Algorithm Search⌘K

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

Read the full page about Hopscotch hashing