Algorithm Search⌘K

Suffix tree in Python

A compacted suffix trie with linear size, supporting substring queries in time linear in the pattern length.

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

from collections.abc import Hashable, Sequence
from typing import Any


class _End:
    """The sentinel: equal only to itself, and smaller than every symbol."""

    def __repr__(self) -> str:
        return "$"


END = _End()


class SuffixTree:
    """The suffix tree of S$, built by inserting the suffixes one by one.

    The text S[0 .. n-1] is any sequence of hashable symbols; children are
    found by hashing, and order (<) is used only by suffix_array. Positions
    are 0-based. A unique sentinel END is appended, so the tree has exactly
    n + 1 leaves, one for each suffix of S$. An edge into a node v is labeled
    t[start[v] : end[v]] where t = S + [END]. The build is the simple
    quadratic one: O(n^2) time in the worst case, O(n) space.
    """

    def __init__(self, s: Sequence[Hashable]) -> None:
        self.t: list[Any] = list(s) + [END]
        self.n = len(s)
        self.start: list[int] = [0]
        self.end: list[int] = [0]
        self.depth: list[int] = [0]  # string depth of each node
        self.leaf: list[int] = [-1]  # suffix start for a leaf, -1 otherwise
        self.children: list[dict[Any, int]] = [{}]
        t = self.t
        for i in range(self.n + 1):  # @line L1: insert the suffix t[i:]
            v, j = 0, i  # @line L2: at node v, t[i:j] is spelled
            while True:
                c = t[j]
                if c not in self.children[v]:  # @line L3: no edge starts with c
                    self._new_node(v, j, len(t), i)  # @line L4: add a leaf
                    break
                w = self.children[v][c]  # @line L5
                a, b = self.start[w], self.end[w]
                k = 0
                while a + k < b and t[a + k] == t[j + k]:  # @line L6: walk along the edge
                    k += 1
                if a + k == b:  # @line L7: the whole edge matched
                    v, j = w, j + k
                    continue
                u = self._new_node(v, a, a + k, -1)  # @line L8: split the edge at depth k
                self.start[w] = a + k
                self.children[u][t[a + k]] = w
                self._new_node(u, j + k, len(t), i)  # @line L9: add a leaf below the split
                break
        self._count = self._leaf_counts()

    def _new_node(self, parent: int, a: int, b: int, leaf: int) -> int:
        v = len(self.start)
        self.start.append(a)
        self.end.append(b)
        self.depth.append(self.depth[parent] + b - a)
        self.leaf.append(leaf)
        self.children.append({})
        self.children[parent][self.t[a]] = v
        return v

    def _leaf_counts(self) -> list[int]:
        """The number of leaves of real suffixes (start < n) below each node."""
        cnt = [0] * len(self.start)
        order, stack = [], [0]
        while stack:
            v = stack.pop()
            order.append(v)
            stack.extend(self.children[v].values())
        for v in reversed(order):
            if 0 <= self.leaf[v] < self.n:
                cnt[v] = 1
            for w in self.children[v].values():
                cnt[v] += cnt[w]
        return cnt

    def _locus(self, p: Sequence[Hashable]) -> int | None:
        """The highest node v with p a prefix of the path to v, or None."""
        v, i, t = 0, 0, self.t
        while i < len(p):  # @line L10
            w = self.children[v].get(p[i])  # @line L11
            if w is None:
                return None
            a, b = self.start[w], self.end[w]
            k = 0
            while k < b - a and i < len(p):  # @line L12
                if t[a + k] != p[i]:
                    return None
                k += 1
                i += 1
            v = w
        return v  # @line L13

    def contains(self, p: Sequence[Hashable]) -> bool:
        return self._locus(p) is not None

    def count(self, p: Sequence[Hashable]) -> int:
        v = self._locus(p)
        return 0 if v is None else self._count[v]

    def occurrences(self, p: Sequence[Hashable]) -> list[int]:
        v = self._locus(p)
        if v is None:
            return []
        out, stack = [], [v]
        while stack:
            x = stack.pop()
            if 0 <= self.leaf[x] < self.n:
                out.append(self.leaf[x])
            stack.extend(self.children[x].values())
        return sorted(out)

    def suffix_array(self) -> list[int]:
        """The suffix array of S, from a walk with children in symbol order."""
        out, stack = [], [0]
        while stack:
            v = stack.pop()
            if 0 <= self.leaf[v] < self.n:
                out.append(self.leaf[v])
            keys = sorted(self.children[v], key=lambda c: (c is not END, 0 if c is END else c))
            stack.extend(self.children[v][c] for c in reversed(keys))
        return out

    def node_count(self) -> int:
        return len(self.start)

    def leaf_count(self) -> int:
        return sum(1 for x in self.leaf if x >= 0)

    def check_invariant(self) -> None:
        t, n = self.t, self.n
        assert self.leaf_count() == n + 1
        seen: set[int] = set()
        stack = [(0, 0)]
        while stack:
            v, d = stack.pop()
            assert self.depth[v] == d
            kids = self.children[v]
            if v != 0:
                assert self.start[v] < self.end[v]
            if self.leaf[v] >= 0:
                assert not kids and self.end[v] == len(t)
                i = self.leaf[v]
                assert d == len(t) - i and i not in seen
                seen.add(i)
            elif v != 0:
                assert len(kids) >= 2  # no inner node with one child
            for c, w in kids.items():
                assert t[self.start[w]] == c
                stack.append((w, d + self.end[w] - self.start[w]))
        # each leaf spells its suffix: compare the path labels
        parent = {w: v for v in range(len(self.start)) for w in self.children[v].values()}
        for v in range(len(self.start)):
            if self.leaf[v] >= 0:
                i, x, pos = self.leaf[v], v, len(t)
                while x != 0:
                    a, b = self.start[x], self.end[x]
                    pos -= b - a
                    assert t[a:b] == t[pos:pos + b - a]
                    x = parent[x]
                assert pos == i
        assert len(self.start) <= 2 * (n + 1)
test_suffix_tree.py 73 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/suffix-tree/. The authors are listed in prov/21ZQTC.jsonl.

import random

from suffix_tree import SuffixTree


def naive_occ(s: str, p: str) -> list[int]:
    return [i for i in range(len(s) - len(p) + 1) if s[i:i + len(p)] == p]


def naive_node_count(s: str) -> int:
    """Leaves of S$ plus the root plus the right-branching substrings of S."""
    t = list(s) + [None]
    n = len(s)
    ext: dict[str, set] = {}
    for i in range(n):
        for j in range(i + 1, n + 1):
            ext.setdefault(s[i:j], set()).add(t[j])
    inner = sum(1 for x in ext.values() if len(x) >= 2)
    return (n + 1) + 1 + inner


def test_example() -> None:
    st = SuffixTree("banana")
    st.check_invariant()
    assert st.suffix_array() == [5, 3, 1, 0, 4, 2]
    assert st.node_count() == 11 and st.leaf_count() == 7
    assert st.occurrences("ana") == [1, 3]
    assert st.count("an") == 2 and st.count("nab") == 0
    assert st.count("") == 6
    st = SuffixTree("abab")
    st.check_invariant()
    assert st.node_count() == naive_node_count("abab") == 8


def test_edges() -> None:
    st = SuffixTree("")
    st.check_invariant()
    assert st.node_count() == 2 and st.suffix_array() == []
    assert st.count("a") == 0 and st.count("") == 0
    for n in (1, 2, 50, 300):
        st = SuffixTree("a" * n)
        st.check_invariant()
        assert st.node_count() == 2 * n + 1
        assert st.suffix_array() == list(range(n - 1, -1, -1))
    st = SuffixTree([3, 1, 4, 1, 5, 9, 2, 6])
    st.check_invariant()
    assert st.suffix_array() == [1, 3, 6, 0, 2, 4, 7, 5]


def test_random_against_naive() -> None:
    rng = random.Random(1973)
    for trial in range(600):
        sigma = "abcd"[: rng.choice([1, 2, 4])]
        n = rng.randint(0, 25)
        s = "".join(rng.choice(sigma) for _ in range(n))
        if trial % 30 == 0:
            s = rng.choice(["a" * n, "ab" * (n // 2), ""])
        st = SuffixTree(s)
        st.check_invariant()
        assert st.suffix_array() == sorted(range(len(s)), key=lambda i: s[i:])
        assert st.node_count() == naive_node_count(s)
        for _ in range(8):
            p = "".join(rng.choice(sigma + "e") for _ in range(rng.randint(0, 5)))
            if s and rng.random() < 0.5:
                i = rng.randrange(len(s))
                p = s[i:rng.randint(i, len(s))]
            occ = naive_occ(s, p)
            assert st.occurrences(p) == (occ if p else list(range(len(s))))
            assert st.count(p) == (len(occ) if p else len(s))
            assert st.contains(p) == (bool(occ) or p == "")

What the next level needs

  • the tests did not run in this build

Read the full page about Suffix tree