Extendible hashing in Python
Disk-oriented hashing with a directory that doubles, splitting only overflowing buckets.
- File
extendible_hashing.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/extendible-hashing/. The authors are listed in prov/X591T6.jsonl.
from collections.abc import Callable, Hashable, Iterator
from typing import Any
W = 64 # default number of bits of a hash value
def hash64(k: Hashable) -> int:
"""The built-in hash, as a non-negative 64-bit value."""
return hash(k) % (1 << W)
class Bucket:
"""A bucket: a list of [key, value] pairs and a local depth."""
def __init__(self, depth: int) -> None:
self.depth = depth
self.pairs: list[list[Any]] = []
class ExtendibleHashing:
"""A dictionary stored by extendible hashing.
A directory of 2**d pointers sends key k to the bucket dir[h(k) mod 2**d].
Each bucket holds at most b pairs (more only when its local depth is W).
A full bucket splits on one more bit; the directory doubles only when the
bucket's local depth equals d. Search and delete cost O(b). An insert costs
O(b + 2**d) in the worst case (W is a constant).
"""
def __init__(self, b: int = 4, h: Callable[[Hashable], int] = hash64, w: int = W) -> None:
if b < 1:
raise ValueError("the bucket capacity must be at least 1")
self.b = b
self.w = w
self.h = lambda k: h(k) % (1 << w)
self.d = 0
self.dir: list[Bucket] = [Bucket(0)]
self.n = 0
def _find(self, bucket: Bucket, k: Hashable) -> int:
for p, pair in enumerate(bucket.pairs):
if pair[0] == k:
return p
return -1
def search(self, k: Hashable) -> Any:
"""Return the value of k, or None if k is not a key."""
bucket = self.dir[self.h(k) % (1 << self.d)] # @line L1
p = self._find(bucket, k)
if p >= 0: # @line L2
return bucket.pairs[p][1]
return None # @line L3
def insert(self, k: Hashable, v: Any) -> None:
"""Set the value of k to v."""
hk = self.h(k)
bucket = self.dir[hk % (1 << self.d)] # @line L4
p = self._find(bucket, k)
if p >= 0: # @line L5
bucket.pairs[p][1] = v
return
while len(bucket.pairs) >= self.b and bucket.depth < self.w: # @line L6
if bucket.depth == self.d: # @line L7
self.dir = self.dir + self.dir
self.d += 1
bucket.depth += 1 # @line L8
new = Bucket(bucket.depth)
bit = bucket.depth - 1
keep = [q for q in bucket.pairs if not (self.h(q[0]) >> bit) & 1] # @line L9
new.pairs = [q for q in bucket.pairs if (self.h(q[0]) >> bit) & 1]
bucket.pairs = keep
for i in range(len(self.dir)): # @line L10
if self.dir[i] is bucket and (i >> bit) & 1:
self.dir[i] = new
bucket = self.dir[hk % (1 << self.d)] # @line L11
bucket.pairs.append([k, v]) # @line L12
self.n += 1
def delete(self, k: Hashable) -> bool:
"""Remove k. Return True if k was a key. Buckets are not merged."""
bucket = self.dir[self.h(k) % (1 << self.d)] # @line L13
p = self._find(bucket, k)
if p >= 0: # @line L14
bucket.pairs.pop(p)
self.n -= 1
return True
return False
def __len__(self) -> int:
return self.n
def buckets(self) -> list[Bucket]:
"""The distinct buckets, in order of their first directory entry."""
seen: dict[int, Bucket] = {}
for bucket in self.dir:
seen.setdefault(id(bucket), bucket)
return list(seen.values())
def items(self) -> Iterator[tuple[Any, Any]]:
for bucket in self.buckets():
for k, v in bucket.pairs:
yield k, v
def check_invariant(self) -> None:
"""Assert (E1) to (E5) of the page."""
assert len(self.dir) == 1 << self.d
keys = []
where: dict[int, list[int]] = {}
for i, x in enumerate(self.dir):
where.setdefault(id(x), []).append(i)
for bucket in self.buckets():
ell = bucket.depth
assert 0 <= ell <= self.d
idx = where[id(bucket)]
pat = idx[0] % (1 << ell)
assert idx == list(range(pat, len(self.dir), 1 << ell)) # (E1)
for k, _ in bucket.pairs:
assert self.h(k) % (1 << ell) == pat # (E2)
keys.append(k)
assert len(bucket.pairs) <= self.b or ell == self.w # (E5)
assert len(keys) == len(set(keys)) # (E3)
assert self.n == len(keys) # (E4)
test_extendible_hashing.py 84 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/extendible-hashing/. The authors are listed in prov/X591T6.jsonl.
import random
from extendible_hashing import ExtendibleHashing
def layout(t: ExtendibleHashing) -> tuple[int, list[int], list[tuple[int, list[int]]]]:
bs = t.buckets()
return t.d, [next(j for j, s in enumerate(bs) if s is x) for x in t.dir], [
(x.depth, [k for k, _ in x.pairs]) for x in bs
]
def test_page_example() -> None:
t = ExtendibleHashing(b=2, h=lambda k: k)
for k in [4, 12, 7, 9, 2, 16, 6]:
t.insert(k, str(k))
t.check_invariant()
d, dirs, bs = layout(t)
assert d == 3
# buckets in order of first entry: A, B, C, D
assert dirs == [0, 1, 2, 1, 3, 1, 2, 1]
assert bs == [(3, [16]), (1, [7, 9]), (2, [2, 6]), (3, [4, 12])]
assert t.search(12) == "12" and t.search(5) is None
assert t.delete(9) and not t.delete(9) and len(t) == 6
t.check_invariant()
def test_split_with_empty_side() -> None:
t = ExtendibleHashing(b=2, h=lambda k: k)
for k in [0, 8, 16]: # equal in the low 3 bits: needs 4 splits on one insert
t.insert(k, k)
t.check_invariant()
assert t.d == 4 and all(t.search(k) == k for k in [0, 8, 16])
def test_equal_hash_values_overflow() -> None:
u = ExtendibleHashing(b=1, w=5)
u.insert(-1, 1)
u.insert(-2, 2) # same built-in hash value
u.check_invariant()
assert u.d == 5 and u.search(-1) == 1 and u.search(-2) == 2
t = ExtendibleHashing(b=1, h=lambda k: 5, w=3)
for k in range(4):
t.insert(k, k)
t.check_invariant()
assert all(t.search(k) == k for k in range(4)) and len(t) == 4
assert t.d == 3 and len(t.dir) == 8
def test_edges() -> None:
t = ExtendibleHashing(b=1, w=16)
assert t.search("a") is None and not t.delete("a") and len(t) == 0
t.insert("a", 1)
t.insert("a", 2)
assert t.search("a") == 2 and len(t) == 1
for k in [2**63 - 1, -(2**63), -1, 0, 2**64 + 3]:
t.insert(k, k)
t.check_invariant()
assert all(t.search(k) == k for k in [2**63 - 1, -(2**63), -1, 0, 2**64 + 3])
def test_random_against_dict() -> None:
rng = random.Random(1979)
for _ in range(600):
t = ExtendibleHashing(b=rng.randint(1, 4), w=12) # hash(-1) == hash(-2) in Python
model: dict[int, int] = {}
for _ in range(rng.randint(0, 60)):
k = rng.randint(-40, 40)
op = rng.random()
if op < 0.5:
v = rng.randint(0, 99)
t.insert(k, v)
model[k] = v
elif op < 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 dict(t.items()) == model
What the next level needs
- the tests did not run in this build