Suffix tree from suffix array in Python
Build the suffix tree from the suffix array and LCP array in linear time.
- File
suffix_tree_from_suffix_array.py, 106 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-from-suffix-array/. The authors are listed in prov/Q1HEHJ.jsonl.
SENTINEL = "\0"
class Node:
"""A suffix tree node. depth is the string depth; start is the start of a suffix below it.
The label of the edge into the node is text[start + parent.depth : start + depth].
leaf is the suffix number for a leaf and -1 for an internal node.
"""
__slots__ = ("depth", "start", "leaf", "children")
def __init__(self, depth: int, start: int, leaf: int = -1) -> None:
self.depth = depth
self.start = start
self.leaf = leaf
self.children: list[Node] = []
def suffix_array(t: str) -> list[int]:
"""A simple suffix array, for the tests and the examples."""
return sorted(range(len(t)), key=lambda i: t[i:])
def lcp_array(t: str, sa: list[int]) -> list[int]:
"""Kasai's algorithm: lcp[i] is the longest common prefix of the suffixes sa[i-1] and sa[i]; lcp[0] = 0."""
n = len(t)
rank = [0] * n
for i, p in enumerate(sa):
rank[p] = i
lcp = [0] * n
h = 0
for p in range(n):
if rank[p] > 0:
q = sa[rank[p] - 1]
while p + h < n and q + h < n and t[p + h] == t[q + h]:
h += 1
lcp[rank[p]] = h
if h > 0:
h -= 1
else:
h = 0
return lcp
def suffix_tree_from_sa(t: str, sa: list[int], lcp: list[int]) -> Node:
"""Build the suffix tree of t from its suffix array and LCP array in O(n) time.
t must end with a symbol that occurs nowhere else, so that every suffix ends at a leaf.
"""
n = len(t)
root = Node(0, 0)
stack = [root] # @line L1
for i in range(n):
h, last = lcp[i], None # @line L2
while stack[-1].depth > h: # @line L3
last = stack.pop()
top = stack[-1]
if top.depth < h: # @line L4
assert last is not None
u = Node(h, last.start)
top.children[-1] = u
u.children.append(last)
stack.append(u)
top = u
v = Node(n - sa[i], sa[i], sa[i]) # @line L5
top.children.append(v)
stack.append(v)
return root # @line L6
def build(s: str) -> tuple[str, Node]:
"""Append the sentinel to s and build its suffix tree through the suffix array."""
if SENTINEL in s:
raise ValueError("the text must not contain the sentinel")
t = s + SENTINEL
sa = suffix_array(t)
return t, suffix_tree_from_sa(t, sa, lcp_array(t, sa))
def edge_label(t: str, parent: Node, child: Node) -> str:
return t[child.start + parent.depth: child.start + child.depth]
def check_invariant(t: str, root: Node) -> None:
"""Every internal node but the root branches, children are sorted by their first symbol,
and each leaf spells its suffix."""
leaves = 0
stack: list[tuple[Node, str]] = [(root, "")]
while stack:
x, path = stack.pop()
assert len(path) == x.depth
if x.leaf >= 0:
assert not x.children and path == t[x.leaf:]
leaves += 1
continue
assert x is root or len(x.children) >= 2, "unary internal node"
firsts = [edge_label(t, x, c)[0] for c in x.children]
assert firsts == sorted(firsts) and len(set(firsts)) == len(firsts)
for c in x.children:
assert c.depth > x.depth and t.startswith(path, c.start)
stack.append((c, path + edge_label(t, x, c)))
assert leaves == len(t)
test_suffix_tree_from_suffix_array.py 60 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/suffix-tree-from-suffix-array/. The authors are listed in prov/Q1HEHJ.jsonl.
import random
import pytest
from suffix_tree_from_suffix_array import Node, build, check_invariant, edge_label, lcp_array, suffix_array
def internal_labels(t: str, root: Node) -> set[str]:
out: set[str] = set()
stack: list[tuple[Node, str]] = [(root, "")]
while stack:
x, path = stack.pop()
if x.leaf < 0:
out.add(path)
for c in x.children:
stack.append((c, path + edge_label(t, x, c)))
return out
def branching_substrings(t: str) -> set[str]:
"""The right-branching substrings of t, plus the empty string: the internal nodes of the suffix tree."""
follow: dict[str, set[str]] = {}
for i in range(len(t)):
for j in range(i, len(t)):
follow.setdefault(t[i:j], set()).add(t[j])
return {w for w, f in follow.items() if len(f) >= 2} | {""}
def test_example() -> None:
t = "banana\0"
sa = suffix_array(t)
lcp = lcp_array(t, sa)
assert sa == [6, 5, 3, 1, 0, 4, 2] and lcp == [0, 0, 1, 3, 0, 0, 2]
t, root = build("banana")
check_invariant(t, root)
assert sorted(internal_labels(t, root)) == ["", "a", "ana", "na"]
assert [edge_label(t, root, c) for c in root.children] == ["\0", "a", "banana\0", "na"]
def test_edges() -> None:
for s in ["", "a", "aaaa", "abab", "abcd"]:
t, root = build(s)
check_invariant(t, root)
assert internal_labels(t, root) == branching_substrings(t)
t, root = build("aaaa")
assert sorted(internal_labels(t, root)) == ["", "a", "aa", "aaa"]
with pytest.raises(ValueError):
build("a\0b")
def test_random_against_naive() -> None:
rng = random.Random(2004)
for case in range(600):
s = "".join(rng.choice("ab" if case % 2 else "abc") for _ in range(rng.randint(0, 25)))
t, root = build(s)
check_invariant(t, root)
assert internal_labels(t, root) == branching_substrings(t)
What the next level needs
- the tests did not run in this build