Algorithm Search⌘K

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
suffix_automaton.pyRawGitHub
# 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

Read the full page about Suffix automaton