Algorithm Search⌘K

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

Read the full page about Trie