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
# 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