Ukkonen's algorithm in Python
Online linear-time suffix tree construction that extends the tree one character at a time.
- File
ukkonen_algorithm.py, 168 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/ukkonen-algorithm/. The authors are listed in prov/YN86V8.jsonl.
from collections.abc import Hashable, Iterable
from typing import Any
class _End:
"""The sentinel: equal only to itself, and smaller than every symbol."""
def __repr__(self) -> str:
return "$"
END = _End()
class UkkonenTree:
"""Ukkonen's online suffix tree construction.
Symbols are any hashable values, found in child dicts by hashing; order
is used only by suffix_array. Positions are 0-based. extend(c) appends
one symbol and keeps the implicit suffix tree of the text read so far;
finish() appends the unique sentinel END, after which the tree is the
suffix tree of S$, with one leaf for each suffix. A leaf edge ends at the
current end of the text ("open" edge), stored as -1. Amortized O(1) time
per symbol for a constant alphabet; no recursion.
"""
def __init__(self, text: Iterable[Hashable] = ()) -> None:
self.t: list[Any] = []
self.start: list[int] = [0]
self.end: list[int] = [0] # -1 means the current end of the text
self.link: list[int] = [0]
self.leaf: list[int] = [-1] # suffix start of a leaf, -1 for inner nodes
self.children: list[dict[Any, int]] = [{}]
self.node, self.edge, self.length = 0, 0, 0 # @line L1: the active point
self.remainder = 0 # suffixes still to insert explicitly
self.finished = False
for c in text:
self.extend(c)
def _new(self, a: int, b: int, leaf: int) -> int:
self.start.append(a)
self.end.append(b)
self.link.append(0)
self.leaf.append(leaf)
self.children.append({})
return len(self.start) - 1
def _len(self, v: int) -> int:
b = len(self.t) if self.end[v] == -1 else self.end[v]
return b - self.start[v]
def extend(self, c: Hashable) -> None:
"""Append c to the text: phase j of the algorithm."""
assert not self.finished
t = self.t
t.append(c)
j = len(t) - 1 # @line L2
self.remainder += 1 # @line L3
last_new = 0 # the inner node that waits for its suffix link (0: none)
while self.remainder > 0: # @line L4
if self.length == 0: # @line L5
self.edge = j
kids = self.children[self.node]
w = kids.get(t[self.edge])
if w is None: # @line L6: rule 2, a new leaf at a node
kids[t[self.edge]] = self._new(j, -1, j - self.remainder + 1) # @line L7
if last_new:
self.link[last_new] = self.node
last_new = 0
else:
ln = self._len(w)
if self.length >= ln: # @line L8: skip and count down the edge
self.node, self.edge, self.length = w, self.edge + ln, self.length - ln
continue
if t[self.start[w] + self.length] == c: # @line L9: rule 3, already there
self.length += 1
if last_new:
self.link[last_new] = self.node
break
a = self.start[w] # @line L10: rule 2 inside an edge, split it
u = self._new(a, a + self.length, -1)
kids[t[a]] = u
self.start[w] = a + self.length
self.children[u][t[a + self.length]] = w
self.children[u][c] = self._new(j, -1, j - self.remainder + 1)
if last_new:
self.link[last_new] = u
last_new = u
self.remainder -= 1 # @line L11
if self.node == 0 and self.length > 0: # @line L12
self.length -= 1
self.edge = j - self.remainder + 1
else:
self.node = self.link[self.node] # @line L13: follow the suffix link
def finish(self) -> None:
"""Append the sentinel; afterwards every suffix has its own leaf."""
self.extend(END)
self.finished = True
def contains(self, p: Iterable[Hashable]) -> bool:
"""Whether p is a substring of the text read so far (without the sentinel)."""
v, k, t = 0, 0, self.t # k symbols matched on the edge into v
for c in p:
if c is END:
return False
if k == (0 if v == 0 else self._len(v)):
w = self.children[v].get(c)
if w is None:
return False
v, k = w, 0
if t[self.start[v] + k] != c:
return False
k += 1
return True
def suffix_array(self) -> list[int]:
"""The suffix array of S, from the finished tree."""
assert self.finished
n = len(self.t) - 1
out, stack = [], [0]
while stack:
v = stack.pop()
if 0 <= self.leaf[v] < n:
out.append(self.leaf[v])
keys = sorted(self.children[v], key=lambda c: (c is not END, 0 if c is END else c))
stack.extend(self.children[v][c] for c in reversed(keys))
return out
def node_count(self) -> int:
return len(self.start)
def check_invariant(self) -> None:
"""Compact trie; suffix links of inner nodes drop one symbol; leaves spell suffixes."""
t = self.t
parent: dict[int, int] = {}
depth = [0] * len(self.start)
order, stack = [], [0]
while stack:
v = stack.pop()
order.append(v)
for c, w in self.children[v].items():
assert t[self.start[w]] == c and self._len(w) > 0
parent[w] = v
depth[w] = depth[v] + self._len(w)
stack.append(w)
assert len(order) == len(self.start)
def label(v: int) -> list[Any]:
parts = []
while v != 0:
parts.append(t[self.start[v]:self.start[v] + self._len(v)])
v = parent[v]
return [x for part in reversed(parts) for x in part]
for v in order:
if self.leaf[v] >= 0:
assert not self.children[v] and self.end[v] == -1
assert label(v) == t[self.leaf[v]:]
elif v != 0:
assert len(self.children[v]) >= 2
assert label(self.link[v]) == label(v)[1:]
if self.finished:
leaves = sorted(self.leaf[v] for v in order if self.leaf[v] >= 0)
assert leaves == list(range(len(t)))
test_ukkonen_algorithm.py 79 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ukkonen-algorithm/. The authors are listed in prov/YN86V8.jsonl.
import random
from ukkonen_algorithm import UkkonenTree
def naive_node_count(s: str) -> int:
t = list(s) + [None]
ext: dict[str, set] = {}
for i in range(len(s)):
for j in range(i + 1, len(s) + 1):
ext.setdefault(s[i:j], set()).add(t[j])
return len(s) + 2 + sum(1 for x in ext.values() if len(x) >= 2)
def build(s: str) -> UkkonenTree:
tr = UkkonenTree(s)
tr.finish()
return tr
def test_example() -> None:
tr = UkkonenTree()
for c in "abcabx":
tr.extend(c)
tr.check_invariant()
assert tr.contains("cab") and not tr.contains("bb")
tr.finish()
tr.check_invariant()
assert tr.suffix_array() == sorted(range(6), key=lambda i: "abcabx"[i:])
tr = build("banana")
tr.check_invariant()
assert tr.suffix_array() == [5, 3, 1, 0, 4, 2] and tr.node_count() == 11
def test_edges() -> None:
tr = build("")
tr.check_invariant()
assert tr.suffix_array() == [] and tr.node_count() == 2
for n in (1, 2, 7, 400):
tr = build("a" * n)
if n < 50:
tr.check_invariant()
assert tr.node_count() == 2 * n + 1
assert tr.suffix_array() == list(range(n - 1, -1, -1))
tr = build("ab" * 300)
assert tr.suffix_array() == sorted(range(600), key=lambda i: ("ab" * 300)[i:])
tr = build([3, 1, 4, 1, 5, 9, 2, 6])
tr.check_invariant()
assert tr.suffix_array() == [1, 3, 6, 0, 2, 4, 7, 5]
def test_online_against_naive() -> None:
rng = random.Random(1995)
for trial in range(600):
sigma = "abcd"[: rng.choice([1, 2, 4])]
n = rng.randint(0, 22)
s = "".join(rng.choice(sigma) for _ in range(n))
if trial % 30 == 0:
s = rng.choice(["a" * n, "ab" * (n // 2), ""])
tr = UkkonenTree()
for j, c in enumerate(s):
tr.extend(c)
if trial % 5 == 0:
tr.check_invariant()
pre = s[: j + 1]
for _ in range(4):
p = "".join(rng.choice(sigma + "e") for _ in range(rng.randint(1, 4)))
assert tr.contains(p) == (p in pre)
tr.finish()
tr.check_invariant()
assert tr.suffix_array() == sorted(range(len(s)), key=lambda i: s[i:])
assert tr.node_count() == naive_node_count(s)
for _ in range(6):
i = rng.randrange(len(s) + 1)
p = s[i:rng.randint(i, len(s))]
assert tr.contains(p)
What the next level needs
- the tests did not run in this build