Hash table in Python
Array of buckets indexed by a hash function, giving expected O(1) dictionary operations.
- File
hash_table.py, 95 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/hash-table/. The authors are listed in prov/42GYZ4.jsonl.
from collections.abc import Callable, Hashable, Iterator
from typing import Generic, TypeVar
K = TypeVar("K", bound=Hashable)
V = TypeVar("V")
class HashTable(Generic[K, V]):
"""A hash table with separate chaining and doubling.
Each bucket is a list of [key, value] pairs. The table doubles its number
of buckets when it has more keys than buckets, so the load factor stays at
most 1. With a hash function that spreads the keys well, each operation
takes O(1) expected amortized time; in the worst case it takes O(n).
"""
def __init__(self, m: int = 4, h: Callable[[K], int] = hash) -> None:
if m < 1:
raise ValueError("the number of buckets must be at least 1")
self.h = h
self.m = m
self.n = 0
self.buckets: list[list[list]] = [[] for _ in range(m)]
def _bucket(self, k: K, m: int) -> int:
return self.h(k) % m
def insert(self, k: K, v: V) -> None:
"""Set the value of k to v. Replace the old value if k is a key."""
i = self._bucket(k, self.m) # @line L1
for pair in self.buckets[i]: # @line L2
if pair[0] == k: # @line L3
pair[1] = v
return
self.buckets[i].append([k, v]) # @line L4
self.n += 1 # @line L5
if self.n > self.m: # @line L6
self.resize(2 * self.m)
def search(self, k: K) -> V | None:
"""Return the value of k, or None if k is not a key."""
i = self._bucket(k, self.m) # @line L7
for pair in self.buckets[i]: # @line L8
if pair[0] == k: # @line L9
return pair[1]
return None # @line L10
def contains(self, k: K) -> bool:
"""Return True if k is a key (also when its value is None)."""
i = self._bucket(k, self.m)
return any(pair[0] == k for pair in self.buckets[i])
def delete(self, k: K) -> bool:
"""Remove k and its value. Return True if k was a key."""
i = self._bucket(k, self.m) # @line L11
bucket = self.buckets[i]
for p in range(len(bucket)): # @line L12
if bucket[p][0] == k: # @line L13
bucket[p] = bucket[-1]
bucket.pop()
self.n -= 1
return True
return False
def resize(self, m2: int) -> None:
"""Move every pair to a new array of m2 buckets. Cost O(n + m + m2)."""
new: list[list[list]] = [[] for _ in range(m2)] # @line L14
for bucket in self.buckets: # @line L15
for pair in bucket:
new[self._bucket(pair[0], m2)].append(pair) # @line L16
self.buckets, self.m = new, m2 # @line L17
def __len__(self) -> int:
return self.n
def items(self) -> Iterator[tuple[K, V]]:
"""Yield the pairs in bucket order."""
for bucket in self.buckets:
for k, v in bucket:
yield k, v
def check_invariant(self) -> None:
"""Assert (H1) to (H4) of the page."""
assert len(self.buckets) == self.m
keys = []
for i, bucket in enumerate(self.buckets):
for k, _ in bucket:
assert self._bucket(k, self.m) == i # (H1)
keys.append(k)
assert len(keys) == len(set(keys)) # (H2)
assert self.n == len(keys) # (H3)
assert self.n <= self.m # (H4)
test_hash_table.py 81 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/hash-table/. The authors are listed in prov/42GYZ4.jsonl.
import random
from hash_table import HashTable
def keys_of(t: HashTable) -> list[list[int]]:
return [[k for k, _ in b] for b in t.buckets]
def test_page_example() -> None:
t: HashTable[int, str] = HashTable(4)
states = []
for k in [10, 22, 31, 4, 15, 28, 17]:
t.insert(k, str(k))
t.check_invariant()
states.append(keys_of(t))
assert states[3] == [[4], [], [10, 22], [31]]
assert states[4] == [[], [], [10], [], [4], [], [22], [31, 15]]
assert states[6] == [[], [17], [10], [], [4, 28], [], [22], [31, 15]]
assert t.m == 8 and len(t) == 7
assert t.search(28) == "28"
assert t.search(12) is None
assert t.delete(22)
assert len(t) == 6 and t.search(22) is None
t.check_invariant()
def test_edges() -> None:
t: HashTable[str, int] = HashTable(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) # type: ignore[arg-type]
assert t.contains("b") and t.search("b") is None
assert t.delete("a") and not t.delete("a")
t.check_invariant()
def test_all_keys_collide() -> None:
t: HashTable[int, int] = HashTable(4, h=lambda k: 0)
for k in range(50):
t.insert(k, k * k)
t.check_invariant()
assert all(t.search(k) == k * k for k in range(50))
assert sum(1 for b in t.buckets if b) == 1
def test_big_and_negative_keys() -> None:
t: HashTable[int, int] = HashTable()
ks = [2**63 - 1, -(2**63), -1, -2, 0, 2**64 + 5]
for k in ks:
t.insert(k, k)
t.check_invariant()
assert all(t.search(k) == k for k in ks)
def test_random_against_dict() -> None:
rng = random.Random(4242)
for trial in range(600):
h = (lambda k: k % 3) if trial % 5 == 0 else hash
t: HashTable[int, int] = HashTable(rng.randint(1, 5), h=h)
model: dict[int, int] = {}
for _ in range(rng.randint(0, 60)):
k = rng.randint(-20, 20)
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