Algorithm Search⌘K

Extendible hashing in Python

Disk-oriented hashing with a directory that doubles, splitting only overflowing buckets.

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

from collections.abc import Callable, Hashable, Iterator
from typing import Any

W = 64  # default number of bits of a hash value


def hash64(k: Hashable) -> int:
    """The built-in hash, as a non-negative 64-bit value."""
    return hash(k) % (1 << W)


class Bucket:
    """A bucket: a list of [key, value] pairs and a local depth."""

    def __init__(self, depth: int) -> None:
        self.depth = depth
        self.pairs: list[list[Any]] = []


class ExtendibleHashing:
    """A dictionary stored by extendible hashing.

    A directory of 2**d pointers sends key k to the bucket dir[h(k) mod 2**d].
    Each bucket holds at most b pairs (more only when its local depth is W).
    A full bucket splits on one more bit; the directory doubles only when the
    bucket's local depth equals d. Search and delete cost O(b). An insert costs
    O(b + 2**d) in the worst case (W is a constant).
    """

    def __init__(self, b: int = 4, h: Callable[[Hashable], int] = hash64, w: int = W) -> None:
        if b < 1:
            raise ValueError("the bucket capacity must be at least 1")
        self.b = b
        self.w = w
        self.h = lambda k: h(k) % (1 << w)
        self.d = 0
        self.dir: list[Bucket] = [Bucket(0)]
        self.n = 0

    def _find(self, bucket: Bucket, k: Hashable) -> int:
        for p, pair in enumerate(bucket.pairs):
            if pair[0] == k:
                return p
        return -1

    def search(self, k: Hashable) -> Any:
        """Return the value of k, or None if k is not a key."""
        bucket = self.dir[self.h(k) % (1 << self.d)]  # @line L1
        p = self._find(bucket, k)
        if p >= 0:  # @line L2
            return bucket.pairs[p][1]
        return None  # @line L3

    def insert(self, k: Hashable, v: Any) -> None:
        """Set the value of k to v."""
        hk = self.h(k)
        bucket = self.dir[hk % (1 << self.d)]  # @line L4
        p = self._find(bucket, k)
        if p >= 0:  # @line L5
            bucket.pairs[p][1] = v
            return
        while len(bucket.pairs) >= self.b and bucket.depth < self.w:  # @line L6
            if bucket.depth == self.d:  # @line L7
                self.dir = self.dir + self.dir
                self.d += 1
            bucket.depth += 1  # @line L8
            new = Bucket(bucket.depth)
            bit = bucket.depth - 1
            keep = [q for q in bucket.pairs if not (self.h(q[0]) >> bit) & 1]  # @line L9
            new.pairs = [q for q in bucket.pairs if (self.h(q[0]) >> bit) & 1]
            bucket.pairs = keep
            for i in range(len(self.dir)):  # @line L10
                if self.dir[i] is bucket and (i >> bit) & 1:
                    self.dir[i] = new
            bucket = self.dir[hk % (1 << self.d)]  # @line L11
        bucket.pairs.append([k, v])  # @line L12
        self.n += 1

    def delete(self, k: Hashable) -> bool:
        """Remove k. Return True if k was a key. Buckets are not merged."""
        bucket = self.dir[self.h(k) % (1 << self.d)]  # @line L13
        p = self._find(bucket, k)
        if p >= 0:  # @line L14
            bucket.pairs.pop(p)
            self.n -= 1
            return True
        return False

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

    def buckets(self) -> list[Bucket]:
        """The distinct buckets, in order of their first directory entry."""
        seen: dict[int, Bucket] = {}
        for bucket in self.dir:
            seen.setdefault(id(bucket), bucket)
        return list(seen.values())

    def items(self) -> Iterator[tuple[Any, Any]]:
        for bucket in self.buckets():
            for k, v in bucket.pairs:
                yield k, v

    def check_invariant(self) -> None:
        """Assert (E1) to (E5) of the page."""
        assert len(self.dir) == 1 << self.d
        keys = []
        where: dict[int, list[int]] = {}
        for i, x in enumerate(self.dir):
            where.setdefault(id(x), []).append(i)
        for bucket in self.buckets():
            ell = bucket.depth
            assert 0 <= ell <= self.d
            idx = where[id(bucket)]
            pat = idx[0] % (1 << ell)
            assert idx == list(range(pat, len(self.dir), 1 << ell))  # (E1)
            for k, _ in bucket.pairs:
                assert self.h(k) % (1 << ell) == pat  # (E2)
                keys.append(k)
            assert len(bucket.pairs) <= self.b or ell == self.w  # (E5)
        assert len(keys) == len(set(keys))  # (E3)
        assert self.n == len(keys)  # (E4)
test_extendible_hashing.py 84 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/extendible-hashing/. The authors are listed in prov/X591T6.jsonl.

import random

from extendible_hashing import ExtendibleHashing


def layout(t: ExtendibleHashing) -> tuple[int, list[int], list[tuple[int, list[int]]]]:
    bs = t.buckets()
    return t.d, [next(j for j, s in enumerate(bs) if s is x) for x in t.dir], [
        (x.depth, [k for k, _ in x.pairs]) for x in bs
    ]


def test_page_example() -> None:
    t = ExtendibleHashing(b=2, h=lambda k: k)
    for k in [4, 12, 7, 9, 2, 16, 6]:
        t.insert(k, str(k))
        t.check_invariant()
    d, dirs, bs = layout(t)
    assert d == 3
    # buckets in order of first entry: A, B, C, D
    assert dirs == [0, 1, 2, 1, 3, 1, 2, 1]
    assert bs == [(3, [16]), (1, [7, 9]), (2, [2, 6]), (3, [4, 12])]
    assert t.search(12) == "12" and t.search(5) is None
    assert t.delete(9) and not t.delete(9) and len(t) == 6
    t.check_invariant()


def test_split_with_empty_side() -> None:
    t = ExtendibleHashing(b=2, h=lambda k: k)
    for k in [0, 8, 16]:  # equal in the low 3 bits: needs 4 splits on one insert
        t.insert(k, k)
        t.check_invariant()
    assert t.d == 4 and all(t.search(k) == k for k in [0, 8, 16])


def test_equal_hash_values_overflow() -> None:
    u = ExtendibleHashing(b=1, w=5)
    u.insert(-1, 1)
    u.insert(-2, 2)  # same built-in hash value
    u.check_invariant()
    assert u.d == 5 and u.search(-1) == 1 and u.search(-2) == 2
    t = ExtendibleHashing(b=1, h=lambda k: 5, w=3)
    for k in range(4):
        t.insert(k, k)
        t.check_invariant()
    assert all(t.search(k) == k for k in range(4)) and len(t) == 4
    assert t.d == 3 and len(t.dir) == 8


def test_edges() -> None:
    t = ExtendibleHashing(b=1, w=16)
    assert t.search("a") is None and not t.delete("a") and len(t) == 0
    t.insert("a", 1)
    t.insert("a", 2)
    assert t.search("a") == 2 and len(t) == 1
    for k in [2**63 - 1, -(2**63), -1, 0, 2**64 + 3]:
        t.insert(k, k)
        t.check_invariant()
    assert all(t.search(k) == k for k in [2**63 - 1, -(2**63), -1, 0, 2**64 + 3])


def test_random_against_dict() -> None:
    rng = random.Random(1979)
    for _ in range(600):
        t = ExtendibleHashing(b=rng.randint(1, 4), w=12)  # hash(-1) == hash(-2) in Python
        model: dict[int, int] = {}
        for _ in range(rng.randint(0, 60)):
            k = rng.randint(-40, 40)
            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 Extendible hashing