Algorithm Search⌘K

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
hash_table.pyRawGitHub
# 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

Read the full page about Hash table