Generalized suffix automaton in Python
A suffix automaton over several strings, built by inserting each string into one automaton.
- File
generalized_suffix_automaton.py, 110 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/generalized-suffix-automaton/. The authors are listed in prov/NCDQ46.jsonl.
from collections.abc import Hashable, Sequence
class GeneralizedSuffixAutomaton:
"""Suffix automaton of a list of strings, built by inserting each string from the root.
Symbols only need to be hashable; no sentinel or separator is used, and
positions are 0-based. State 0 is the root. Each state v stores len[v], the
length of the longest string of its class, link[v], and its transitions.
"""
def __init__(self, strings: Sequence[Sequence[Hashable]]) -> None:
self.strings = list(strings)
self.length: list[int] = [0]
self.link: list[int] = [-1]
self.next: list[dict[Hashable, int]] = [{}]
self.prefix_states: list[list[int]] = []
for s in self.strings:
last, states = 0, []
for c in s:
last = self.extend(last, c)
states.append(last)
self.prefix_states.append(states)
self._masks: list[int] | None = None
def _new_state(self, length: int, link: int, trans: dict[Hashable, int]) -> int:
self.length.append(length)
self.link.append(link)
self.next.append(trans)
return len(self.length) - 1
def extend(self, last: int, c: Hashable) -> int:
"""Add the symbol c after the string whose state is last; return the state of the longer string."""
if c in self.next[last]: # @line L1
q = self.next[last][c]
if self.length[q] == self.length[last] + 1: # @line L2
return q
return self._split(last, c, q) # @line L3
cur = self._new_state(self.length[last] + 1, -1, {}) # @line L4
p = last
while p != -1 and c not in self.next[p]: # @line L5
self.next[p][c] = cur
p = self.link[p]
if p == -1: # @line L6
self.link[cur] = 0
else:
q = self.next[p][c] # @line L7
if self.length[q] == self.length[p] + 1:
self.link[cur] = q
else:
self.link[cur] = self._split(p, c, q) # @line L8
return cur # @line L9
def _split(self, p: int, c: Hashable, q: int) -> int:
"""Split the class of q so that the strings of length up to len[p] + 1 get their own state."""
clone = self._new_state(self.length[p] + 1, self.link[q], dict(self.next[q])) # @line L10
while p != -1 and self.next[p].get(c) == q: # @line L11
self.next[p][c] = clone
p = self.link[p]
self.link[q] = clone # @line L12
return clone
def state_count(self) -> int:
return len(self.length)
def walk(self, pattern: Sequence[Hashable]) -> int:
"""State reached by reading pattern from the root, or -1."""
v = 0
for c in pattern:
v = self.next[v].get(c, -1)
if v == -1:
return -1
return v
def contains(self, pattern: Sequence[Hashable]) -> bool:
"""True if pattern is a substring of at least one of the strings."""
return self.walk(pattern) != -1
def _string_masks(self) -> list[int]:
"""mask[v] has bit k set if the strings of state v occur in string k."""
if self._masks is None:
mask = [0] * len(self.length)
for k, states in enumerate(self.prefix_states):
for v in states:
mask[v] |= 1 << k
for v in sorted(range(1, len(self.length)), key=self.length.__getitem__, reverse=True):
mask[self.link[v]] |= mask[v]
self._masks = mask
return self._masks
def strings_containing(self, pattern: Sequence[Hashable]) -> list[int]:
"""Indices of the strings that contain pattern."""
v = self.walk(pattern)
if v == -1:
return []
mask = self._string_masks()[v]
return [k for k in range(len(self.strings)) if mask >> k & 1]
def distinct_substrings(self) -> int:
"""Number of distinct nonempty strings that are a substring of at least one string."""
return sum(self.length[v] - self.length[self.link[v]] for v in range(1, len(self.length)))
def longest_common_substring(self, k: int | None = None) -> int:
"""Length of the longest string that occurs in at least k of the strings (all by default)."""
need = len(self.strings) if k is None else k
mask = self._string_masks()
return max((self.length[v] for v in range(len(self.length)) if bin(mask[v]).count("1") >= need), default=0)
test_generalized_suffix_automaton.py 70 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/generalized-suffix-automaton/. The authors are listed in prov/NCDQ46.jsonl.
import random
from generalized_suffix_automaton import GeneralizedSuffixAutomaton
def substrings(strings: list[str]) -> set[str]:
return {s[i:j] for s in strings for i in range(len(s)) for j in range(i + 1, len(s) + 1)}
def end_sets(strings: list[str]) -> set[frozenset]:
"""The distinct sets of end positions (k, e) of the nonempty substrings, plus the root."""
classes = {frozenset((k, e) for k, s in enumerate(strings) for e in range(len(s)) for _ in [0] if s[:e + 1].endswith(u)) for u in substrings(strings)}
return classes | {frozenset(["root"])}
def check(strings: list[str]) -> None:
sam = GeneralizedSuffixAutomaton(strings)
subs = substrings(strings)
assert sam.distinct_substrings() == len(subs)
assert sam.state_count() == len(end_sets(strings))
total = sum(len(s) for s in strings)
assert sam.state_count() <= max(1, 2 * total)
for u in subs:
assert sam.contains(u)
assert sam.strings_containing(u) == [k for k, s in enumerate(strings) if u in s]
for u in ("x", "ax", "ba" * 3):
if u not in subs:
assert not sam.contains(u)
for need in range(1, len(strings) + 1):
best = max((len(u) for u in subs if sum(u in s for s in strings) >= need), default=0)
assert sam.longest_common_substring(need) == best
reach, stack = {0}, [0]
while stack:
for w in sam.next[stack.pop()].values():
if w not in reach:
reach.add(w)
stack.append(w)
assert len(reach) == sam.state_count()
def test_example() -> None:
sam = GeneralizedSuffixAutomaton(["ab", "cb"])
assert sam.state_count() == 6
assert sam.length == [0, 1, 2, 1, 2, 1]
assert sam.link == [-1, 0, 5, 0, 5, 0]
assert sam.strings_containing("b") == [0, 1]
check(["ab", "cb"])
check(["abab", "baba", "aab"])
def test_special() -> None:
check([])
check([""])
check(["", "a"])
for n in range(1, 9):
check(["a" * n])
check(["ab" * n, "ba" * n])
check(["a" * n, "a" * (n + 2), "b"])
def test_random() -> None:
rng = random.Random(11)
for trial in range(600):
sigma = (1, 2, 4)[trial % 3]
count = rng.randrange(1, 5)
strings = ["".join(rng.choice("acgt"[:sigma]) for _ in range(rng.randrange(0, 9))) for _ in range(count)]
check(strings)
What the next level needs
- the tests did not run in this build