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