Suffix automaton in Python
The minimal deterministic automaton accepting all suffixes of a string; built online in linear time.
- File
suffix_automaton.py, 133 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-automaton/. The authors are listed in prov/XE8A8C.jsonl.
from collections.abc import Hashable, Iterable, Sequence
from typing import Any
class SuffixAutomaton:
"""The suffix automaton (DAWG) of a text, built online.
Symbols are any hashable values; transitions are dicts, so only equality
and hashing of symbols are used. Positions are 0-based; no sentinel.
State 0 is the initial state. For each state v: nxt[v] maps a symbol to a
state, link[v] is the suffix link, length[v] is the length of the longest
string of the class, and first[v] is the end position of its first
occurrence. At most 2n - 1 states for n >= 2. No recursion.
"""
def __init__(self, text: Iterable[Hashable] = ()) -> None:
self.nxt: list[dict[Any, int]] = [{}]
self.link: list[int] = [-1]
self.length: list[int] = [0]
self.first: list[int] = [-1]
self.is_clone: list[bool] = [False]
self.last = 0
self.n = 0
self._occ: list[int] | None = None
for c in text:
self.extend(c)
def _state(self, length: int, first: int, nxt: dict[Any, int], link: int, clone: bool) -> int:
self.nxt.append(nxt)
self.link.append(link)
self.length.append(length)
self.first.append(first)
self.is_clone.append(clone)
return len(self.nxt) - 1
def extend(self, c: Hashable) -> None:
"""Append one symbol: the classes of the new suffixes are created or split."""
self._occ = None
cur = self._state(self.length[self.last] + 1, self.n, {}, 0, False) # @line L1
p = self.last # @line L2
while p != -1 and c not in self.nxt[p]: # @line L3
self.nxt[p][c] = cur
p = self.link[p]
if p != -1: # @line L4
q = self.nxt[p][c]
if self.length[p] + 1 == self.length[q]: # @line L5: q is solid
self.link[cur] = q
else: # @line L6: split q by cloning
cl = self._state(self.length[p] + 1, self.first[q], dict(self.nxt[q]), self.link[q], True)
while p != -1 and self.nxt[p].get(c) == q: # @line L7
self.nxt[p][c] = cl
p = self.link[p]
self.link[q] = self.link[cur] = cl # @line L8
self.last = cur # @line L9
self.n += 1
def _walk(self, p: Sequence[Hashable]) -> int:
v = 0
for c in p: # @line L10
v = self.nxt[v].get(c, -1)
if v < 0:
return -1
return v # @line L11
def contains(self, p: Sequence[Hashable]) -> bool:
return self._walk(p) >= 0
def is_suffix(self, p: Sequence[Hashable]) -> bool:
"""Whether p is a suffix of the text: its state lies on the link path of last."""
v = self._walk(p)
if v < 0:
return False
u = self.last
while u != -1 and self.length[u] >= len(p):
if u == v:
return True
u = self.link[u]
return False
def _counts(self) -> list[int]:
if self._occ is None:
occ = [0 if self.is_clone[v] or v == 0 else 1 for v in range(len(self.nxt))]
for v in sorted(range(1, len(self.nxt)), key=lambda v: -self.length[v]):
occ[self.link[v]] += occ[v]
self._occ = occ
return self._occ
def count(self, p: Sequence[Hashable]) -> int:
"""The number of occurrences of p (n for the empty pattern)."""
if len(p) == 0:
return self.n
v = self._walk(p)
return 0 if v < 0 else self._counts()[v]
def first_occurrence(self, p: Sequence[Hashable]) -> int:
"""The start of the leftmost occurrence of p, or -1."""
v = self._walk(p)
if v < 0:
return -1
return 0 if len(p) == 0 else self.first[v] - len(p) + 1
def distinct_substrings(self) -> int:
"""The number of distinct nonempty substrings."""
return sum(self.length[v] - self.length[self.link[v]] for v in range(1, len(self.nxt)))
def state_count(self) -> int:
return len(self.nxt)
def transition_count(self) -> int:
return sum(len(d) for d in self.nxt)
def check_invariant(self) -> None:
"""Links shorten lengths, transitions go forward in length, sizes are bounded."""
m = len(self.nxt)
n = self.n
assert self.link[0] == -1 and self.length[0] == 0
for v in range(1, m):
u = self.link[v]
assert 0 <= u < m and self.length[u] < self.length[v]
for w in self.nxt[v].values():
assert self.length[w] > self.length[v]
for w in self.nxt[0].values():
assert self.length[w] >= 1
if n >= 2:
assert m <= 2 * n - 1
if n >= 3:
assert self.transition_count() <= 3 * n - 4
# the state of the longest string of each class is reached by it
for v in range(1, m):
assert self.length[v] <= n
test_suffix_automaton.py 73 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/suffix-automaton/. The authors are listed in prov/XE8A8C.jsonl.
import itertools
import random
from suffix_automaton import SuffixAutomaton
def endpos_classes(s: str) -> int:
"""The number of distinct end-position sets of substrings, the empty one included."""
sets = set()
for i in range(len(s)):
for j in range(i + 1, len(s) + 1):
x = s[i:j]
sets.add(frozenset(k + len(x) - 1 for k in range(len(s) - len(x) + 1) if s[k:k + len(x)] == x))
return len(sets) + 1
def naive_count(s: str, p: str) -> int:
return sum(1 for i in range(len(s) - len(p) + 1) if s[i:i + len(p)] == p)
def test_example() -> None:
a = SuffixAutomaton("abbb")
a.check_invariant()
assert a.state_count() == 7
assert a.count("b") == 3 and a.count("bb") == 2 and a.count("abb") == 1
assert a.is_suffix("bbb") and not a.is_suffix("ab")
a = SuffixAutomaton("banana")
a.check_invariant()
assert a.distinct_substrings() == 15
assert a.count("ana") == 2 and a.first_occurrence("ana") == 1
assert a.first_occurrence("na") == 2 and a.first_occurrence("nab") == -1
def test_edges() -> None:
a = SuffixAutomaton("")
assert a.state_count() == 1 and a.contains("") and not a.contains("a")
assert a.count("") == 0 and a.distinct_substrings() == 0
for n in (1, 2, 10, 1000):
a = SuffixAutomaton("a" * n)
a.check_invariant()
assert a.state_count() == n + 1 and a.distinct_substrings() == n
k = max(1, n // 2)
assert a.count("a" * k) == n - k + 1
for n in (3, 4, 7, 12):
assert SuffixAutomaton("a" + "b" * (n - 1)).state_count() == 2 * n - 1
assert SuffixAutomaton("a" + "b" * (n - 2) + "c").transition_count() == 3 * n - 4
a = SuffixAutomaton([1, 2, 1, 2, 3])
assert a.count([1, 2]) == 2 and a.is_suffix([2, 3])
def test_random_against_naive() -> None:
rng = random.Random(1985)
for trial in range(600):
sigma = "abcd"[: rng.choice([1, 2, 4])]
n = rng.randint(0, 14)
s = "".join(rng.choice(sigma) for _ in range(n))
if trial % 30 == 0:
s = rng.choice(["a" * n, "ab" * (n // 2), ""])
a = SuffixAutomaton(s)
a.check_invariant()
assert a.state_count() == endpos_classes(s)
subs = {s[i:j] for i in range(len(s)) for j in range(i + 1, len(s) + 1)}
assert a.distinct_substrings() == len(subs)
for k in range(0, min(len(s), 5) + 2):
for t in itertools.product(sigma + "e" if k < 3 else sigma, repeat=k):
p = "".join(t)
assert a.contains(p) == (p in s)
assert a.is_suffix(p) == s.endswith(p)
assert a.count(p) == (naive_count(s, p) if p else len(s))
assert a.first_occurrence(p) == s.find(p)
What the next level needs
- the tests did not run in this build