Palindromic tree (eertree) in Python
Online structure with one node per distinct palindromic substring, built in linear time.
- File
palindromic_tree.py, 115 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/palindromic-tree/. The authors are listed in prov/6DMXQ2.jsonl.
"""Palindromic tree (eertree), built online one symbol at a time.
Indices are 0-based. Node 0 is the imaginary root of length -1 and node 1 is
the empty palindrome of length 0; every other node is one distinct nonempty
palindrome of the text, odd or even. Symbols must be hashable and are only
tested for equality and used as dictionary keys.
"""
from collections.abc import Hashable, Iterable
IMAG = 0 # the root of length -1
EMPTY = 1 # the root of length 0
class PalindromicTree:
"""The eertree of the text read so far.
For a node v: length[v] is the length of its palindrome, link[v] is the
node of its longest proper palindromic suffix, edges[v][c] is the node of
c + palindrome(v) + c, end[v] is the position where it first ends,
depth[v] is the number of nonempty palindromic suffixes of palindrome(v),
and hits[v] counts the positions whose longest palindromic suffix is v.
"""
def __init__(self, text: Iterable[Hashable] = ()) -> None:
self.text: list[Hashable] = []
self.length: list[int] = [-1, 0]
self.link: list[int] = [IMAG, IMAG]
self.edges: list[dict[Hashable, int]] = [{}, {}]
self.end: list[int] = [-1, -1]
self.depth: list[int] = [0, 0]
self.hits: list[int] = [0, 0]
self.last = EMPTY
for c in text:
self.add(c)
def _fits(self, v: int, i: int, c: Hashable) -> bool:
"""True if c + palindrome(v) + c ends at position i of the text."""
j = i - self.length[v] - 1
return j >= 0 and self.text[j] == c
def add(self, c: Hashable) -> bool:
"""Append c to the text; return True if a new palindrome appeared."""
self.text.append(c) # @line L1
i = len(self.text) - 1
v = self.last
while not self._fits(v, i, c): # @line L2
v = self.link[v]
if c in self.edges[v]: # @line L3
self.last = self.edges[v][c]
self.hits[self.last] += 1
return False
w = len(self.length) # @line L4
self.length.append(self.length[v] + 2)
self.edges.append({})
self.end.append(i)
self.hits.append(1)
if self.length[w] == 1: # @line L5
self.link.append(EMPTY)
else:
u = self.link[v] # @line L6
while not self._fits(u, i, c): # @line L7
u = self.link[u]
self.link.append(self.edges[u][c]) # @line L8
self.depth.append(self.depth[self.link[w]] + 1)
self.edges[v][c] = w # @line L9
self.last = w
return True
def size(self) -> int:
"""Number of distinct nonempty palindromes in the text."""
return len(self.length) - 2
def palindrome(self, v: int) -> list[Hashable]:
"""The palindrome of node v, as a list of symbols."""
e = self.end[v]
return self.text[e - self.length[v] + 1 : e + 1] if v > EMPTY else []
def occurrences(self) -> list[int]:
"""occ[v] = number of occurrences of palindrome(v) in the text.
Each position counts for its longest palindromic suffix and, through
suffix links, for all shorter ones. Nodes are created after their
suffix-link targets, so one backward pass suffices.
"""
occ = list(self.hits)
for v in range(len(occ) - 1, EMPTY, -1):
occ[self.link[v]] += occ[v]
return occ
def check_invariant(self) -> None:
"""Check the structure against the definition, in polynomial time."""
t = self.text
n = len(t)
pals = {tuple(t[a:b]) for a in range(n) for b in range(a + 1, n + 1) if t[a:b] == t[a:b][::-1]}
nodes = [tuple(self.palindrome(v)) for v in range(2, len(self.length))]
assert len(set(nodes)) == len(nodes) and set(nodes) == pals
for v in range(2, len(self.length)):
p = self.palindrome(v)
assert len(p) == self.length[v] and p == p[::-1]
best = max(k for k in range(len(p)) if p[len(p) - k :] == p[len(p) - k :][::-1])
assert self.length[self.link[v]] == best
assert self.depth[v] == self.depth[self.link[v]] + 1
for c, w in self.edges[v].items():
assert self.palindrome(w) == [c, *p, c]
for c, w in self.edges[IMAG].items():
assert self.palindrome(w) == [c]
for c, w in self.edges[EMPTY].items():
assert self.palindrome(w) == [c, c]
if n:
longest = max(k for k in range(1, n + 1) if t[n - k :] == t[n - k :][::-1])
assert self.length[self.last] == longest
test_palindromic_tree.py 79 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/palindromic-tree/. The authors are listed in prov/6DMXQ2.jsonl.
import random
from collections import Counter
from palindromic_tree import EMPTY, IMAG, PalindromicTree
def brute_occurrences(s: str) -> Counter[str]:
"""Every palindromic occurrence s[a..b-1], counted by its string."""
return Counter(s[a:b] for a in range(len(s)) for b in range(a + 1, len(s) + 1) if s[a:b] == s[a:b][::-1])
def test_example() -> None:
t = PalindromicTree("eertree")
rows = [("".join(t.palindrome(v)), t.length[v], t.link[v], t.end[v]) for v in range(2, len(t.length))]
assert rows == [
("e", 1, EMPTY, 0),
("ee", 2, 2, 1),
("r", 1, EMPTY, 2),
("t", 1, EMPTY, 3),
("rtr", 3, 4, 4),
("ertre", 5, 2, 5),
("eertree", 7, 3, 6),
]
assert t.size() == 7
t.check_invariant()
def test_edges() -> None:
t = PalindromicTree("")
assert t.size() == 0 and t.last == EMPTY
t.check_invariant()
for n in range(1, 25):
t = PalindromicTree("a" * n)
assert t.size() == n
assert t.depth[t.last] == n
t.check_invariant()
t = PalindromicTree("abc" * 8)
assert t.size() == 3
t.check_invariant()
t = PalindromicTree([1, 2, 1, 1])
assert t.size() == 4
assert t.link[2] == EMPTY and t.link[IMAG] == IMAG
def test_random_against_brute_force() -> None:
rng = random.Random(6202)
for trial in range(900):
k = (1, 2, 3)[trial % 3]
n = rng.randrange(0, 16)
s = "".join(rng.choice("abc"[:k]) for _ in range(n))
t = PalindromicTree()
total = 0
for i, c in enumerate(s):
before = t.size()
new = t.add(c)
assert new == (t.size() == before + 1)
total += t.depth[t.last]
want_new = len(brute_occurrences(s[: i + 1])) - len(brute_occurrences(s[:i]))
assert int(new) == want_new
t.check_invariant()
want = brute_occurrences(s)
assert t.size() == len(want)
assert total == sum(want.values())
occ = t.occurrences()
for v in range(2, len(t.length)):
assert occ[v] == want["".join(t.palindrome(v))]
def test_long_input() -> None:
rng = random.Random(9)
s = "".join(rng.choice("ab") for _ in range(20000))
t = PalindromicTree(s)
assert t.size() <= len(s)
assert len(t.length) == t.size() + 2
t2 = PalindromicTree("a" * 20000)
assert t2.size() == 20000 and t2.depth[t2.last] == 20000
What the next level needs
- the tests did not run in this build