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