Weiner's algorithm in Python
The first linear-time suffix tree construction, building the tree from right to left.
- File
weiner_algorithm.py, 176 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/weiner-algorithm/. The authors are listed in prov/7NCBQB.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 WeinerTree:
"""Weiner's suffix tree construction, inserting the suffixes of S$ from shortest to longest.
Symbols are any hashable values, found in dicts by hashing; order is used
only by suffix_array. Positions are 0-based. The unique sentinel END is
appended, so the result is the suffix tree of S$. Node v has the edge
label t[start[v]:end[v]] and string depth depth[v]. For inner nodes and
the root, ind[v] is the set of symbols x such that x + str(v) is a prefix
of an inserted suffix, and wlink[v][x] is the node of x + str(v) when that
string is a node (the Weiner links). O(n) time for a constant alphabet;
no recursion. trace records, for each suffix i, (i, case, depth of head).
"""
def __init__(self, text: Iterable[Hashable] = ()) -> None:
t: list[Any] = list(text)
t.append(END)
self.t = t
n = len(t) - 1
self.start, self.end, self.depth, self.parent, self.leaf = [0], [0], [0], [0], [-1]
self.children: list[dict[Any, int]] = [{}]
self.ind: list[set[Any]] = [set()]
self.wlink: list[dict[Any, int]] = [{}]
self.leaf_of = [0] * (n + 1)
self.trace: list[tuple[int, str, int]] = []
self.leaf_of[n] = self._new(n, n + 1, 0, n) # @line L1: the leaf of t_n = $
self.ind[0].add(END)
for i in range(n - 1, -1, -1): # @line L2
x = t[i] # @line L3
u = self.parent[self.leaf_of[i + 1]]
passed = []
while u != 0 and x not in self.ind[u]: # @line L4: up to the lowest v with x in ind[v]
passed.append(u)
u = self.parent[u]
found = x in self.ind[u]
for w in passed + [u]:
self.ind[w].add(x)
if not found: # @line L5: x is new, the head is empty
self.leaf_of[i] = self._new(i, n + 1, 0, i)
self.trace.append((i, "new symbol", 0))
continue
v = vp = u
while vp != 0 and x not in self.wlink[vp]: # @line L6: up to the lowest v' with a link for x
vp = self.parent[vp]
if x in self.wlink[vp]: # @line L7
vpp, dvp = self.wlink[vp][x], self.depth[vp]
else: # v' is a virtual node above the root, of depth -1, linked to the root
vpp, dvp = 0, -1
gap = self.depth[v] - dvp
if gap == 0: # @line L8: x + str(v) is already a node
h, case = vpp, "link"
else: # @line L9: go down gap symbols inside one edge and split it there
w = self.children[vpp][t[i + 1 + dvp]]
assert gap < self.end[w] - self.start[w]
h = self._split(w, gap)
self.wlink[v][x] = h
case = "split" if dvp >= 0 else "split, virtual node"
self.leaf_of[i] = self._new(i + self.depth[h], n + 1, h, i) # @line L10
self.trace.append((i, case, self.depth[h]))
# @line L11: the tree is complete
def _new(self, a: int, b: int, p: int, leaf: int) -> int:
self.start.append(a)
self.end.append(b)
self.depth.append(self.depth[p] + (b - a))
self.parent.append(p)
self.leaf.append(leaf)
self.children.append({})
self.ind.append(set())
self.wlink.append({})
self.children[p][self.t[a]] = len(self.start) - 1
return len(self.start) - 1
def _split(self, w: int, k: int) -> int:
"""Split the edge into w after k symbols; the new node copies the indicators of w."""
p, a = self.parent[w], self.start[w]
u = self._new(a, a + k, p, -1)
self.start[w] = a + k
self.parent[w] = u
self.children[u][self.t[a + k]] = w
self.ind[u] = set(self._indicators(w))
return u
def _indicators(self, w: int) -> set[Any]:
"""ind of an inner node, or, for the leaf of t_k, the symbol t[k - 1] if t_(k-1) is inserted."""
if self.leaf[w] < 0:
return self.ind[w]
k = self.leaf[w]
return {self.t[k - 1]} if k > 0 else set()
def contains(self, p: Iterable[Hashable]) -> bool:
"""Whether p is a substring of S."""
v, k, t = 0, 0, self.t
for c in p:
if c is END:
return False
if k == self.end[v] - self.start[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, by a depth-first walk in symbol order."""
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 of S$; ind and wlink of inner nodes match their definitions."""
t = self.t
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.end[w] > self.start[w]
assert self.parent[w] == v
assert self.depth[w] == self.depth[v] + self.end[w] - self.start[w]
stack.append(w)
assert len(order) == len(self.start)
def label(v: int) -> tuple[Any, ...]:
parts = []
while v != 0:
parts.append(t[self.start[v]:self.end[v]])
v = self.parent[v]
return tuple(x for part in reversed(parts) for x in part)
labels = {v: label(v) for v in order}
nodes = {lab: v for v, lab in labels.items()}
suffixes = [tuple(t[i:]) for i in range(len(t))]
for v in order:
lab = labels[v]
if self.leaf[v] >= 0:
assert not self.children[v] and list(lab) == t[self.leaf[v]:]
continue
if v != 0:
assert len(self.children[v]) >= 2
left = {s[0] for s in suffixes if len(s) > len(lab) and s[1:1 + len(lab)] == lab}
assert self.ind[v] == left
for x, w in self.wlink[v].items():
assert labels[w] == (x,) + lab
for x in left:
if (x,) + lab in nodes and self.leaf[nodes[(x,) + lab]] < 0:
assert self.wlink[v].get(x) == nodes[(x,) + lab]
assert sorted(self.leaf[v] for v in order if self.leaf[v] >= 0) == list(range(len(t)))
test_weiner_algorithm.py 77 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/weiner-algorithm/. The authors are listed in prov/7NCBQB.jsonl.
import random
from weiner_algorithm import END, WeinerTree
def naive_node_count(s: str) -> int:
"""Root, n + 1 leaves, and one inner node per right-branching substring of S$."""
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 test_example() -> None:
tr = WeinerTree("abaaa")
tr.check_invariant()
assert tr.node_count() == 9 and tr.suffix_array() == [4, 3, 2, 0, 1]
assert tr.trace == [
(4, "new symbol", 0), (3, "split, virtual node", 1), (2, "split", 2),
(1, "new symbol", 0), (0, "link", 1)]
assert tr.ind[0] == {END, "a", "b"}
tr = WeinerTree("banana")
tr.check_invariant()
assert tr.suffix_array() == [5, 3, 1, 0, 4, 2] and tr.node_count() == 11
assert tr.contains("ana") and not tr.contains("nab") and tr.contains("")
def test_edges() -> None:
tr = WeinerTree("")
tr.check_invariant()
assert tr.suffix_array() == [] and tr.node_count() == 2
for n in (1, 2, 7, 500):
tr = WeinerTree("a" * n)
if n < 30:
tr.check_invariant()
assert tr.node_count() == 2 * n + 1
assert tr.suffix_array() == list(range(n - 1, -1, -1))
s = "ab" * 300
tr = WeinerTree(s)
assert tr.suffix_array() == sorted(range(600), key=lambda i: s[i:])
tr = WeinerTree([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_against_naive() -> None:
rng = random.Random(1973)
for trial in range(700):
sigma = "abcd"[: rng.choice([1, 2, 4])]
n = rng.randint(0, 20)
s = "".join(rng.choice(sigma) for _ in range(n))
if trial % 25 == 0:
s = rng.choice(["a" * n, "ab" * (n // 2), ""])
tr = WeinerTree(s)
tr.check_invariant()
assert tr.suffix_array() == sorted(range(len(s)), key=lambda i: s[i:])
assert tr.node_count() == naive_node_count(s)
assert sum(len(x) for x in tr.wlink) <= tr.node_count()
for _ in range(8):
p = "".join(rng.choice(sigma + "e") for _ in range(rng.randint(0, 4)))
assert tr.contains(p) == (p in s)
def test_long_inputs() -> None:
"""Long random and periodic strings build without recursion and sort correctly."""
rng = random.Random(5)
for s in ["".join(rng.choice("acgt") for _ in range(4000)), "abc" * 1500, "a" * 3000 + "b"]:
tr = WeinerTree(s)
assert tr.node_count() <= 2 * len(s) + 1
sa = tr.suffix_array()
assert sorted(sa) == list(range(len(s)))
assert all(s[sa[r - 1]:] < s[sa[r]:] for r in range(1, 300))
What the next level needs
- the tests did not run in this build