Double hashing in Python
Open addressing where a second hash function sets the probe step size.
- File
double_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/double-hashing/. The authors are listed in prov/PMZJW6.jsonl.
from collections.abc import Callable
from typing import Any
class _Deleted:
"""The marker of a slot whose key was deleted (a tombstone)."""
def __repr__(self) -> str:
return "DELETED"
DELETED: Any = _Deleted()
def _is_prime(m: int) -> bool:
if m < 2:
return False
d = 2
while d * d <= m:
if m % d == 0:
return False
d += 1
return True
class DoubleHashTable:
"""An open-addressing table with double hashing and a fixed number m of slots.
The probe sequence of key k is (h1(k) + i * h2(k)) mod m for i = 0, 1, ...,
m - 1. Precondition: m is prime and 1 <= h2(k) <= m - 1 for every key, so
the sequence visits every slot once. Each operation probes at most m slots;
under the uniform hashing assumption an unsuccessful search probes at most
1 / (1 - alpha) slots in expectation, where alpha is the fraction of
non-empty slots.
"""
def __init__(
self,
m: int = 13,
h1: Callable[[Any], int] | None = None,
h2: Callable[[Any], int] | None = None,
) -> None:
if not _is_prime(m):
raise ValueError("m must be prime")
self.m = m
self.h1 = h1 if h1 is not None else (lambda k: hash(k) % m)
self.h2 = h2 if h2 is not None else (lambda k: 1 + hash(k) % (m - 2 if m > 3 else 1))
self.slots: list[Any] = [None] * m # None is EMPTY; else DELETED or [key, value]
self.n = 0
def _probe(self, k: Any, i: int) -> int:
step = self.h2(k)
if not 1 <= step <= self.m - 1:
raise ValueError("h2(k) must be in 1 .. m - 1")
return (self.h1(k) + i * step) % self.m
def insert(self, k: Any, v: Any) -> None:
"""Set the value of k to v. Raise OverflowError if the table is full."""
j = None # @line L1
for i in range(self.m): # @line L2
q = self._probe(k, i) # @line L3
s = self.slots[q]
if s is None: # @line L4
if j is None: # @line L5
j = q
break # @line L6
elif s is DELETED: # @line L7
if j is None: # @line L8
j = q
elif s[0] == k: # @line L9
s[1] = v # @line L10
return
if j is None: # @line L11
raise OverflowError("the table is full")
self.slots[j] = [k, v] # @line L12
self.n += 1
def search(self, k: Any) -> Any:
"""Return the value of k, or None if k is not a key."""
for i in range(self.m): # @line L13
q = self._probe(k, i) # @line L14
s = self.slots[q]
if s is None: # @line L15
return None
if s is not DELETED and s[0] == k: # @line L16
return s[1]
return None # @line L17
def delete(self, k: Any) -> bool:
"""Mark the slot of k as DELETED. Return True if k was a key."""
for i in range(self.m): # @line L18
q = self._probe(k, i) # @line L19
s = self.slots[q]
if s is None: # @line L20
return False
if s is not DELETED and s[0] == k: # @line L21
self.slots[q] = DELETED
self.n -= 1
return True
return False
def __len__(self) -> int:
return self.n
def keys(self) -> list[Any]:
return [s[0] for s in self.slots if s is not None and s is not DELETED]
def check_invariant(self) -> None:
"""Assert (D1) and (D2) of the page, and the count."""
ks = self.keys()
assert len(ks) == len(set(ks)) == self.n # (D2) and the count
for q, s in enumerate(self.slots):
if s is None or s is DELETED:
continue
i = 0
while self._probe(s[0], i) != q: # (D1): every earlier probe is non-empty
assert self.slots[self._probe(s[0], i)] is not None
i += 1
test_double_hashing.py 85 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/double-hashing/. The authors are listed in prov/PMZJW6.jsonl.
import random
import pytest
from double_hashing import DELETED, DoubleHashTable
def layout(t: DoubleHashTable) -> list:
return [None if s is None else ("D" if s is DELETED else s[0]) for s in t.slots]
def test_page_example() -> None:
t = DoubleHashTable(13, h1=lambda k: k % 13, h2=lambda k: 1 + k % 11)
for k in [18, 41, 22, 44, 59, 32, 31, 73]:
t.insert(k, -k)
t.check_invariant()
assert layout(t) == [None, None, 41, None, 32, 18, 44, 59, 73, 22, None, None, 31]
assert t.delete(44)
assert t.search(32) == -32 # probes slot 6 (DELETED), then slot 4
assert t.search(57) is None
t.insert(19, -19) # path 6 (DELETED), 2, 11 (EMPTY): reuses slot 6
assert t.slots[6] == [19, -19]
t.check_invariant()
def test_default_hashes_match_example() -> None:
t = DoubleHashTable(13)
for k in [18, 41, 22, 44, 59, 32, 31, 73]:
t.insert(k, k)
assert layout(t) == [None, None, 41, None, 32, 18, 44, 59, 73, 22, None, None, 31]
def test_edges() -> None:
with pytest.raises(ValueError):
DoubleHashTable(12)
t = DoubleHashTable(2)
assert t.search(5) is None and not t.delete(5)
t.insert(5, "a")
t.insert(5, "b")
assert len(t) == 1 and t.search(5) == "b"
t.insert(6, "c")
with pytest.raises(OverflowError):
t.insert(7, "d")
assert t.delete(5)
t.insert(7, "d") # reuses the DELETED slot of a full table
assert t.search(7) == "d" and t.search(6) == "c" and t.search(5) is None
t.check_invariant()
def test_fill_every_slot() -> None:
t = DoubleHashTable(31)
for k in range(31):
t.insert(k * 1000, k)
t.check_invariant()
assert all(t.search(k * 1000) == k for k in range(31))
def test_random_against_dict() -> None:
rng = random.Random(7331)
for _ in range(600):
m = rng.choice([2, 3, 5, 7, 11, 13, 17])
t = DoubleHashTable(m)
model: dict[int, int] = {}
for _ in range(rng.randint(0, 50)):
k = rng.randint(-30, 30)
r = rng.random()
if r < 0.45:
v = rng.randint(0, 9)
if k in model or len(t) < m:
try:
t.insert(k, v)
model[k] = v
except OverflowError:
assert k not in model and all(s is not None and s is not DELETED for s in t.slots)
elif r < 0.75:
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