BK-tree in Python
A tree over a discrete metric such as edit distance, where children are keyed by their distance to the parent.
- File
bk_tree.py, 93 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/bk-tree/. The authors are listed in prov/WM7ED9.jsonl.
from collections.abc import Callable, Hashable, Iterable
from typing import Generic, TypeVar
T = TypeVar("T", bound=Hashable)
def levenshtein(a: str, b: str) -> int:
"""Edit distance with unit-cost insertion, deletion and substitution. O(|a| |b|) time."""
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
prev = cur
return prev[-1]
class _Node(Generic[T]):
__slots__ = ("item", "children")
def __init__(self, item: T) -> None:
self.item = item
self.children: dict[int, _Node[T]] = {}
class BKTree(Generic[T]):
"""Burkhard-Keller tree for a metric with nonnegative integer values.
Invariant: every item in the subtree of t.children[i] is at distance exactly i from t.item.
Insert: O(h) distance evaluations. Range and nearest: O(n) evaluations in the worst case.
"""
def __init__(self, dist: Callable[[T, T], int] = levenshtein, items: Iterable[T] = ()) -> None:
self.dist = dist
self.root: _Node[T] | None = None
self.size = 0
for x in items:
self.insert(x)
def insert(self, x: T) -> bool:
"""Add x; return False if an item at distance 0 is already stored."""
if self.root is None: # @line L1
self.root = _Node(x)
self.size = 1
return True
t = self.root # @line L2
while True:
k = self.dist(x, t.item) # @line L3
if k == 0: # @line L4
return False
child = t.children.get(k) # @line L5
if child is None:
t.children[k] = _Node(x)
self.size += 1
return True
t = child
def range(self, q: T, r: int) -> list[T]:
"""Return all stored items x with dist(q, x) <= r."""
out: list[T] = []
stack = [self.root]
while stack:
t = stack.pop()
if t is None: # @line L6
continue
k = self.dist(q, t.item) # @line L7
if k <= r:
out.append(t.item)
for i, child in t.children.items(): # @line L8
if k - r <= i <= k + r:
stack.append(child)
return out
def nearest(self, q: T) -> tuple[int, T] | None:
"""Return (distance, item) for a stored item nearest to q, or None if the tree is empty."""
if self.root is None:
return None
best: tuple[int, T] | None = None # @line L9
stack: list[tuple[int, _Node[T]]] = [(0, self.root)]
while stack: # @line L10
lb, t = stack.pop()
if best is not None and lb >= best[0]:
continue
k = self.dist(q, t.item) # @line L11
if best is None or k < best[0]:
best = (k, t.item)
for i, child in t.children.items(): # @line L12
if abs(i - k) < best[0]:
stack.append((abs(i - k), child))
return best # @line L13
test_bk_tree.py 75 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/bk-tree/. The authors are listed in prov/WM7ED9.jsonl.
import random
from bk_tree import BKTree, levenshtein
WORDS = ["book", "books", "cake", "boo", "boon", "cook", "cape", "cart"]
def check_invariant(t) -> None:
def items(u):
yield u.item
for c in u.children.values():
yield from items(c)
for i, c in t.children.items():
assert all(levenshtein(x, t.item) == i for x in items(c))
check_invariant(c)
def test_levenshtein() -> None:
assert levenshtein("kitten", "sitting") == 3
assert levenshtein("", "abc") == 3
assert levenshtein("flaw", "lawn") == 2
def test_example() -> None:
tree = BKTree(items=WORDS)
assert tree.root.item == "book"
assert sorted(tree.root.children) == [1, 4]
assert tree.root.children[1].item == "books"
assert tree.root.children[4].item == "cake"
check_invariant(tree.root)
assert sorted(tree.range("bool", 1)) == ["boo", "book", "boon"]
assert tree.nearest("cast") == (1, "cart")
assert tree.insert("book") is False
assert tree.size == len(WORDS)
def test_empty() -> None:
tree = BKTree()
assert tree.range("a", 3) == []
assert tree.nearest("a") is None
def test_random_against_brute_force() -> None:
rng = random.Random(2024)
for _ in range(500):
alphabet = "ab" if rng.random() < 0.5 else "abcd"
words = {"".join(rng.choice(alphabet) for _ in range(rng.randint(0, 6))) for _ in range(rng.randint(0, 30))}
words = list(words)
rng.shuffle(words)
tree = BKTree(items=words)
assert tree.size == len(words)
if tree.root is not None:
check_invariant(tree.root)
q = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 6)))
r = rng.randint(0, 4)
assert sorted(tree.range(q, r)) == sorted(w for w in words if levenshtein(q, w) <= r)
got = tree.nearest(q)
if words:
assert got[0] == min(levenshtein(q, w) for w in words)
assert levenshtein(q, got[1]) == got[0]
else:
assert got is None
def test_other_metric() -> None:
rng = random.Random(7)
pts = rng.sample(range(1000), 200)
tree = BKTree(dist=lambda a, b: abs(a - b), items=pts)
for _ in range(200):
q, r = rng.randrange(1000), rng.randrange(20)
assert sorted(tree.range(q, r)) == sorted(p for p in pts if abs(p - q) <= r)
What the next level needs
- the tests did not run in this build