Trie in Python
Rooted tree in which each edge is labelled by a symbol and each root-to-node path spells a string.
- File
trie.py, 149 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/trie/. The authors are listed in prov/SJRBA7.jsonl.
from typing import Generic, TypeVar
V = TypeVar("V")
class _Node(Generic[V]):
__slots__ = ("val", "next")
def __init__(self) -> None:
self.val: V | None = None
self.next: dict[str, "_Node[V]"] = {}
class Trie(Generic[V]):
"""A trie with a dictionary of children in each node.
get, put and delete take O(m) expected time for a key of length m.
keys_with_prefix takes O(|p| + t) expected time, where t is the number
of nodes below the node of p. 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 get(self, s: str) -> V | None:
"""Return the value of key s, or None if s is not a key."""
x: _Node[V] | None = self.root # @line L1
for c in s: # @line L2
x = x.next.get(c) # @line L3
if x is None: # @line L4
return None
return x.val # @line L5
def contains(self, s: str) -> bool:
return self.get(s) is not None
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 L6
for c in s: # @line L7
if c not in x.next: # @line L8
x.next[c] = _Node()
x = x.next[c] # @line L9
if x.val is None: # @line L10
self.n += 1
x.val = v # @line L11
def delete(self, s: str) -> None:
"""Remove key s if it is a key, and cut the nodes that are no longer needed."""
path = [self.root] # @line L12
x: _Node[V] | None = self.root
for c in s: # @line L13
x = x.next.get(c) # @line L14
if x is None: # @line L15
return
path.append(x) # @line L16
assert x is not None
if x.val is None: # @line L17
return
x.val = None # @line L18
self.n -= 1
for i in range(len(s), 0, -1): # @line L19
if path[i].val is not None or path[i].next: # @line L20
return
del path[i - 1].next[s[i - 1]] # @line L21
def keys_with_prefix(self, p: str) -> list[str]:
"""Return the keys that start with p, in lexicographic order."""
x: _Node[V] | None = self.root # @line L22
for c in p:
x = x.next.get(c)
if x is None:
break
if x is None: # @line L23
return []
out: list[str] = [] # @line L24
stack: list[tuple[_Node[V], str]] = [(x, p)]
while stack: # @line L25
y, t = stack.pop() # @line L26
if y.val is not None: # @line L27
out.append(t)
for c in sorted(y.next, reverse=True): # @line L28
stack.append((y.next[c], t + c))
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: _Node[V] | None = self.root # @line L30
best = -1
for i in range(len(s) + 1): # @line L31
assert x is not None
if x.val is not None: # @line L32
best = i
if i == len(s): # @line L33
break
x = x.next.get(s[i]) # @line L34
if x is None: # @line L35
break
return None if best < 0 else s[:best] # @line L36
def keys_that_match(self, q: str) -> list[str]:
"""Return the keys of length |q| that match q, where '.' matches any one symbol."""
out: list[str] = []
stack: list[tuple[_Node[V], str]] = [(self.root, "")]
while stack:
y, t = stack.pop()
d = len(t)
if d == len(q):
if y.val is not None:
out.append(t)
continue
if q[d] == ".":
for c in sorted(y.next, reverse=True):
stack.append((y.next[c], t + c))
elif q[d] in y.next:
stack.append((y.next[q[d]], t + q[d]))
return out
def node_count(self) -> int:
count, stack = 0, [self.root]
while stack:
y = stack.pop()
count += 1
stack.extend(y.next.values())
return count
def check_invariant(self) -> None:
"""Check (T2) and (T3)."""
valued, stack = 0, [self.root]
while stack:
y = stack.pop()
if y.val is not None:
valued += 1
elif y is not self.root:
assert y.next, "a node without a value must have a child"
stack.extend(y.next.values())
assert valued == self.n
test_trie.py 84 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/trie/. The authors are listed in prov/SJRBA7.jsonl.
import random
import re
from trie import Trie
WORDS = "she sells sea shells by the sea shore".split()
def build() -> Trie[int]:
t: Trie[int] = Trie()
for i, w in enumerate(WORDS):
t.put(w, i)
t.check_invariant()
return t
def test_example() -> None:
t = build()
assert t.size() == 7
assert t.node_count() == 20
assert t.get("shells") == 3
assert t.get("sea") == 6
assert t.get("shell") is None
assert t.get("shy") is None
assert t.keys_with_prefix("sh") == ["she", "shells", "shore"]
assert t.keys() == ["by", "sea", "sells", "she", "shells", "shore", "the"]
assert t.longest_prefix_of("shellsort") == "shells"
assert t.longest_prefix_of("shelter") == "she"
assert t.longest_prefix_of("xyz") is None
assert t.keys_that_match(".he") == ["she", "the"]
t.delete("shells")
t.check_invariant()
assert t.node_count() == 17
assert t.get("she") == 0
def test_edges() -> None:
t: Trie[int] = Trie()
assert t.get("") is None and t.keys() == [] and t.longest_prefix_of("a") is None
t.put("", 1)
assert t.get("") == 1 and t.longest_prefix_of("abc") == ""
t.delete("x")
t.delete("")
t.check_invariant()
assert t.size() == 0 and t.node_count() == 1
t.put("a", 1)
t.put("ab", 2)
t.delete("a")
t.check_invariant()
assert t.get("ab") == 2 and t.node_count() == 3
def test_random_against_dict() -> None:
rng = random.Random(4417)
for _ in range(500):
t: Trie[int] = Trie()
model: dict[str, int] = {}
for _ in range(rng.randint(0, 40)):
k = "".join(rng.choice("abc") for _ in range(rng.randint(0, 4)))
op = rng.random()
if op < 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)
prefixes = {k[:i] for k in model for i in range(len(k) + 1)}
assert t.node_count() == len(prefixes | {""})
p = "".join(rng.choice("abc") for _ in range(rng.randint(0, 2)))
assert t.keys_with_prefix(p) == sorted(k for k in model if k.startswith(p))
s = "".join(rng.choice("abc") for _ in range(rng.randint(0, 5)))
cands = [k for k in model if s.startswith(k)]
assert t.longest_prefix_of(s) == (max(cands, key=len) if cands else None)
q = "".join(rng.choice("ab.") for _ in range(rng.randint(0, 3)))
rx = re.compile(q)
assert t.keys_that_match(q) == sorted(k for k in model if rx.fullmatch(k))
What the next level needs
- the tests did not run in this build