Sparse suffix tree in Python
Suffix tree built only for a sampled subset of suffixes, trading query time for space.
- File
sparse_suffix_tree.py, 128 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/sparse-suffix-tree/. The authors are listed in prov/WSA7VT.jsonl.
END = "\0"
class _Node:
__slots__ = ("start", "end", "depth", "rep", "leaf", "children")
def __init__(self, start: int, end: int, depth: int, rep: int, leaf: int = -1) -> None:
self.start = start # the edge into the node is text[start:end]
self.end = end
self.depth = depth # string depth
self.rep = rep # a sampled suffix in the subtree
self.leaf = leaf # the sampled suffix of a leaf, or -1
self.children: dict[str, _Node] = {}
class SparseSuffixTree:
"""The compacted trie of the suffixes of a text that start at a chosen set of positions.
The text gets an end marker, so every chosen suffix ends at its own leaf. The
tree has at most 2b nodes for b chosen suffixes, and edges are stored as
intervals of the text.
"""
def __init__(self, text: str, positions: list[int]) -> None:
if END in text:
raise ValueError("the text must not contain the end marker")
t = self.text = text + END
self.n = len(text)
self.positions = sorted(set(positions))
if any(not 0 <= i < self.n for i in self.positions):
raise ValueError("positions must lie in the text")
order = sorted(self.positions, key=lambda i: t[i:]) # @line L1
self.root = _Node(0, 0, 0, -1)
stack = [self.root]
prev = -1
for s in order:
h = 0
if prev >= 0:
while t[prev + h] == t[s + h]:
h += 1
last = None
while stack[-1].depth > h: # @line L2
last = stack.pop()
top = stack[-1]
if last is not None and top.depth < h: # @line L3
u = _Node(last.rep + top.depth, last.rep + h, h, last.rep)
top.children[t[u.start]] = u
last.start = last.rep + h
u.children[t[last.start]] = last
stack.append(u)
top = u
leaf = _Node(s + top.depth, len(t), len(t) - s, s, s) # @line L4
top.children[t[leaf.start]] = leaf
stack.append(leaf)
if self.root.rep < 0:
self.root.rep = s
prev = s
def _locus(self, p: str) -> _Node | None:
"""The highest node whose string has p as a prefix, or None."""
t, x, d = self.text, self.root, 0
while d < len(p): # @line L5
y = x.children.get(p[d])
if y is None:
return None
k = y.start
while k < y.end and d < len(p):
if t[k] != p[d]:
return None
k, d = k + 1, d + 1
x = y
return x
def sampled_occurrences(self, p: str) -> list[int]:
"""The chosen positions where p occurs, sorted."""
x = self._locus(p)
if x is None:
return []
out: list[int] = []
stack = [x]
while stack: # @line L6
y = stack.pop()
if y.leaf >= 0:
out.append(y.leaf)
stack.extend(y.children.values())
return sorted(out)
def all_occurrences(self, p: str, k: int) -> list[int]:
"""All occurrences of p, when the chosen positions are the multiples of k and |p| >= k."""
if self.positions != list(range(0, self.n, k)) or len(p) < k:
raise ValueError("needs the positions 0, k, 2k, ... and a pattern of length at least k")
out = []
for r in range(k): # @line L7
for j in self.sampled_occurrences(p[r:]):
if j - r >= 0 and self.text[j - r: j] == p[:r]: # @line L8
out.append(j - r)
return sorted(out)
def node_count(self) -> int:
count, stack = 0, [self.root]
while stack:
y = stack.pop()
count += 1
stack.extend(y.children.values())
return count
def check_invariant(self) -> None:
t = self.text
leaves: list[int] = []
stack: list[tuple[_Node, str]] = [(self.root, "")]
while stack:
x, s = stack.pop()
assert x.depth == len(s)
if x is not self.root:
assert t[x.rep: x.rep + x.depth] == s, "rep spells the node"
if x.leaf >= 0:
assert not x.children and s == t[x.leaf:]
leaves.append(x.leaf)
elif x is not self.root:
assert len(x.children) >= 2, "compacted"
for c, y in x.children.items():
assert y.end > y.start and t[y.start] == c
stack.append((y, s + t[y.start: y.end]))
assert sorted(leaves) == self.positions
assert self.node_count() <= 2 * len(self.positions) + 1
test_sparse_suffix_tree.py 56 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/sparse-suffix-tree/. The authors are listed in prov/WSA7VT.jsonl.
import random
import pytest
from sparse_suffix_tree import SparseSuffixTree
def occurrences(t: str, p: str) -> list[int]:
return [i for i in range(len(t) - len(p) + 1) if t.startswith(p, i)]
def test_example() -> None:
x = SparseSuffixTree("abracadabra", [0, 3, 6, 9])
x.check_invariant()
assert x.node_count() == 6
assert sorted(x.root.children) == ["a", "d", "r"]
assert x.sampled_occurrences("a") == [0, 3]
assert x.sampled_occurrences("abra") == [0]
assert x.all_occurrences("abra", 3) == [0, 7]
assert x.all_occurrences("bra", 3) == [1, 8]
assert x.all_occurrences("cad", 3) == [4]
def test_edges() -> None:
x = SparseSuffixTree("abc", [])
x.check_invariant()
assert x.sampled_occurrences("a") == [] and x.node_count() == 1
t = "mississippi"
full = SparseSuffixTree(t, list(range(len(t))))
full.check_invariant()
assert full.sampled_occurrences("ssi") == [2, 5]
assert full.all_occurrences("i", 1) == occurrences(t, "i")
with pytest.raises(ValueError):
full.all_occurrences("ss", 3)
with pytest.raises(ValueError):
SparseSuffixTree("a\0", [0])
def test_random_against_scan() -> None:
rng = random.Random(1996)
for _ in range(600):
t = "".join(rng.choice("ab" if rng.random() < 0.5 else "abc") for _ in range(rng.randint(1, 30)))
sample = [i for i in range(len(t)) if rng.random() < 0.4]
x = SparseSuffixTree(t, sample)
x.check_invariant()
k = rng.randint(1, 4)
y = SparseSuffixTree(t, list(range(0, len(t), k)))
y.check_invariant()
for _ in range(3):
p = "".join(rng.choice("abc") for _ in range(rng.randint(1, 6)))
assert x.sampled_occurrences(p) == [i for i in occurrences(t, p) if i in sample]
if len(p) >= k:
assert y.all_occurrences(p, k) == occurrences(t, p)
What the next level needs
- the tests did not run in this build