Algorithm Search⌘K

Binary search symbol table in Python

Ordered symbol table kept in sorted parallel arrays, using binary search to find keys and rank.

File
binary_search_symbol_table.py, 106 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
binary_search_symbol_table.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/binary-search-symbol-table/. The authors are listed in prov/7XC042.jsonl.

from typing import Any, Generic, Iterator, TypeVar

K = TypeVar("K")
V = TypeVar("V")


class BinarySearchST(Generic[K, V]):
    """An ordered symbol table in two parallel sorted arrays.

    rank, get, floor and ceiling cost O(log n); put of a new key and delete
    cost O(n) for the shift; min, max and select cost O(1). Space: 2n cells
    (Python lists resize themselves). Precondition: keys are totally ordered.
    """

    def __init__(self) -> None:
        self.keys: list[Any] = []
        self.vals: list[Any] = []

    def __len__(self) -> int:
        return len(self.keys)

    def __iter__(self) -> Iterator[K]:
        return iter(list(self.keys))

    def rank(self, k: K) -> int:
        """Return the number of keys less than k. At most floor(lg n) + 1 probes."""
        lo, hi = 0, len(self.keys) - 1  # @line L1
        while lo <= hi:  # @line L2
            mid = lo + (hi - lo) // 2  # @line L3
            if k < self.keys[mid]:  # @line L4
                hi = mid - 1
            elif k > self.keys[mid]:  # @line L5
                lo = mid + 1
            else:
                return mid  # @line L6
        return lo  # @line L7

    def get(self, k: K) -> V | None:
        """Return the value of k, or None. Cost O(log n)."""
        i = self.rank(k)  # @line L8
        if i < len(self.keys) and self.keys[i] == k:
            return self.vals[i]
        return None

    def contains(self, k: K) -> bool:
        i = self.rank(k)
        return i < len(self.keys) and self.keys[i] == k

    def put(self, k: K, v: V) -> None:
        """Insert (k, v), or replace the value of k. Cost O(n) for a new key."""
        i = self.rank(k)  # @line L9
        n = len(self.keys)
        if i < n and self.keys[i] == k:  # @line L10
            self.vals[i] = v
            return
        self.keys.append(None)
        self.vals.append(None)
        for j in range(n, i, -1):  # @line L11
            self.keys[j] = self.keys[j - 1]
            self.vals[j] = self.vals[j - 1]
        self.keys[i], self.vals[i] = k, v  # @line L12

    def delete(self, k: K) -> None:
        """Remove k and its value if present. Cost O(n)."""
        i = self.rank(k)  # @line L13
        n = len(self.keys)
        if i == n or self.keys[i] != k:
            return
        for j in range(i, n - 1):  # @line L14
            self.keys[j] = self.keys[j + 1]
            self.vals[j] = self.vals[j + 1]
        self.keys.pop()  # @line L15
        self.vals.pop()

    def min(self) -> K:
        return self.keys[0]

    def max(self) -> K:
        return self.keys[-1]

    def select(self, i: int) -> K:
        return self.keys[i]

    def ceiling(self, k: K) -> K | None:
        i = self.rank(k)
        return self.keys[i] if i < len(self.keys) else None

    def floor(self, k: K) -> K | None:
        i = self.rank(k)
        if i < len(self.keys) and self.keys[i] == k:
            return k
        return self.keys[i - 1] if i > 0 else None

    def size_range(self, lo: K, hi: K) -> int:
        """Number of keys in [lo, hi]. Cost O(log n)."""
        if hi < lo:
            return 0
        return self.rank(hi) - self.rank(lo) + (1 if self.contains(hi) else 0)

    def check_invariant(self) -> None:
        assert len(self.keys) == len(self.vals)
        for a, b in zip(self.keys, self.keys[1:]):
            assert a < b, "keys not strictly increasing"
test_binary_search_symbol_table.py 64 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/binary-search-symbol-table/. The authors are listed in prov/7XC042.jsonl.

import bisect
import random

from binary_search_symbol_table import BinarySearchST


def test_page_example() -> None:
    st: BinarySearchST[str, int] = BinarySearchST()
    for i, c in enumerate("SEARCHEXAMPLE"):
        st.put(c, i)
        st.check_invariant()
    assert "".join(st.keys) == "ACEHLMPRSX"
    assert st.vals == [8, 4, 12, 5, 11, 9, 10, 3, 0, 7]
    assert st.get("E") == 12 and st.get("F") is None
    assert st.rank("F") == 3
    assert st.ceiling("F") == "H" and st.floor("F") == "E"
    assert st.select(5) == "M"
    assert st.size_range("F", "Q") == 4
    st.delete("H")
    assert "".join(st.keys) == "ACELMPRSX"
    assert st.min() == "A" and st.max() == "X"


def test_empty_and_edges() -> None:
    st: BinarySearchST[int, str] = BinarySearchST()
    assert st.rank(5) == 0 and st.get(5) is None
    assert st.floor(5) is None and st.ceiling(5) is None
    st.delete(5)
    st.put(5, "a")
    assert st.floor(4) is None and st.ceiling(6) is None
    assert st.floor(5) == 5 and st.ceiling(5) == 5
    assert st.size_range(6, 1) == 0
    st.delete(5)
    assert len(st) == 0


def test_random_against_dict() -> None:
    rng = random.Random(1946)
    for _ in range(600):
        st: BinarySearchST[int, int] = BinarySearchST()
        model: dict[int, int] = {}
        for _ in range(rng.randrange(1, 40)):
            k = rng.randrange(30)
            r = rng.random()
            if r < 0.6:
                v = rng.randrange(1000)
                st.put(k, v); model[k] = v
            else:
                st.delete(k); model.pop(k, None)
            st.check_invariant()
            ks = sorted(model)
            assert st.keys == ks and st.vals == [model[x] for x in ks]
            q = rng.randrange(-2, 33)
            assert st.rank(q) == bisect.bisect_left(ks, q)
            assert st.get(q) == model.get(q)
            fl = [x for x in ks if x <= q]
            ce = [x for x in ks if x >= q]
            assert st.floor(q) == (fl[-1] if fl else None)
            assert st.ceiling(q) == (ce[0] if ce else None)
            lo, hi = sorted((rng.randrange(30), rng.randrange(30)))
            assert st.size_range(lo, hi) == sum(1 for x in ks if lo <= x <= hi)

What the next level needs

  • the tests did not run in this build

Read the full page about Binary search symbol table