Linear probing in Python
Open addressing that probes consecutive slots h(k), h(k)+1, and so on.
- File
linear_probing.py, 125 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/linear-probing/. The authors are listed in prov/J1RWHQ.jsonl.
from collections.abc import Callable, Hashable, Iterator
from typing import Generic, TypeVar
K = TypeVar("K", bound=Hashable)
V = TypeVar("V")
_EMPTY = object()
class LinearProbingTable(Generic[K, V]):
"""A map from keys to values, stored by linear probing in one array.
Slot j holds a key and its value, or is empty. The table keeps at least
one empty slot, and it doubles its size before the load factor passes 1/2.
Deletion shifts later keys of the run back (Knuth's Algorithm R), so the
table has no tombstones. With a good hash function, each operation takes
expected O(1) time; the worst case is O(m) for m slots.
"""
def __init__(self, m: int = 8, home: Callable[[K, int], int] | None = None) -> None:
if m < 2:
raise ValueError("the table needs at least 2 slots")
self._home_fn = home if home is not None else (lambda k, size: hash(k) % size)
self._keys: list[object] = [_EMPTY] * m
self._vals: list[object] = [None] * m
self._n = 0
def __len__(self) -> int:
return self._n
@property
def capacity(self) -> int:
return len(self._keys)
def _home(self, k: K) -> int:
return self._home_fn(k, len(self._keys)) % len(self._keys)
def insert(self, k: K, v: V) -> int:
"""Store k with value v (replace the value if k is present). Return the slot of k."""
if 2 * (self._n + 1) > len(self._keys):
self._resize(2 * len(self._keys))
t, m = self._keys, len(self._keys)
j = self._home(k) # @line L1
while t[j] is not _EMPTY: # @line L2
if t[j] == k: # @line L3
self._vals[j] = v
return j
j = (j + 1) % m # @line L4
t[j] = k # @line L5
self._vals[j] = v
self._n += 1
return j # @line L6
def search(self, k: K) -> int | None:
"""Return the slot of k, or None if k is not in the table."""
t, m = self._keys, len(self._keys)
j = self._home(k) # @line L7
while t[j] is not _EMPTY: # @line L8
if t[j] == k: # @line L9
return j
j = (j + 1) % m # @line L10
return None # @line L11
def get(self, k: K, default: V | None = None) -> V | None:
j = self.search(k)
return default if j is None else self._vals[j] # type: ignore[return-value]
def __contains__(self, k: object) -> bool:
return self.search(k) is not None # type: ignore[arg-type]
def delete(self, k: K) -> bool:
"""Remove k. Return True if k was present. Later keys of the run move back."""
t, m = self._keys, len(self._keys)
i = self.search(k) # @line L12
if i is None: # @line L13
return False
t[i] = _EMPTY # @line L14
self._vals[i] = None
self._n -= 1
j = i # @line L15
while True: # @line L16
j = (j + 1) % m # @line L17
if t[j] is _EMPTY: # @line L18
return True
r = self._home(t[j]) # type: ignore[arg-type] # @line L19
if 0 < (r - i) % m <= (j - i) % m: # @line L20: r is in (i, j], so T[j] stays
continue
t[i], self._vals[i] = t[j], self._vals[j] # @line L21
t[j], self._vals[j] = _EMPTY, None
i = j
def items(self) -> Iterator[tuple[K, V]]:
for key, val in zip(self._keys, self._vals):
if key is not _EMPTY:
yield key, val # type: ignore[misc]
def slots(self) -> list[K | None]:
"""The keys slot by slot, with None for an empty slot (for tests and examples)."""
return [None if key is _EMPTY else key for key in self._keys] # type: ignore[misc]
def check_invariant(self) -> None:
"""Each key is reachable from its home slot over full slots; keys are distinct."""
t, m = self._keys, len(self._keys)
assert sum(key is not _EMPTY for key in t) == self._n < m
seen = set()
for p, key in enumerate(t):
if key is _EMPTY:
continue
assert key not in seen
seen.add(key)
j = self._home(key) # type: ignore[arg-type]
while j != p:
assert t[j] is not _EMPTY
j = (j + 1) % m
def _resize(self, new_m: int) -> None:
old = list(self.items())
self._keys = [_EMPTY] * new_m
self._vals = [None] * new_m
self._n = 0
for key, val in old:
self.insert(key, val)
test_linear_probing.py 89 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/linear-probing/. The authors are listed in prov/J1RWHQ.jsonl.
import random
from linear_probing import LinearProbingTable
def mod_home(k: int, m: int) -> int:
return k % m
def table_of_page() -> LinearProbingTable[int, str]:
t: LinearProbingTable[int, str] = LinearProbingTable(16, mod_home)
for k in (18, 34, 50, 20, 31, 47, 15):
t.insert(k, str(k))
t.check_invariant()
return t
def test_example() -> None:
t = table_of_page()
s = t.slots()
assert s == [47, 15, 18, 34, 50, 20] + [None] * 9 + [31]
assert t.search(50) == 4 and t.search(66) is None
assert t.delete(18)
t.check_invariant()
assert t.slots() == [47, 15, 34, 50, 20] + [None] * 10 + [31]
assert t.delete(31)
t.check_invariant()
assert t.slots() == [15, None, 34, 50, 20] + [None] * 10 + [47]
def test_edges() -> None:
t: LinearProbingTable[int, int] = LinearProbingTable(2, mod_home)
assert t.search(0) is None and not t.delete(0) and len(t) == 0
t.insert(5, 1)
t.insert(5, 2)
assert len(t) == 1 and t.get(5) == 2
assert t.delete(5) and len(t) == 0 and 5 not in t
t.check_invariant()
# all keys share one home slot
u: LinearProbingTable[int, int] = LinearProbingTable(64, lambda k, m: 7)
for k in range(20):
u.insert(k, k)
u.check_invariant()
for k in range(0, 20, 2):
assert u.delete(k)
u.check_invariant()
assert sorted(k for k, _ in u.items()) == list(range(1, 20, 2))
# large 64-bit keys
w: LinearProbingTable[int, int] = LinearProbingTable()
big = [2**63 - 1, -(2**63), 2**64 + 3]
for k in big:
w.insert(k, k)
assert all(w.get(k) == k for k in big)
def test_wraparound_delete() -> None:
t: LinearProbingTable[int, int] = LinearProbingTable(8, mod_home)
for k in (7, 15, 23): # slots 7, 0, 1
t.insert(k, 0)
assert t.slots()[7] == 7 and t.slots()[0] == 15 and t.slots()[1] == 23
t.delete(7)
t.check_invariant()
assert t.slots()[7] == 15 and t.slots()[0] == 23 and t.slots()[1] is None
def test_random_against_dict() -> None:
rng = random.Random(4127)
for trial in range(500):
mod = rng.randint(1, 12)
t: LinearProbingTable[int, int] = LinearProbingTable(rng.choice([2, 4, 8]), lambda k, m, mod=mod: (k % mod) * 3 + k // 7)
oracle: dict[int, int] = {}
for _ in range(rng.randint(0, 80)):
k = rng.randint(-20, 40)
op = rng.random()
if op < 0.5:
v = rng.randint(0, 999)
t.insert(k, v)
oracle[k] = v
elif op < 0.8:
assert t.delete(k) == (k in oracle)
oracle.pop(k, None)
else:
assert t.get(k) == oracle.get(k)
t.check_invariant()
assert len(t) == len(oracle)
assert dict(t.items()) == oracle
What the next level needs
- the tests did not run in this build