Compressed suffix tree in Python
A suffix tree stored in compressed space while supporting its navigation operations.
- File
compressed_suffix_tree.py, 309 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/compressed-suffix-tree/. The authors are listed in prov/Q61TVH.jsonl.
from collections.abc import Hashable, Sequence
INF = 1 << 62
Node = tuple[int, int]
def _suffix_array(t: list[int]) -> list[int]:
"""Suffix array by prefix doubling with library sorts; t ends with a unique smallest code."""
n = len(t)
sa, rank, h = list(range(n)), t[:], 1
while n > 1:
key = [(rank[i], rank[i + h] if i + h < n else -1) for i in range(n)]
sa.sort(key=key.__getitem__)
new = [0] * n
for r in range(1, n):
new[sa[r]] = new[sa[r - 1]] + (key[sa[r]] != key[sa[r - 1]])
rank = new
if rank[sa[-1]] == n - 1:
break
h *= 2
return sa
class _GammaStream:
"""Positive integers written with the Elias gamma code into one growing integer."""
def __init__(self) -> None:
self.bits, self.length = 0, 0
def write(self, x: int) -> None:
k = x.bit_length()
self.length += k - 1
self.bits |= int(format(x, "b")[::-1], 2) << self.length
self.length += k
def read(self, pos: int) -> tuple[int, int]:
zeros = 0
while not self.bits >> (pos + zeros) & 1:
zeros += 1
pos += zeros
chunk = (self.bits >> pos) & ((1 << (zeros + 1)) - 1)
return int(format(chunk, "0%db" % (zeros + 1))[::-1], 2), pos + zeros + 1
class CompressedSuffixTree:
"""Suffix tree of T$ from a compressed suffix array, the PLCP bit vector H and range minima.
The symbols need a total order and are coded 1 .. sigma; the sentinel $ is the
code 0, unique and smallest. Ranks and positions are 0-based, N = n + 1. A node
is a rank range (lb, rb): a leaf has lb == rb, the root is (0, n). Neither the
text, nor SA, nor LCP is kept after the build: SA comes from Psi and samples,
LCP[r] = PLCP[SA[r]] comes from H, and the minima of LCP are kept per block of
`block` ranks in a small tree. The boundary values LCP[0] and LCP[N] are -1.
"""
def __init__(self, text: Sequence[Hashable], step: int = 4, block: int = 8) -> None:
self.n = n = len(text)
self.N, self.step, self.block = n + 1, step, block
self.symbols = sorted(set(text))
code = {c: r + 1 for r, c in enumerate(self.symbols)}
t = [code[c] for c in text] + [0]
sa = _suffix_array(t)
isa = [0] * (n + 1)
for i, p in enumerate(sa):
isa[p] = i
# Psi as the increasing values F(i) * N + Psi(i), sampled and gamma coded.
self.samples: list[tuple[int, int]] = []
self.stream, prev = _GammaStream(), 0
for i, p in enumerate(sa):
value = t[p] * self.N + isa[(p + 1) % self.N]
if i % block == 0:
self.samples.append((value, self.stream.length))
else:
self.stream.write(value - prev)
prev = value
self.sa_sample = {i: p for i, p in enumerate(sa) if p % step == 0 or p == n}
self.isa_sample = [isa[p] for p in range(0, self.N, step)]
# PLCP by Kasai's method, then H with a 1 at PLCP[p] + 2p.
plcp, h = [0] * self.N, 0
for p in range(self.N):
if isa[p] > 0:
q = sa[isa[p] - 1]
while t[p + h] == t[q + h]:
h += 1
plcp[p] = h
h = max(h - 1, 0)
else:
h = 0
self.h_bits, self.h_samples = 0, []
for p in range(self.N):
if p % block == 0:
self.h_samples.append(plcp[p] + 2 * p)
self.h_bits |= 1 << (plcp[p] + 2 * p)
# Block minima of LCP[0 .. N] in a tree of minima.
lcp = [-1] + [plcp[sa[r]] for r in range(1, self.N)] + [-1]
nb = (self.N + 1 + block - 1) // block
self.size = 1
while self.size < nb:
self.size *= 2
self.tree = [INF] * (2 * self.size)
for b in range(nb):
self.tree[self.size + b] = min(lcp[b * block:(b + 1) * block])
for v in range(self.size - 1, 0, -1):
self.tree[v] = min(self.tree[2 * v], self.tree[2 * v + 1])
# ----- the compressed suffix array -----
def _value(self, i: int) -> int:
value, pos = self.samples[i // self.block]
for _ in range(i % self.block):
gap, pos = self.stream.read(pos)
value += gap
return value
def psi(self, i: int) -> int:
return self._value(i) % self.N
def first(self, i: int) -> int:
return self._value(i) // self.N
def sa(self, i: int) -> int:
t = 0
while i not in self.sa_sample:
i, t = self.psi(i), t + 1
return self.sa_sample[i] - t
def isa(self, p: int) -> int:
i = self.isa_sample[p // self.step]
for _ in range(p % self.step):
i = self.psi(i)
return i
# ----- LCP from H, and range minima -----
def _plcp(self, p: int) -> int:
"""PLCP[p] = select1(H, p) - 2p, by a scan from the sampled one."""
pos, k = self.h_samples[p // self.block], p % self.block
while k:
pos += 1
if self.h_bits >> pos & 1:
k -= 1
return pos - 2 * p
def lcp(self, r: int) -> int:
"""LCP[r] for 0 <= r <= N, with LCP[0] = LCP[N] = -1."""
return -1 if r <= 0 or r >= self.N else self._plcp(self.sa(r))
def _range_min(self, a: int, b: int) -> int:
"""Minimum of LCP[a .. b]."""
ba, bb, B = a // self.block, b // self.block, self.block
if ba == bb:
return min(self.lcp(r) for r in range(a, b + 1))
best = min(min(self.lcp(r) for r in range(a, (ba + 1) * B)), min(self.lcp(r) for r in range(bb * B, b + 1)))
lo, hi = ba + 1 + self.size, bb - 1 + self.size
while lo <= hi:
if lo & 1:
best, lo = min(best, self.tree[lo]), lo + 1
if not hi & 1:
best, hi = min(best, self.tree[hi]), hi - 1
lo, hi = lo >> 1, hi >> 1
return best
def _fwd(self, start: int, x: int) -> int:
"""The smallest r >= start with LCP[r] < x; N is a valid answer for x >= 0."""
B = self.block
for r in range(start, min((start // B + 1) * B, self.N + 1)):
if self.lcp(r) < x:
return r
i = start // B + 1 + self.size
while self.tree[i] >= x:
while i & 1:
i >>= 1
i += 1
while i < self.size:
i = 2 * i if self.tree[2 * i] < x else 2 * i + 1
return next(r for r in range((i - self.size) * B, self.N + 1) if self.lcp(r) < x)
def _bwd(self, start: int, x: int) -> int:
"""The largest r <= start with LCP[r] < x; 0 is a valid answer for x >= 0."""
B = self.block
for r in range(start, (start // B) * B - 1, -1):
if self.lcp(r) < x:
return r
i = start // B - 1 + self.size
while self.tree[i] >= x:
while not i & 1:
i >>= 1
i -= 1
while i < self.size:
i = 2 * i + 1 if self.tree[2 * i + 1] < x else 2 * i
b = i - self.size
return next(r for r in range(min((b + 1) * B, self.N + 1) - 1, -1, -1) if self.lcp(r) < x)
def rmq(self, a: int, b: int) -> int:
"""The leftmost position of the minimum of LCP[a .. b]."""
return self._fwd(a, self._range_min(a, b) + 1)
# ----- tree operations -----
def root(self) -> Node:
return (0, self.n)
def is_leaf(self, v: Node) -> bool:
return v[0] == v[1]
def depth(self, v: Node) -> int:
"""String depth; a leaf counts the sentinel."""
lb, rb = v
if lb == rb:
return self.N - self.sa(lb) # @line L1
return self.lcp(self.rmq(lb + 1, rb)) # @line L2
def _enclose(self, k: int) -> Node:
"""The node whose value is LCP[k] and whose range contains k - 1 and k."""
h = self.lcp(k)
return (self._bwd(k - 1, h), self._fwd(k + 1, h) - 1)
def parent(self, v: Node) -> Node | None:
lb, rb = v
if v == self.root():
return None
k = lb if self.lcp(lb) > self.lcp(rb + 1) else rb + 1 # @line L3
return self._enclose(k) # @line L4
def first_child(self, v: Node) -> Node | None:
lb, rb = v
if lb == rb:
return None
return (lb, self.rmq(lb + 1, rb) - 1) # @line L5
def next_sibling(self, v: Node) -> Node | None:
lb, rb = v
if v == self.root() or self.lcp(lb) > self.lcp(rb + 1): # @line L6
return None
h = self.lcp(rb + 1) # @line L7
return (rb + 1, self._fwd(rb + 2, h + 1) - 1)
def children(self, v: Node) -> list[Node]:
out, w = [], self.first_child(v)
while w is not None:
out.append(w)
w = self.next_sibling(w)
return out
def lca(self, u: Node, v: Node) -> Node:
if u[0] <= v[0] and v[1] <= u[1]:
return u
if v[0] <= u[0] and u[1] <= v[1]:
return v
if u[0] > v[0]:
u, v = v, u
return self._enclose(self.rmq(u[1] + 1, v[0])) # @line L8
def suffix_link(self, v: Node) -> Node | None:
lb, rb = v
if v == self.root():
return None
if lb == rb:
return self.root() if self.sa(lb) == self.n else (self.psi(lb),) * 2
return self.lca((self.psi(lb),) * 2, (self.psi(rb),) * 2) # @line L9
def letter(self, v: Node, d: int) -> int:
"""Code of the symbol at depth d of the path label of v (0 is the sentinel)."""
return self.first(self.isa(self.sa(v[0]) + d)) # @line L10
def child(self, v: Node, c: Hashable) -> Node | None:
code = {s: r + 1 for r, s in enumerate(self.symbols)}.get(c)
d = self.depth(v)
for w in self.children(v):
if self.letter(w, d) == code: # @line L11
return w
return None
def find(self, pattern: Sequence[Hashable]) -> tuple[int, int]:
"""Rank range [lo, hi) of the suffixes that start with pattern."""
code = {s: r + 1 for r, s in enumerate(self.symbols)}
if any(c not in code for c in pattern):
return (0, 0)
p = [code[c] for c in pattern]
v, d = self.root(), 0
while d < len(p):
if self.is_leaf(v):
return (0, 0)
w = next((w for w in self.children(v) if self.letter(w, d) == p[d]), None)
if w is None:
return (0, 0)
e = min(self.depth(w), len(p))
i = self.isa(self.sa(w[0]) + d + 1) if d + 1 < e else 0
for x in p[d + 1:e]:
if self.first(i) != x:
return (0, 0)
i = self.psi(i)
v, d = w, e
return (v[0], v[1] + 1)
def label(self, v: Node) -> list[Hashable]:
"""The path label of v, without the sentinel."""
out, d = [], self.depth(v)
i = self.isa(self.sa(v[0])) if d else 0
for _ in range(d):
c = self.first(i)
if c == 0:
break
out.append(self.symbols[c - 1])
i = self.psi(i)
return out
test_compressed_suffix_tree.py 102 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/compressed-suffix-tree/. The authors are listed in prov/Q61TVH.jsonl.
import random
from compressed_suffix_tree import CompressedSuffixTree
def naive_tree(text: str) -> tuple[list[int], dict[str, tuple[int, int]]]:
"""SA of T$ by sorting the suffixes, and every node label of the suffix tree of T$ with its rank range."""
t = text + "\0"
sa = sorted(range(len(t)), key=lambda p: t[p:])
nexts: dict[str, set[str]] = {}
for i in range(len(text)):
for j in range(i, len(text) + 1):
nexts.setdefault(text[i:j], set()).add(t[j])
labels = {u for u, s in nexts.items() if len(s) >= 2} | ({""} if text else set())
labels |= {t[p:] for p in range(len(t))}
nodes = {}
for u in labels:
ranks = [r for r, p in enumerate(sa) if t.startswith(u, p)]
nodes[u] = (ranks[0], ranks[-1])
return sa, nodes
def check(text: str, step: int = 4, block: int = 8) -> None:
cst = CompressedSuffixTree(text, step, block)
sa, nodes = naive_tree(text)
t = text + "\0"
n = len(text)
assert [cst.sa(i) for i in range(n + 1)] == sa
by_range = {rng: u for u, rng in nodes.items()}
assert len(by_range) == len(nodes)
seen, todo = [], [cst.root()]
while todo:
v = todo.pop()
seen.append(v)
todo.extend(cst.children(v))
assert sorted(seen) == sorted(by_range), text
for v in seen:
u = by_range[v]
assert cst.depth(v) == len(u)
assert cst.is_leaf(v) == (u.endswith("\0") or (n == 0))
anc = [w for w in nodes if len(w) < len(u) and u.startswith(w)]
par = cst.parent(v)
if anc:
assert par == nodes[max(anc, key=len)], (text, v)
else:
assert par is None and v == cst.root()
kids = sorted((w for w in nodes if w != u and w.startswith(u) and nodes[max((x for x in nodes if len(x) < len(w) and w.startswith(x)), key=len)] == v), key=lambda w: nodes[w])
assert cst.children(v) == [nodes[w] for w in kids]
if v != cst.root():
assert cst.suffix_link(v) == nodes[u[1:]] if u != "\0" else cst.suffix_link(v) == cst.root()
assert "".join(cst.label(v)) == u.rstrip("\0")
for d in range(len(u)):
assert cst.letter(v, d) == (0 if u[d] == "\0" else sorted(set(text)).index(u[d]) + 1)
for c in sorted(set(text)) + ["z"]:
w = cst.child(v, c)
expect = [x for x in kids if len(x) > len(u) and x[len(u)] == c]
assert w == (nodes[expect[0]] if expect else None)
rng = random.Random(len(text))
for _ in range(20):
a, b = rng.choice(seen), rng.choice(seen)
ua, ub = by_range[a], by_range[b]
k = 0
while k < min(len(ua), len(ub)) and ua[k] == ub[k]:
k += 1
common = max((w for w in nodes if ua.startswith(w) and ub.startswith(w)), key=len)
assert cst.lca(a, b) == nodes[common]
pats = {text[i:j] for i in range(n) for j in range(i, min(n, i + 5) + 1)} | {"z", "ba", "aaa"}
for pat in pats:
lo, hi = cst.find(pat)
expect = [r for r, p in enumerate(sa) if t.startswith(pat, p)]
assert list(range(lo, hi)) == expect, (text, pat)
def test_mississippi() -> None:
cst = CompressedSuffixTree("mississippi")
assert [cst.lcp(r) for r in range(13)] == [-1, 0, 1, 1, 4, 0, 0, 1, 0, 2, 1, 3, -1]
assert cst.children(cst.root()) == [(0, 0), (1, 4), (5, 5), (6, 7), (8, 11)]
assert cst.parent((10, 11)) == (8, 11)
assert cst.suffix_link((10, 11)) == (8, 9)
assert cst.depth((3, 4)) == 4
check("mississippi")
check("mississippi", 2, 2)
check("mississippi", 3, 1)
def test_special() -> None:
check("")
for n in range(1, 16):
check("a" * n)
check("ab" * n)
check("ab" * n + "a", 3, 2)
def test_random() -> None:
rng = random.Random(23)
for trial in range(500):
sigma = (1, 2, 4)[trial % 3]
text = "".join(rng.choice("acgt"[:sigma]) for _ in range(rng.randrange(0, 17)))
check(text, rng.choice((1, 2, 3, 4)), rng.choice((1, 2, 3, 8)))
What the next level needs
- the tests did not run in this build