Dynamic perfect hashing in Python
Extends FKS to support updates with worst-case O(1) lookups.
- File
dynamic_perfect_hashing.py, 139 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/dynamic-perfect-hashing/. The authors are listed in prov/F7B69T.jsonl.
import random
P61 = (1 << 61) - 1 # a Mersenne prime
SIGMA = 512 # the bound of line L8: total second-level slots at most SIGMA * M
SIGMA0 = 64 # the bound of line L22
def h_ab(a: int, b: int, p: int, m: int, x: int) -> int:
"""The Carter-Wegman hash ((a x + b) mod p) mod m."""
return ((a * x + b) % p) % m
class DynamicPerfectHash:
"""A dynamic two-level perfect hash table for integer keys in [0, p).
Search reads one slot: O(1) time in the worst case. Insert and delete
take O(1) expected amortized time. The space is O(n + 1) words in every
state. The random choices come from rng, so a test can fix them.
"""
def __init__(self, rng: random.Random | None = None, p: int = P61) -> None:
self.rng = rng if rng is not None else random.Random()
self.p = p
self.rehash_all(set())
# ----------------------------------------------------------- search
def search(self, x: int) -> bool:
j = h_ab(self.a, self.b, self.p, self.M, x) # @line L14
a_j, b_j = self.params[j]
return self.tables[j][h_ab(a_j, b_j, self.p, len(self.tables[j]), x)] == x # @line L15
# ----------------------------------------------------------- updates
def insert(self, x: int) -> None:
if not 0 <= x < self.p:
raise ValueError("key out of range")
if self.search(x): # @line L1
return
self.u += 1 # @line L2
if self.u > (self.M + 1) // 2: # @line L3
self.rehash_all(self.keys() | {x})
return
j = h_ab(self.a, self.b, self.p, self.M, x) # @line L4
self.buckets[j].add(x)
self.c[j] += 1
t = self.tables[j]
i = self._slot(j, x)
if self.c[j] <= self.L[j] and t[i] is None: # @line L5
t[i] = x
return
if self.c[j] > self.L[j] and len(self.buckets[j]) > self.L[j] / 2: # @line L6
self.space += 6 * self.L[j] ** 2 # 2 (2L)^2 - 2 L^2
self.L[j] *= 2 # @line L7
if self.space > SIGMA * self.M: # @line L8
self.rehash_all(self.keys())
return
self.rebuild_bucket(j) # @line L9
def delete(self, x: int) -> None:
if not 0 <= x < self.p or not self.search(x): # @line L10
return
self.u += 1 # @line L11
if self.u > (self.M + 1) // 2: # @line L12
self.rehash_all(self.keys() - {x})
return
j = h_ab(self.a, self.b, self.p, self.M, x) # @line L13
self.tables[j][self._slot(j, x)] = None
self.buckets[j].discard(x)
# ----------------------------------------------------------- rebuilds
def _slot(self, j: int, x: int) -> int:
a_j, b_j = self.params[j]
return h_ab(a_j, b_j, self.p, len(self.tables[j]), x)
def rebuild_bucket(self, j: int) -> None:
size = 2 * self.L[j] ** 2
if len(self.tables[j]) != size: # @line L16
self.tables[j] = [None] * size
else:
t = self.tables[j]
for y in self.buckets[j]: # by (D2) only these slots can be in use
t[self._slot(j, y)] = None
t = self.tables[j]
keys = list(self.buckets[j])
while True:
a_j, b_j = self.rng.randrange(1, self.p), self.rng.randrange(self.p) # @line L17
slots = [h_ab(a_j, b_j, self.p, size, x) for x in keys]
if len(set(slots)) == len(slots): # @line L18
break
self.params[j] = (a_j, b_j)
for x, i in zip(keys, slots): # @line L19
t[i] = x
self.c[j] = len(keys)
def rehash_all(self, s: set[int]) -> None:
self.M = max(len(s), 1) # @line L20
self.u = 0
while True:
self.a, self.b = self.rng.randrange(1, self.p), self.rng.randrange(self.p) # @line L21
self.buckets = [set() for _ in range(self.M)]
for x in s:
self.buckets[h_ab(self.a, self.b, self.p, self.M, x)].add(x)
if sum(8 * max(len(b), 1) ** 2 for b in self.buckets) <= SIGMA0 * self.M: # @line L22
break
self.L = [2 * max(len(b), 1) for b in self.buckets] # @line L23
self.space = sum(2 * l * l for l in self.L) # kept equal to the sum of line L8
self.c = [0] * self.M
self.params = [(1, 0)] * self.M
self.tables: list[list[int | None]] = [[] for _ in range(self.M)]
for j in range(self.M):
self.rebuild_bucket(j)
# ----------------------------------------------------------- helpers
def keys(self) -> set[int]:
out: set[int] = set()
for b in self.buckets:
out |= b
return out
def __len__(self) -> int:
return sum(len(b) for b in self.buckets)
def check_invariant(self) -> None:
"""Assert (D1) to (D5) of the page."""
n = len(self)
assert len(self.buckets) == self.M == len(self.tables) == len(self.L)
for j, b in enumerate(self.buckets):
assert all(h_ab(self.a, self.b, self.p, self.M, x) == j for x in b) # (D1)
t = self.tables[j]
assert len(t) == 2 * self.L[j] ** 2
stored = [x for x in t if x is not None]
assert sorted(stored) == sorted(b) # (D2)
assert all(t[self._slot(j, x)] == x for x in b)
assert len(b) <= self.c[j] <= self.L[j] # (D3)
assert sum(len(t) for t in self.tables) == self.space <= SIGMA * self.M # (D4)
assert self.u <= (self.M + 1) // 2 # (D5)
assert self.M <= 2 * n + 1
test_dynamic_perfect_hashing.py 79 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/dynamic-perfect-hashing/. The authors are listed in prov/F7B69T.jsonl.
import random
import pytest
from dynamic_perfect_hashing import P61, DynamicPerfectHash
def state(d: DynamicPerfectHash) -> tuple:
return d.M, d.u, [sorted(b) for b in d.buckets], list(d.L), sum(len(t) for t in d.tables)
def test_page_example() -> None:
d = DynamicPerfectHash(random.Random(5), p=101)
got = []
for x in [10, 22, 37, 40, 52, 60, 70]:
d.insert(x)
d.check_invariant()
got.append(state(d))
assert got[1] == (2, 0, [[22], [10]], [2, 2], 16)
assert got[3] == (4, 0, [[40], [10, 22], [], [37]], [2, 4, 2, 2], 56)
assert got[5] == (4, 2, [[40, 52, 60], [10, 22], [], [37]], [4, 4, 2, 2], 80)
assert got[6][0] == 7 and got[6][1] == 0 and got[6][4] == 144
d.delete(37)
d.check_invariant()
assert d.search(22) and not d.search(37) and len(d) == 6
def test_edges() -> None:
d = DynamicPerfectHash(random.Random(1))
assert not d.search(0) and len(d) == 0
d.delete(5)
d.insert(5)
d.insert(5)
assert len(d) == 1 and d.search(5)
d.delete(5)
assert len(d) == 0 and not d.search(5)
for x in [0, P61 - 1, 2**40]:
d.insert(x)
d.check_invariant()
assert d.search(P61 - 1) and d.search(0)
with pytest.raises(ValueError):
d.insert(P61)
def test_insert_delete_same_key_many_times() -> None:
d = DynamicPerfectHash(random.Random(2), p=1009)
for x in range(20):
d.insert(x)
for _ in range(500):
d.delete(3)
d.insert(3)
d.check_invariant()
assert d.keys() == set(range(20))
def test_random_against_set() -> None:
rng = random.Random(1994)
for _ in range(600):
p = rng.choice([101, 1009, P61])
d = DynamicPerfectHash(rng, p=p)
model: set[int] = set()
universe = rng.randint(1, 80)
for _ in range(rng.randint(0, 80)):
x = rng.randrange(universe)
r = rng.random()
if r < 0.5:
d.insert(x)
model.add(x)
elif r < 0.8:
d.delete(x)
model.discard(x)
else:
assert d.search(x) == (x in model)
d.check_invariant()
assert d.keys() == model
assert all(d.search(x) == (x in model) for x in range(universe))
What the next level needs
- the tests did not run in this build