Subsequence automaton in Python
A linear-size automaton that answers subsequence queries against many patterns in time linear in pattern length.
- File
subsequence_automaton.py, 70 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/subsequence-automaton/. The authors are listed in prov/BAZPF7.jsonl.
from bisect import bisect_right
from collections.abc import Hashable, Iterable, Sequence
from typing import Generic, TypeVar
T = TypeVar("T", bound=Hashable)
class SubsequenceAutomaton(Generic[T]):
"""States 0..n; nxt[i][c] is the smallest j > i with text[j - 1] == c, or None."""
def __init__(self, text: Sequence[T]) -> None:
self.n = len(text)
self.letters: list[T] = list(dict.fromkeys(text))
self.index = {c: a for a, c in enumerate(self.letters)}
row: list[int | None] = [None] * len(self.letters) # @line L1
self.nxt: list[list[int | None]] = [row]
for i in range(self.n - 1, -1, -1): # @line L2
row = row.copy() # @line L3
row[self.index[text[i]]] = i + 1 # @line L4
self.nxt.append(row)
self.nxt.reverse() # @line L5
def step(self, q: int | None, c: T) -> int | None:
"""Return the state after reading c from state q, or None."""
if q is None or c not in self.index:
return None
return self.nxt[q][self.index[c]]
def contains(self, pattern: Iterable[T]) -> bool:
"""Return True when pattern is a subsequence of the text."""
q: int | None = 0 # @line L6
for c in pattern: # @line L7
q = self.step(q, c) # @line L8
if q is None: # @line L9
return False
return True # @line L10
def count_distinct_subsequences(self) -> int:
"""Return the number of distinct subsequences of the text, the empty one included."""
cnt = [0] * (self.n + 1)
for i in range(self.n, -1, -1): # @line L11
cnt[i] = 1 + sum(cnt[j] for j in self.nxt[i] if j is not None)
return cnt[0] # @line L12
class CompactSubsequenceIndex(Generic[T]):
"""The same queries in O(n) space: sorted position lists and binary search."""
def __init__(self, text: Sequence[T]) -> None:
self.pos: dict[T, list[int]] = {}
for j, c in enumerate(text, start=1):
self.pos.setdefault(c, []).append(j)
def step(self, q: int | None, c: T) -> int | None:
if q is None or c not in self.pos:
return None
lst = self.pos[c]
k = bisect_right(lst, q)
return lst[k] if k < len(lst) else None
def contains(self, pattern: Iterable[T]) -> bool:
q: int | None = 0
for c in pattern:
q = self.step(q, c)
if q is None:
return False
return True
test_subsequence_automaton.py 41 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/subsequence-automaton/. The authors are listed in prov/BAZPF7.jsonl.
import itertools
import random
from subsequence_automaton import CompactSubsequenceIndex, SubsequenceAutomaton
def is_subsequence(p: str, t: str) -> bool:
it = iter(t)
return all(c in it for c in p)
def test_banana() -> None:
a = SubsequenceAutomaton("banana")
assert [a.step(0, c) for c in "abn"] == [2, 1, 3]
assert a.step(3, "a") == 4 and a.step(3, "n") == 5 and a.step(3, "b") is None
assert a.contains("bnn") and a.contains("aaa") and a.contains("")
assert not a.contains("nb") and not a.contains("aaaa") and not a.contains("x")
assert a.count_distinct_subsequences() == 40
def test_counts() -> None:
assert SubsequenceAutomaton("").count_distinct_subsequences() == 1
assert SubsequenceAutomaton("aaa").count_distinct_subsequences() == 4
assert SubsequenceAutomaton("abc").count_distinct_subsequences() == 8
assert SubsequenceAutomaton("ABCBDAB").count_distinct_subsequences() == 102
def test_random_against_brute_force() -> None:
rng = random.Random(1991)
for _ in range(200):
t = "".join(rng.choice("abc") for _ in range(rng.randrange(10)))
a = SubsequenceAutomaton(t)
c = CompactSubsequenceIndex(t)
subs = {"".join(s) for k in range(len(t) + 1) for s in itertools.combinations(t, k)}
assert a.count_distinct_subsequences() == len(subs)
for _ in range(30):
p = "".join(rng.choice("abcd") for _ in range(rng.randrange(6)))
assert a.contains(p) == c.contains(p) == is_subsequence(p, t)
What the next level needs
- the tests did not run in this build