Algorithm Search⌘K

Double hashing in Python

Open addressing where a second hash function sets the probe step size.

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

from collections.abc import Callable
from typing import Any


class _Deleted:
    """The marker of a slot whose key was deleted (a tombstone)."""

    def __repr__(self) -> str:
        return "DELETED"


DELETED: Any = _Deleted()


def _is_prime(m: int) -> bool:
    if m < 2:
        return False
    d = 2
    while d * d <= m:
        if m % d == 0:
            return False
        d += 1
    return True


class DoubleHashTable:
    """An open-addressing table with double hashing and a fixed number m of slots.

    The probe sequence of key k is (h1(k) + i * h2(k)) mod m for i = 0, 1, ...,
    m - 1. Precondition: m is prime and 1 <= h2(k) <= m - 1 for every key, so
    the sequence visits every slot once. Each operation probes at most m slots;
    under the uniform hashing assumption an unsuccessful search probes at most
    1 / (1 - alpha) slots in expectation, where alpha is the fraction of
    non-empty slots.
    """

    def __init__(
        self,
        m: int = 13,
        h1: Callable[[Any], int] | None = None,
        h2: Callable[[Any], int] | None = None,
    ) -> None:
        if not _is_prime(m):
            raise ValueError("m must be prime")
        self.m = m
        self.h1 = h1 if h1 is not None else (lambda k: hash(k) % m)
        self.h2 = h2 if h2 is not None else (lambda k: 1 + hash(k) % (m - 2 if m > 3 else 1))
        self.slots: list[Any] = [None] * m  # None is EMPTY; else DELETED or [key, value]
        self.n = 0

    def _probe(self, k: Any, i: int) -> int:
        step = self.h2(k)
        if not 1 <= step <= self.m - 1:
            raise ValueError("h2(k) must be in 1 .. m - 1")
        return (self.h1(k) + i * step) % self.m

    def insert(self, k: Any, v: Any) -> None:
        """Set the value of k to v. Raise OverflowError if the table is full."""
        j = None  # @line L1
        for i in range(self.m):  # @line L2
            q = self._probe(k, i)  # @line L3
            s = self.slots[q]
            if s is None:  # @line L4
                if j is None:  # @line L5
                    j = q
                break  # @line L6
            elif s is DELETED:  # @line L7
                if j is None:  # @line L8
                    j = q
            elif s[0] == k:  # @line L9
                s[1] = v  # @line L10
                return
        if j is None:  # @line L11
            raise OverflowError("the table is full")
        self.slots[j] = [k, v]  # @line L12
        self.n += 1

    def search(self, k: Any) -> Any:
        """Return the value of k, or None if k is not a key."""
        for i in range(self.m):  # @line L13
            q = self._probe(k, i)  # @line L14
            s = self.slots[q]
            if s is None:  # @line L15
                return None
            if s is not DELETED and s[0] == k:  # @line L16
                return s[1]
        return None  # @line L17

    def delete(self, k: Any) -> bool:
        """Mark the slot of k as DELETED. Return True if k was a key."""
        for i in range(self.m):  # @line L18
            q = self._probe(k, i)  # @line L19
            s = self.slots[q]
            if s is None:  # @line L20
                return False
            if s is not DELETED and s[0] == k:  # @line L21
                self.slots[q] = DELETED
                self.n -= 1
                return True
        return False

    def __len__(self) -> int:
        return self.n

    def keys(self) -> list[Any]:
        return [s[0] for s in self.slots if s is not None and s is not DELETED]

    def check_invariant(self) -> None:
        """Assert (D1) and (D2) of the page, and the count."""
        ks = self.keys()
        assert len(ks) == len(set(ks)) == self.n  # (D2) and the count
        for q, s in enumerate(self.slots):
            if s is None or s is DELETED:
                continue
            i = 0
            while self._probe(s[0], i) != q:  # (D1): every earlier probe is non-empty
                assert self.slots[self._probe(s[0], i)] is not None
                i += 1
test_double_hashing.py 85 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/double-hashing/. The authors are listed in prov/PMZJW6.jsonl.

import random

import pytest

from double_hashing import DELETED, DoubleHashTable


def layout(t: DoubleHashTable) -> list:
    return [None if s is None else ("D" if s is DELETED else s[0]) for s in t.slots]


def test_page_example() -> None:
    t = DoubleHashTable(13, h1=lambda k: k % 13, h2=lambda k: 1 + k % 11)
    for k in [18, 41, 22, 44, 59, 32, 31, 73]:
        t.insert(k, -k)
        t.check_invariant()
    assert layout(t) == [None, None, 41, None, 32, 18, 44, 59, 73, 22, None, None, 31]
    assert t.delete(44)
    assert t.search(32) == -32  # probes slot 6 (DELETED), then slot 4
    assert t.search(57) is None
    t.insert(19, -19)  # path 6 (DELETED), 2, 11 (EMPTY): reuses slot 6
    assert t.slots[6] == [19, -19]
    t.check_invariant()


def test_default_hashes_match_example() -> None:
    t = DoubleHashTable(13)
    for k in [18, 41, 22, 44, 59, 32, 31, 73]:
        t.insert(k, k)
    assert layout(t) == [None, None, 41, None, 32, 18, 44, 59, 73, 22, None, None, 31]


def test_edges() -> None:
    with pytest.raises(ValueError):
        DoubleHashTable(12)
    t = DoubleHashTable(2)
    assert t.search(5) is None and not t.delete(5)
    t.insert(5, "a")
    t.insert(5, "b")
    assert len(t) == 1 and t.search(5) == "b"
    t.insert(6, "c")
    with pytest.raises(OverflowError):
        t.insert(7, "d")
    assert t.delete(5)
    t.insert(7, "d")  # reuses the DELETED slot of a full table
    assert t.search(7) == "d" and t.search(6) == "c" and t.search(5) is None
    t.check_invariant()


def test_fill_every_slot() -> None:
    t = DoubleHashTable(31)
    for k in range(31):
        t.insert(k * 1000, k)
    t.check_invariant()
    assert all(t.search(k * 1000) == k for k in range(31))


def test_random_against_dict() -> None:
    rng = random.Random(7331)
    for _ in range(600):
        m = rng.choice([2, 3, 5, 7, 11, 13, 17])
        t = DoubleHashTable(m)
        model: dict[int, int] = {}
        for _ in range(rng.randint(0, 50)):
            k = rng.randint(-30, 30)
            r = rng.random()
            if r < 0.45:
                v = rng.randint(0, 9)
                if k in model or len(t) < m:
                    try:
                        t.insert(k, v)
                        model[k] = v
                    except OverflowError:
                        assert k not in model and all(s is not None and s is not DELETED for s in t.slots)
            elif r < 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 sorted(t.keys()) == sorted(model)

What the next level needs

  • the tests did not run in this build

Read the full page about Double hashing