Hopscotch hashing in Python
Open addressing that bounds each key's distance from its home slot with a neighbourhood bitmap.
- File
hopscotch_hashing.py, 142 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/hopscotch-hashing/. The authors are listed in prov/W6T3BN.jsonl.
from collections.abc import Callable
from typing import Any
_EMPTY: Any = object()
_MAX_SLOTS = 1 << 20
class HopscotchHashTable:
"""Hopscotch hashing with m slots and neighbourhood size H.
The home slot of a key k is h(k) mod m. Each key is stored at most H - 1
slots after its home slot (cyclically), and the bitmap hop[i] marks the
slots of the neighbourhood of i that hold keys with home i. Search and
delete look at most H slots, so they cost O(H) in the worst case. Insert
finds the nearest free slot by linear probing and moves it back towards the
home slot by displacements; it costs O(m * H) in the worst case, plus a
resize when no free slot can be brought into the neighbourhood. Growth alone
cannot help when more than H keys share one hash value; then, and when the
table would pass _MAX_SLOTS slots, insert raises OverflowError.
"""
def __init__(self, m: int = 16, H: int = 4, h: Callable[[Any], int] = hash) -> None:
if not 1 <= H <= m:
raise ValueError("need 1 <= H <= m")
self.H, self.h = H, h
self.n = 0
self._clear(m)
def _clear(self, m: int) -> None:
self.m = m
self.key: list[Any] = [_EMPTY] * m
self.val: list[Any] = [None] * m
self.hop: list[int] = [0] * m
def insert(self, k: Any, v: Any) -> None:
"""Set the value of k to v, growing the table when needed."""
i = self.h(k) % self.m # @line L1
for d in range(self.H): # @line L2
s = (i + d) % self.m
if (self.hop[i] >> d) & 1 and self.key[s] == k: # @line L3
self.val[s] = v
return
while not self._place(k, v): # @line L4
if sum(1 for x in self.keys() if self.h(x) == self.h(k)) >= self.H:
raise OverflowError("more than H keys share one hash value")
self._resize(2 * self.m)
self.n += 1 # @line L5
def _place(self, k: Any, v: Any) -> bool:
m, H = self.m, self.H
i = self.h(k) % m # @line L6
e = 0
while e < m and self.key[(i + e) % m] is not _EMPTY: # @line L7
e += 1
if e == m: # @line L8
return False
while e >= H: # @line L9
j = (i + e) % m # @line L10
found = None
for t in range(H - 1, 0, -1): # @line L11
b = (j - t) % m
for d in range(t):
if (self.hop[b] >> d) & 1:
found = (t, d, b)
break
if found:
break
if found is None: # @line L12
return False
t, d, b = found
s = (b + d) % m # @line L13
self.key[j], self.val[j] = self.key[s], self.val[s] # @line L14
self.key[s], self.val[s] = _EMPTY, None
self.hop[b] = (self.hop[b] & ~(1 << d)) | (1 << t)
e -= t - d # @line L15
s = (i + e) % m
self.key[s], self.val[s] = k, v # @line L16
self.hop[i] |= 1 << e
return True
def search(self, k: Any) -> Any:
"""Return the value of k, or None if k is not a key."""
s = self._find(k)
return None if s is None else self.val[s]
def _find(self, k: Any) -> int | None:
i = self.h(k) % self.m # @line L17
for d in range(self.H): # @line L18
s = (i + d) % self.m
if (self.hop[i] >> d) & 1 and self.key[s] == k: # @line L19
return s
return None # @line L20
def contains(self, k: Any) -> bool:
return self._find(k) is not None
def delete(self, k: Any) -> bool:
"""Remove k. Return True if k was a key."""
i = self.h(k) % self.m # @line L21
for d in range(self.H): # @line L22
s = (i + d) % self.m
if (self.hop[i] >> d) & 1 and self.key[s] == k: # @line L23
self.key[s], self.val[s] = _EMPTY, None # @line L24
self.hop[i] &= ~(1 << d)
self.n -= 1
return True
return False
def _resize(self, m2: int) -> None:
pairs = [(self.key[s], self.val[s]) for s in range(self.m) if self.key[s] is not _EMPTY] # @line L25
while True: # @line L26
if m2 > _MAX_SLOTS:
raise OverflowError("the table would be too large")
self._clear(m2) # @line L27
ok = all(self._place(k, v) for k, v in pairs) # @line L28
m2 *= 2 # @line L29
if ok: # @line L30
return
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 (P1) to (P3) of the page."""
ks = self.keys()
assert len(ks) == len(set(ks)) == self.n # (P3)
for i in range(self.m):
assert self.hop[i] >> self.H == 0
for d in range(self.H):
s = (i + d) % self.m
bit = (self.hop[i] >> d) & 1
holds = self.key[s] is not _EMPTY and self.h(self.key[s]) % self.m == i
assert bit == holds # (P2)
for s in range(self.m):
if self.key[s] is not _EMPTY:
assert (s - self.h(self.key[s]) % self.m) % self.m < self.H # (P1)
test_hopscotch_hashing.py 104 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/hopscotch-hashing/. The authors are listed in prov/W6T3BN.jsonl.
import random
import pytest
from hopscotch_hashing import HopscotchHashTable, _EMPTY
def layout(t: HopscotchHashTable) -> list[object]:
return [None if x is _EMPTY else x for x in t.key]
def test_page_example() -> None:
t = HopscotchHashTable(10, 3)
for k in [20, 31, 41, 13, 22, 15]:
t.insert(k, str(k))
t.check_invariant()
assert layout(t) == [20, 31, 41, 13, 22, 15, None, None, None, None]
t.insert(52, "52")
t.check_invariant()
assert layout(t) == [20, 31, 41, 52, 22, 13, 15, None, None, None]
assert t.hop[2] == 0b110 and t.hop[3] == 0b100 and t.hop[5] == 0b010
t.insert(62, "62")
t.check_invariant()
assert t.m == 20 and len(t) == 8
want: list[object] = [None] * 20
for s, k in [(0, 20), (1, 41), (2, 22), (3, 62), (11, 31), (12, 52), (13, 13), (15, 15)]:
want[s] = k
assert layout(t) == want
assert t.search(13) == "13" and t.search(42) is None
assert t.delete(41) and not t.delete(41)
assert len(t) == 7 and t.search(41) is None
t.check_invariant()
def test_place_fails_without_resize() -> None:
t = HopscotchHashTable(10, 3)
for k in [20, 31, 41, 13, 22, 15, 52]:
t.insert(k, k)
before = sorted(t.keys())
assert not t._place(62, 62)
t.check_invariant()
assert sorted(t.keys()) == before
def test_edges() -> None:
t = HopscotchHashTable(1, 1)
assert t.search("a") is None and not t.delete("a") and len(t) == 0
t.insert("a", 1)
t.insert("a", 2)
assert len(t) == 1 and t.search("a") == 2
t.insert("b", None)
assert t.contains("b") and t.search("b") is None
assert t.delete("a") and not t.delete("a")
t.check_invariant()
with pytest.raises(ValueError):
HopscotchHashTable(4, 5)
def test_too_many_equal_hashes() -> None:
t = HopscotchHashTable(8, 3, h=lambda k: 0)
for k in range(3):
t.insert(k, k)
t.check_invariant()
with pytest.raises(OverflowError):
t.insert(3, 3)
assert sorted(t.keys()) == [0, 1, 2]
def test_full_neighbourhoods() -> None:
t = HopscotchHashTable(4, 4, h=lambda k: 7)
for k in range(4):
t.insert(k, k)
assert t.m == 4 and len(t) == 4
t.check_invariant()
assert all(t.search(k) == k for k in range(4))
def test_random_against_dict() -> None:
rng = random.Random(9091)
for _ in range(600):
H = rng.randint(1, 6)
t = HopscotchHashTable(rng.randint(H, 16), H)
ref: dict[int, int] = {}
hi = rng.choice([20, 60, 200])
for _ in range(rng.randint(1, 80)):
k = rng.randrange(hi)
op = rng.random()
if op < 0.55:
v = rng.randrange(1000)
t.insert(k, v)
ref[k] = v
elif op < 0.8:
assert t.delete(k) == (k in ref)
ref.pop(k, None)
else:
assert t.search(k) == ref.get(k)
t.check_invariant()
assert len(t) == len(ref)
assert sorted(t.keys()) == sorted(ref)
for k in range(hi):
assert t.search(k) == ref.get(k)
What the next level needs
- the tests did not run in this build