Algorithm Search⌘K

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
bk_tree.pyRawGitHub
# 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

Read the full page about BK-tree