Coalesced hashing in Python
Hybrid of chaining and open addressing in which chains share the table space.
- File
coalesced_hashing.py, 121 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/coalesced-hashing/. The authors are listed in prov/E023N7.jsonl.
from collections.abc import Callable
from typing import Any
_EMPTY: Any = object()
class CoalescedHashTable:
"""Coalesced hashing with m slots (Knuth's Algorithm C, late insertion).
Each slot holds a key, a value and the index of the next slot of its chain.
A new key that collides is put in the highest-numbered empty slot and linked
to the end of the chain that starts at its home slot h(k). Chains of
different home slots can merge (coalesce). Search and insert cost O(1 + L),
where L is the length of the chain from h(k); this is O(n) in the worst case
and O(1) expected for a random hash function, even for a full table.
Delete is not part of the basic method; here it rebuilds the table in O(m).
"""
def __init__(self, m: int = 11, h: Callable[[Any], int] | None = None) -> None:
if m < 1:
raise ValueError("m must be at least 1")
self.m = m
self.h = h if h is not None else (lambda k: hash(k) % m)
self.key: list[Any] = [_EMPTY] * m
self.val: list[Any] = [None] * m
self.next: list[int | None] = [None] * m
self.r = m # every slot with index >= r is occupied
self.n = 0
def insert(self, k: Any, v: Any) -> None:
"""Set the value of k to v. Raise OverflowError if the table is full."""
i = self.h(k) # @line L1
if self.key[i] is _EMPTY: # @line L2
self.key[i], self.val[i], self.next[i] = k, v, None
self.n += 1
return
while True: # @line L3
if self.key[i] == k: # @line L4
self.val[i] = v
return
nxt = self.next[i]
if nxt is None: # @line L5
break
i = nxt # @line L6
self.r -= 1 # @line L7
while self.r >= 0 and self.key[self.r] is not _EMPTY:
self.r -= 1
if self.r < 0: # @line L8
self.r = 0
raise OverflowError("the table is full")
self.next[i] = self.r # @line L9
i = self.r
self.key[i], self.val[i], self.next[i] = k, v, None # @line L10
self.n += 1
def search(self, k: Any) -> Any:
"""Return the value of k, or None if k is not a key."""
i: int | None = self.h(k) # @line L11
if self.key[i] is _EMPTY: # @line L12
return None
while i is not None: # @line L13
if self.key[i] == k: # @line L14
return self.val[i]
i = self.next[i] # @line L15
return None # @line L16
def delete(self, k: Any) -> bool:
"""Remove k by rebuilding the table without it. Cost O(m) plus the reinserts."""
if not self._contains(k):
return False
pairs = [(self.key[i], self.val[i]) for i in range(self.m) if self.key[i] is not _EMPTY and self.key[i] != k]
self.key = [_EMPTY] * self.m
self.val = [None] * self.m
self.next = [None] * self.m
self.r, self.n = self.m, 0
for kk, vv in pairs:
self.insert(kk, vv)
return True
def _contains(self, k: Any) -> bool:
i: int | None = self.h(k)
if self.key[i] is _EMPTY:
return False
while i is not None:
if self.key[i] == k:
return True
i = self.next[i]
return False
def __len__(self) -> int:
return self.n
def keys(self) -> list[Any]:
return [x for x in self.key if x is not _EMPTY]
def check_invariant(self) -> None:
"""Assert (C1) to (C4) of the page."""
ks = self.keys()
assert len(ks) == len(set(ks)) == self.n # (C3)
preds = [0] * self.m
for i in range(self.m):
j = self.next[i]
if self.key[i] is _EMPTY:
assert j is None
elif j is not None:
assert self.key[j] is not _EMPTY # (C2)
preds[j] += 1
assert all(p <= 1 for p in preds) # (C2): at most one predecessor
for i in range(self.m):
if self.key[i] is _EMPTY:
continue
j: int | None = self.h(self.key[i]) # (C1): reachable from the home slot
steps = 0
while j != i:
assert j is not None and steps <= self.m
j = self.next[j]
steps += 1
assert all(self.key[i] is not _EMPTY for i in range(self.r, self.m)) # (C4)
test_coalesced_hashing.py 70 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/coalesced-hashing/. The authors are listed in prov/E023N7.jsonl.
import random
import pytest
from coalesced_hashing import CoalescedHashTable
def test_page_example() -> None:
t = CoalescedHashTable(11)
for k in [27, 18, 29, 28, 39, 13, 16, 38, 21]:
t.insert(k, str(k))
t.check_invariant()
keys = [None if not isinstance(x, int) else x for x in t.key]
assert keys == [None, None, 13, 21, 38, 27, 28, 18, 16, 39, 29]
assert t.next == [None, None, None, None, None, 8, 9, 10, 4, None, 3]
assert t.r == 3
assert t.search(21) == "21" # probes 10, 3
assert t.search(40) is None # probes 7, 10, 3
assert t.search(33) is None # home slot 0 is empty
def test_edges() -> None:
t = CoalescedHashTable(1)
assert t.search(0) is None and not t.delete(0)
t.insert(0, "a")
t.insert(0, "b")
assert len(t) == 1 and t.search(0) == "b"
with pytest.raises(OverflowError):
t.insert(1, "c")
assert t.delete(0)
t.insert(1, "c")
assert t.search(1) == "c" and t.search(0) is None
t.check_invariant()
u = CoalescedHashTable(5, h=lambda k: 0)
for k in range(5):
u.insert(k, k)
u.check_invariant()
assert [u.search(k) for k in range(5)] == [0, 1, 2, 3, 4]
with pytest.raises(OverflowError):
u.insert(9, 9)
def test_random_against_dict() -> None:
rng = random.Random(5150)
for _ in range(600):
m = rng.randint(1, 15)
t = CoalescedHashTable(m)
model: dict[int, int] = {}
for _ in range(rng.randint(0, 40)):
k = rng.randint(-25, 25)
r = rng.random()
if r < 0.5:
v = rng.randint(0, 9)
if k in model or len(model) < m:
t.insert(k, v)
model[k] = v
else:
with pytest.raises(OverflowError):
t.insert(k, v)
elif r < 0.65:
assert t.delete(k) == (k in model)
model.pop(k, None)
else:
assert t.search(k) == model.get(k)
t.check_invariant()
assert len(t) == len(model)
assert sorted(t.keys()) == sorted(model)
What the next level needs
- the tests did not run in this build