Algorithm Search⌘K

Radix tree in Python

Trie in which every chain of single-child nodes is merged into one edge labelled by a string.

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

from typing import Generic, TypeVar

V = TypeVar("V")


class _Node(Generic[V]):
    __slots__ = ("val", "edges")

    def __init__(self, val: "V | None" = None) -> None:
        self.val: V | None = val
        # first symbol of the label -> (label, child)
        self.edges: dict[str, tuple[str, "_Node[V]"]] = {}


def _lcp(a: str, b: str) -> int:
    j = 0
    while j < len(a) and j < len(b) and a[j] == b[j]:
        j += 1
    return j


class RadixTree(Generic[V]):
    """A radix tree (compressed trie) with string-labelled edges.

    get, put and delete take O(m) expected time for a key of length m: the
    walk compares each symbol of the key once, and child lookup is a dict
    lookup. The tree has at most 2n nodes for n keys. Values must not be None.
    """

    def __init__(self) -> None:
        self.root: _Node[V] = _Node()
        self.n = 0

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

    def _walk(self, s: str) -> list[tuple[_Node[V], str]]:
        """Return the path [(node, label of the edge into it)] to the node of s, or [] if none."""
        x = self.root
        path = [(x, "")]
        i = 0
        while i < len(s):
            e = x.edges.get(s[i])
            if e is None or not s.startswith(e[0], i):
                return []
            i += len(e[0])
            x = e[1]
            path.append((x, e[0]))
        return path

    def get(self, s: str) -> V | None:
        """Return the value of key s, or None."""
        x = self.root  # @line L1
        i = 0
        while i < len(s):  # @line L2
            e = x.edges.get(s[i])  # @line L3
            if e is None or not s.startswith(e[0], i):  # @line L4
                return None
            i += len(e[0])  # @line L5
            x = e[1]
        return x.val  # @line L6

    def put(self, s: str, v: V) -> None:
        """Set the value of key s to v (v must not be None)."""
        if v is None:
            raise ValueError("a value must not be None")
        x = self.root  # @line L7
        i = 0
        while i < len(s):  # @line L8
            e = x.edges.get(s[i])  # @line L9
            if e is None:  # @line L10
                x.edges[s[i]] = (s[i:], _Node(v))
                self.n += 1
                return
            label, child = e
            j = _lcp(label, s[i:])  # @line L11
            if j < len(label):  # @line L12
                z: _Node[V] = _Node()  # @line L13
                z.edges[label[j]] = (label[j:], child)
                x.edges[s[i]] = (label[:j], z)
                child = z  # @line L14
            i += j  # @line L15
            x = child
        if x.val is None:  # @line L16
            self.n += 1
        x.val = v  # @line L17

    def delete(self, s: str) -> None:
        """Remove key s if present, and merge the nodes that no longer branch."""
        path = self._walk(s)  # @line L18
        if not path or path[-1][0].val is None:  # @line L19
            return
        x = path[-1][0]
        x.val = None  # @line L20
        self.n -= 1
        k = len(path) - 1
        if k > 0 and not x.edges:  # @line L21
            parent, label = path[k - 1][0], path[k][1]
            del parent.edges[label[0]]
            k -= 1
        x, into = path[k]
        if k > 0 and x.val is None and len(x.edges) == 1:  # @line L22
            parent = path[k - 1][0]
            (flabel, fchild), = x.edges.values()
            parent.edges[into[0]] = (into + flabel, fchild)

    def keys_with_prefix(self, p: str) -> list[str]:
        """Return the keys that start with p, in lexicographic order."""
        x = self.root  # @line L23
        i = 0
        t = ""
        while i < len(p):
            e = x.edges.get(p[i])
            if e is None:
                return []
            label, child = e
            rest = p[i:]
            if not (label.startswith(rest) or rest.startswith(label)):
                return []
            i += len(label)
            t += label
            x = child
        out: list[str] = []  # @line L24
        stack: list[tuple[_Node[V], str]] = [(x, t)]
        while stack:  # @line L25
            y, u = stack.pop()  # @line L26
            if y.val is not None:  # @line L27
                out.append(u)
            for c in sorted(y.edges, reverse=True):  # @line L28
                label, child = y.edges[c]
                stack.append((child, u + label))
        return out  # @line L29

    def keys(self) -> list[str]:
        return self.keys_with_prefix("")

    def longest_prefix_of(self, s: str) -> str | None:
        """Return the longest key that is a prefix of s, or None."""
        x = self.root
        i, best = 0, (0 if self.root.val is not None else -1)
        while i < len(s):
            e = x.edges.get(s[i])
            if e is None or not s.startswith(e[0], i):
                break
            i += len(e[0])
            x = e[1]
            if x.val is not None:
                best = i
        return None if best < 0 else s[:best]

    def node_count(self) -> int:
        count, stack = 0, [self.root]
        while stack:
            y = stack.pop()
            count += 1
            stack.extend(c for _, c in y.edges.values())
        return count

    def check_invariant(self) -> None:
        """Check (R1) to (R4)."""
        valued, stack = 0, [(self.root, True)]
        while stack:
            y, is_root = stack.pop()
            if y.val is not None:
                valued += 1
            elif not is_root:
                assert len(y.edges) >= 2, "a node without a value must branch"
            for c, (label, child) in y.edges.items():
                assert label and label[0] == c
                stack.append((child, False))
        assert valued == self.n
test_radix_tree.py 73 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/radix-tree/. The authors are listed in prov/4A6SZN.jsonl.

import random

from radix_tree import RadixTree

WORDS = "she sells sea shells by the sea shore".split()


def test_example() -> None:
    t: RadixTree[int] = RadixTree()
    counts = []
    for i, w in enumerate(WORDS):
        t.put(w, i)
        t.check_invariant()
        counts.append(t.node_count())
    assert counts == [2, 4, 6, 7, 8, 9, 9, 11]
    assert t.keys() == ["by", "sea", "sells", "she", "shells", "shore", "the"]
    assert t.get("sea") == 6 and t.get("s") is None and t.get("shel") is None
    assert t.keys_with_prefix("sh") == ["she", "shells", "shore"]
    assert t.keys_with_prefix("shel") == ["shells"]
    assert t.keys_with_prefix("shx") == []
    assert t.longest_prefix_of("shellsort") == "shells"
    t.delete("she")
    t.check_invariant()
    assert t.node_count() == 10 and t.get("shells") == 3
    t.delete("sea")
    t.check_invariant()
    assert t.node_count() == 8


def test_edges() -> None:
    t: RadixTree[int] = RadixTree()
    assert t.get("") is None and t.keys() == []
    t.put("", 5)
    t.put("a", 1)
    t.put("aaa", 3)
    t.check_invariant()
    assert t.keys() == ["", "a", "aaa"]
    t.delete("a")
    t.check_invariant()
    assert t.node_count() == 2 and t.get("aaa") == 3
    t.delete("")
    t.delete("zz")
    t.check_invariant()
    assert t.size() == 1


def test_random_against_dict() -> None:
    rng = random.Random(7781)
    for _ in range(500):
        t: RadixTree[int] = RadixTree()
        model: dict[str, int] = {}
        for _ in range(rng.randint(0, 40)):
            k = "".join(rng.choice("ab") for _ in range(rng.randint(0, 5)))
            if rng.random() < 0.6:
                v = rng.randint(0, 99)
                t.put(k, v)
                model[k] = v
            else:
                t.delete(k)
                model.pop(k, None)
            t.check_invariant()
            assert t.get(k) == model.get(k)
        assert t.size() == len(model)
        assert t.keys() == sorted(model)
        assert t.node_count() <= max(1, 2 * len(model))
        p = "".join(rng.choice("ab") for _ in range(rng.randint(0, 3)))
        assert t.keys_with_prefix(p) == sorted(k for k in model if k.startswith(p))
        s = "".join(rng.choice("ab") for _ in range(rng.randint(0, 6)))
        cands = [k for k in model if s.startswith(k)]
        assert t.longest_prefix_of(s) == (max(cands, key=len) if cands else None)

What the next level needs

  • the tests did not run in this build

Read the full page about Radix tree