Algorithm Search⌘K

Dynamic perfect hashing in Python

Extends FKS to support updates with worst-case O(1) lookups.

File
dynamic_perfect_hashing.py, 139 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
dynamic_perfect_hashing.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/dynamic-perfect-hashing/. The authors are listed in prov/F7B69T.jsonl.

import random

P61 = (1 << 61) - 1  # a Mersenne prime
SIGMA = 512  # the bound of line L8: total second-level slots at most SIGMA * M
SIGMA0 = 64  # the bound of line L22


def h_ab(a: int, b: int, p: int, m: int, x: int) -> int:
    """The Carter-Wegman hash ((a x + b) mod p) mod m."""
    return ((a * x + b) % p) % m


class DynamicPerfectHash:
    """A dynamic two-level perfect hash table for integer keys in [0, p).

    Search reads one slot: O(1) time in the worst case. Insert and delete
    take O(1) expected amortized time. The space is O(n + 1) words in every
    state. The random choices come from rng, so a test can fix them.
    """

    def __init__(self, rng: random.Random | None = None, p: int = P61) -> None:
        self.rng = rng if rng is not None else random.Random()
        self.p = p
        self.rehash_all(set())

    # ----------------------------------------------------------- search
    def search(self, x: int) -> bool:
        j = h_ab(self.a, self.b, self.p, self.M, x)  # @line L14
        a_j, b_j = self.params[j]
        return self.tables[j][h_ab(a_j, b_j, self.p, len(self.tables[j]), x)] == x  # @line L15

    # ----------------------------------------------------------- updates
    def insert(self, x: int) -> None:
        if not 0 <= x < self.p:
            raise ValueError("key out of range")
        if self.search(x):  # @line L1
            return
        self.u += 1  # @line L2
        if self.u > (self.M + 1) // 2:  # @line L3
            self.rehash_all(self.keys() | {x})
            return
        j = h_ab(self.a, self.b, self.p, self.M, x)  # @line L4
        self.buckets[j].add(x)
        self.c[j] += 1
        t = self.tables[j]
        i = self._slot(j, x)
        if self.c[j] <= self.L[j] and t[i] is None:  # @line L5
            t[i] = x
            return
        if self.c[j] > self.L[j] and len(self.buckets[j]) > self.L[j] / 2:  # @line L6
            self.space += 6 * self.L[j] ** 2  # 2 (2L)^2 - 2 L^2
            self.L[j] *= 2  # @line L7
            if self.space > SIGMA * self.M:  # @line L8
                self.rehash_all(self.keys())
                return
        self.rebuild_bucket(j)  # @line L9

    def delete(self, x: int) -> None:
        if not 0 <= x < self.p or not self.search(x):  # @line L10
            return
        self.u += 1  # @line L11
        if self.u > (self.M + 1) // 2:  # @line L12
            self.rehash_all(self.keys() - {x})
            return
        j = h_ab(self.a, self.b, self.p, self.M, x)  # @line L13
        self.tables[j][self._slot(j, x)] = None
        self.buckets[j].discard(x)

    # ----------------------------------------------------------- rebuilds
    def _slot(self, j: int, x: int) -> int:
        a_j, b_j = self.params[j]
        return h_ab(a_j, b_j, self.p, len(self.tables[j]), x)

    def rebuild_bucket(self, j: int) -> None:
        size = 2 * self.L[j] ** 2
        if len(self.tables[j]) != size:  # @line L16
            self.tables[j] = [None] * size
        else:
            t = self.tables[j]
            for y in self.buckets[j]:  # by (D2) only these slots can be in use
                t[self._slot(j, y)] = None
        t = self.tables[j]
        keys = list(self.buckets[j])
        while True:
            a_j, b_j = self.rng.randrange(1, self.p), self.rng.randrange(self.p)  # @line L17
            slots = [h_ab(a_j, b_j, self.p, size, x) for x in keys]
            if len(set(slots)) == len(slots):  # @line L18
                break
        self.params[j] = (a_j, b_j)
        for x, i in zip(keys, slots):  # @line L19
            t[i] = x
        self.c[j] = len(keys)

    def rehash_all(self, s: set[int]) -> None:
        self.M = max(len(s), 1)  # @line L20
        self.u = 0
        while True:
            self.a, self.b = self.rng.randrange(1, self.p), self.rng.randrange(self.p)  # @line L21
            self.buckets = [set() for _ in range(self.M)]
            for x in s:
                self.buckets[h_ab(self.a, self.b, self.p, self.M, x)].add(x)
            if sum(8 * max(len(b), 1) ** 2 for b in self.buckets) <= SIGMA0 * self.M:  # @line L22
                break
        self.L = [2 * max(len(b), 1) for b in self.buckets]  # @line L23
        self.space = sum(2 * l * l for l in self.L)  # kept equal to the sum of line L8
        self.c = [0] * self.M
        self.params = [(1, 0)] * self.M
        self.tables: list[list[int | None]] = [[] for _ in range(self.M)]
        for j in range(self.M):
            self.rebuild_bucket(j)

    # ----------------------------------------------------------- helpers
    def keys(self) -> set[int]:
        out: set[int] = set()
        for b in self.buckets:
            out |= b
        return out

    def __len__(self) -> int:
        return sum(len(b) for b in self.buckets)

    def check_invariant(self) -> None:
        """Assert (D1) to (D5) of the page."""
        n = len(self)
        assert len(self.buckets) == self.M == len(self.tables) == len(self.L)
        for j, b in enumerate(self.buckets):
            assert all(h_ab(self.a, self.b, self.p, self.M, x) == j for x in b)  # (D1)
            t = self.tables[j]
            assert len(t) == 2 * self.L[j] ** 2
            stored = [x for x in t if x is not None]
            assert sorted(stored) == sorted(b)  # (D2)
            assert all(t[self._slot(j, x)] == x for x in b)
            assert len(b) <= self.c[j] <= self.L[j]  # (D3)
        assert sum(len(t) for t in self.tables) == self.space <= SIGMA * self.M  # (D4)
        assert self.u <= (self.M + 1) // 2  # (D5)
        assert self.M <= 2 * n + 1
test_dynamic_perfect_hashing.py 79 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/dynamic-perfect-hashing/. The authors are listed in prov/F7B69T.jsonl.

import random

import pytest

from dynamic_perfect_hashing import P61, DynamicPerfectHash


def state(d: DynamicPerfectHash) -> tuple:
    return d.M, d.u, [sorted(b) for b in d.buckets], list(d.L), sum(len(t) for t in d.tables)


def test_page_example() -> None:
    d = DynamicPerfectHash(random.Random(5), p=101)
    got = []
    for x in [10, 22, 37, 40, 52, 60, 70]:
        d.insert(x)
        d.check_invariant()
        got.append(state(d))
    assert got[1] == (2, 0, [[22], [10]], [2, 2], 16)
    assert got[3] == (4, 0, [[40], [10, 22], [], [37]], [2, 4, 2, 2], 56)
    assert got[5] == (4, 2, [[40, 52, 60], [10, 22], [], [37]], [4, 4, 2, 2], 80)
    assert got[6][0] == 7 and got[6][1] == 0 and got[6][4] == 144
    d.delete(37)
    d.check_invariant()
    assert d.search(22) and not d.search(37) and len(d) == 6


def test_edges() -> None:
    d = DynamicPerfectHash(random.Random(1))
    assert not d.search(0) and len(d) == 0
    d.delete(5)
    d.insert(5)
    d.insert(5)
    assert len(d) == 1 and d.search(5)
    d.delete(5)
    assert len(d) == 0 and not d.search(5)
    for x in [0, P61 - 1, 2**40]:
        d.insert(x)
    d.check_invariant()
    assert d.search(P61 - 1) and d.search(0)
    with pytest.raises(ValueError):
        d.insert(P61)


def test_insert_delete_same_key_many_times() -> None:
    d = DynamicPerfectHash(random.Random(2), p=1009)
    for x in range(20):
        d.insert(x)
    for _ in range(500):
        d.delete(3)
        d.insert(3)
        d.check_invariant()
    assert d.keys() == set(range(20))


def test_random_against_set() -> None:
    rng = random.Random(1994)
    for _ in range(600):
        p = rng.choice([101, 1009, P61])
        d = DynamicPerfectHash(rng, p=p)
        model: set[int] = set()
        universe = rng.randint(1, 80)
        for _ in range(rng.randint(0, 80)):
            x = rng.randrange(universe)
            r = rng.random()
            if r < 0.5:
                d.insert(x)
                model.add(x)
            elif r < 0.8:
                d.delete(x)
                model.discard(x)
            else:
                assert d.search(x) == (x in model)
            d.check_invariant()
        assert d.keys() == model
        assert all(d.search(x) == (x in model) for x in range(universe))

What the next level needs

  • the tests did not run in this build

Read the full page about Dynamic perfect hashing