Algorithm Search⌘K

Linear probing in Python

Open addressing that probes consecutive slots h(k), h(k)+1, and so on.

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

from collections.abc import Callable, Hashable, Iterator
from typing import Generic, TypeVar

K = TypeVar("K", bound=Hashable)
V = TypeVar("V")

_EMPTY = object()


class LinearProbingTable(Generic[K, V]):
    """A map from keys to values, stored by linear probing in one array.

    Slot j holds a key and its value, or is empty. The table keeps at least
    one empty slot, and it doubles its size before the load factor passes 1/2.
    Deletion shifts later keys of the run back (Knuth's Algorithm R), so the
    table has no tombstones. With a good hash function, each operation takes
    expected O(1) time; the worst case is O(m) for m slots.
    """

    def __init__(self, m: int = 8, home: Callable[[K, int], int] | None = None) -> None:
        if m < 2:
            raise ValueError("the table needs at least 2 slots")
        self._home_fn = home if home is not None else (lambda k, size: hash(k) % size)
        self._keys: list[object] = [_EMPTY] * m
        self._vals: list[object] = [None] * m
        self._n = 0

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

    @property
    def capacity(self) -> int:
        return len(self._keys)

    def _home(self, k: K) -> int:
        return self._home_fn(k, len(self._keys)) % len(self._keys)

    def insert(self, k: K, v: V) -> int:
        """Store k with value v (replace the value if k is present). Return the slot of k."""
        if 2 * (self._n + 1) > len(self._keys):
            self._resize(2 * len(self._keys))
        t, m = self._keys, len(self._keys)
        j = self._home(k)  # @line L1
        while t[j] is not _EMPTY:  # @line L2
            if t[j] == k:  # @line L3
                self._vals[j] = v
                return j
            j = (j + 1) % m  # @line L4
        t[j] = k  # @line L5
        self._vals[j] = v
        self._n += 1
        return j  # @line L6

    def search(self, k: K) -> int | None:
        """Return the slot of k, or None if k is not in the table."""
        t, m = self._keys, len(self._keys)
        j = self._home(k)  # @line L7
        while t[j] is not _EMPTY:  # @line L8
            if t[j] == k:  # @line L9
                return j
            j = (j + 1) % m  # @line L10
        return None  # @line L11

    def get(self, k: K, default: V | None = None) -> V | None:
        j = self.search(k)
        return default if j is None else self._vals[j]  # type: ignore[return-value]

    def __contains__(self, k: object) -> bool:
        return self.search(k) is not None  # type: ignore[arg-type]

    def delete(self, k: K) -> bool:
        """Remove k. Return True if k was present. Later keys of the run move back."""
        t, m = self._keys, len(self._keys)
        i = self.search(k)  # @line L12
        if i is None:  # @line L13
            return False
        t[i] = _EMPTY  # @line L14
        self._vals[i] = None
        self._n -= 1
        j = i  # @line L15
        while True:  # @line L16
            j = (j + 1) % m  # @line L17
            if t[j] is _EMPTY:  # @line L18
                return True
            r = self._home(t[j])  # type: ignore[arg-type]  # @line L19
            if 0 < (r - i) % m <= (j - i) % m:  # @line L20: r is in (i, j], so T[j] stays
                continue
            t[i], self._vals[i] = t[j], self._vals[j]  # @line L21
            t[j], self._vals[j] = _EMPTY, None
            i = j

    def items(self) -> Iterator[tuple[K, V]]:
        for key, val in zip(self._keys, self._vals):
            if key is not _EMPTY:
                yield key, val  # type: ignore[misc]

    def slots(self) -> list[K | None]:
        """The keys slot by slot, with None for an empty slot (for tests and examples)."""
        return [None if key is _EMPTY else key for key in self._keys]  # type: ignore[misc]

    def check_invariant(self) -> None:
        """Each key is reachable from its home slot over full slots; keys are distinct."""
        t, m = self._keys, len(self._keys)
        assert sum(key is not _EMPTY for key in t) == self._n < m
        seen = set()
        for p, key in enumerate(t):
            if key is _EMPTY:
                continue
            assert key not in seen
            seen.add(key)
            j = self._home(key)  # type: ignore[arg-type]
            while j != p:
                assert t[j] is not _EMPTY
                j = (j + 1) % m

    def _resize(self, new_m: int) -> None:
        old = list(self.items())
        self._keys = [_EMPTY] * new_m
        self._vals = [None] * new_m
        self._n = 0
        for key, val in old:
            self.insert(key, val)
test_linear_probing.py 89 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/linear-probing/. The authors are listed in prov/J1RWHQ.jsonl.

import random

from linear_probing import LinearProbingTable


def mod_home(k: int, m: int) -> int:
    return k % m


def table_of_page() -> LinearProbingTable[int, str]:
    t: LinearProbingTable[int, str] = LinearProbingTable(16, mod_home)
    for k in (18, 34, 50, 20, 31, 47, 15):
        t.insert(k, str(k))
        t.check_invariant()
    return t


def test_example() -> None:
    t = table_of_page()
    s = t.slots()
    assert s == [47, 15, 18, 34, 50, 20] + [None] * 9 + [31]
    assert t.search(50) == 4 and t.search(66) is None
    assert t.delete(18)
    t.check_invariant()
    assert t.slots() == [47, 15, 34, 50, 20] + [None] * 10 + [31]
    assert t.delete(31)
    t.check_invariant()
    assert t.slots() == [15, None, 34, 50, 20] + [None] * 10 + [47]


def test_edges() -> None:
    t: LinearProbingTable[int, int] = LinearProbingTable(2, mod_home)
    assert t.search(0) is None and not t.delete(0) and len(t) == 0
    t.insert(5, 1)
    t.insert(5, 2)
    assert len(t) == 1 and t.get(5) == 2
    assert t.delete(5) and len(t) == 0 and 5 not in t
    t.check_invariant()
    # all keys share one home slot
    u: LinearProbingTable[int, int] = LinearProbingTable(64, lambda k, m: 7)
    for k in range(20):
        u.insert(k, k)
        u.check_invariant()
    for k in range(0, 20, 2):
        assert u.delete(k)
        u.check_invariant()
    assert sorted(k for k, _ in u.items()) == list(range(1, 20, 2))
    # large 64-bit keys
    w: LinearProbingTable[int, int] = LinearProbingTable()
    big = [2**63 - 1, -(2**63), 2**64 + 3]
    for k in big:
        w.insert(k, k)
    assert all(w.get(k) == k for k in big)


def test_wraparound_delete() -> None:
    t: LinearProbingTable[int, int] = LinearProbingTable(8, mod_home)
    for k in (7, 15, 23):  # slots 7, 0, 1
        t.insert(k, 0)
    assert t.slots()[7] == 7 and t.slots()[0] == 15 and t.slots()[1] == 23
    t.delete(7)
    t.check_invariant()
    assert t.slots()[7] == 15 and t.slots()[0] == 23 and t.slots()[1] is None


def test_random_against_dict() -> None:
    rng = random.Random(4127)
    for trial in range(500):
        mod = rng.randint(1, 12)
        t: LinearProbingTable[int, int] = LinearProbingTable(rng.choice([2, 4, 8]), lambda k, m, mod=mod: (k % mod) * 3 + k // 7)
        oracle: dict[int, int] = {}
        for _ in range(rng.randint(0, 80)):
            k = rng.randint(-20, 40)
            op = rng.random()
            if op < 0.5:
                v = rng.randint(0, 999)
                t.insert(k, v)
                oracle[k] = v
            elif op < 0.8:
                assert t.delete(k) == (k in oracle)
                oracle.pop(k, None)
            else:
                assert t.get(k) == oracle.get(k)
            t.check_invariant()
            assert len(t) == len(oracle)
        assert dict(t.items()) == oracle

What the next level needs

  • the tests did not run in this build

Read the full page about Linear probing