“回文树给另一种执行方法:追加到位置 i 后,从 last 沿回文后缀链接走,对每个节点 v 检查 dp[i−len[v]]+1。这样只枚举真的回文末块,不再逐个拒绝非回文区间;主例共检查十六…”
aaa 的六次回文出现中,三个 a 应归到同一内容下,两个 aa 也应归到同一内容下。半径数组按位置组织,不能直接读出这种去重结果。回文树改为“一种回文内容,一个节点”,文本右边每加一个字符,最多新建一个节点。
形式陈述
两种边和两个特殊根
回文树为文本的每种不同非空回文保存一个普通节点
另外保留两个特殊节点。本实现编号零的虚根长度为 cc。长度负一只是把单字符的边界情况纳入统一代码的哨兵,不存在长度负一的真实词。
普通节点的后缀链接指向最长的真回文后缀;单字符链接到空根。空根链接到虚根,虚根链接自身。扩展边长度增加二,后缀链接长度严格减少,虚根自环除外。把两种边混成一种树边,会既算错导航方向,也误解“回文树”这个名称;整个结构是叠加了后缀链接的图。
操作与输出接口
初始化只有两个根,last 指向空根。add(c) 把一个字符追加到文本,维护整个结构和新前缀的最长回文后缀 last,并返回是否新建了普通节点。节点数减二就是不同非空回文数。
节点不保存整段回文副本,只保存首次出现的末位置 first_end[v]。长度与一个末位置足以定位代表区间;真的输出文本时再切片。对 a,aa,…,a^n 共需
直觉
一次追加为什么最多只多一种回文
设旧文本为
令
从空文本连续追加
沿后缀链接寻找能被新字符包住的内层
新最长回文若不止一个字符,必形如
如果扩展边 next[Q][c] 已存在,直接复用那个节点。否则创建
完整的追加实现
class Eertree:
def __init__(self):
self.text = []
self.length = [-1, 0]
self.link = [0, 0]
self.next = [{}, {}]
self.first_end = [-1, -1]
self.as_longest = [0, 0]
self.last = 1
def _extendable(self, v, i):
while i - self.length[v] - 1 < 0 or self.text[i - self.length[v] - 1] != self.text[i]:
v = self.link[v]
return v
def add(self, c):
if not isinstance(c, str) or len(c) != 1:
raise ValueError('add expects one Unicode code point')
self.text.append(c)
i = len(self.text) - 1
q = self._extendable(self.last, i)
created = c not in self.next[q]
if created:
v = len(self.length)
self.length.append(self.length[q] + 2)
self.link.append(1)
self.next.append({})
self.first_end.append(i)
self.as_longest.append(0)
self.next[q][c] = v
if self.length[v] > 1:
p = self._extendable(self.link[q], i)
self.link[v] = self.next[p][c]
self.last = self.next[q][c]
self.as_longest[self.last] += 1
return created
def occurrences(self):
counts = self.as_longest.copy()
for v in range(len(counts) - 1, 1, -1):
counts[self.link[v]] += counts[v]
return counts
这里用字典映射保存稀疏扩展边。Python dict 的通常期望查找成本为常数,不能据此声称任意哈希碰撞输入下也有最坏常数界。若换成有序平衡树字典,则字符查询带
例子与边界
从最长后缀恢复一个可检查的节点表
依次读入 abacdcabba,每步的最长回文后缀为:
| 前缀长度 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| last 内容 | a | b | aba | c | d | cdc | acdca | bacdcab | bb | abba |
这一实例每次恰好新建一个普通节点,最终十个节点。长度八时 last 是 bacdcab,追加第二个 b 时,长七后缀前的字符 a 不能与新 b 配对;回退到 b,它前面的字符仍是 a,再次失败;回退到空根时,前一字符正是 b,于是得到新回文 bb。
下一次追加 a,旧后缀 bb 前面的 a 通过,形成 abba。新节点的后缀链接不是 bb:bb 并非 abba 的后缀。真正最长真回文后缀是 a。图中特别分开扩展边 bb→abba 与后缀链接 abba→a,可用这个例子检查实现是否混淆两种父关系。
并非每次追加都会创建节点。对 abca,最后一个 a 的最长回文后缀就是已存在的单字符 a,返回 False,仍只有三种不同回文。空串则没有普通节点,出现次数列表虽保留根槽,但两个根槽都不属于非空回文统计结果。
在线总成本不是每次追加的最坏成本
先读入 b,就会从长度
推论与应用
把两次查找都纳入摊还证明
第一轮查找可用摊还分析直接记账。令
第二轮查找不能在证明中漏掉。令
未新建节点时不执行第二轮,但仍有
每次追加只做常数次字典操作,故平衡树版本总时间
出现次数沿链接汇总
追加后只给当前 last 的 as_longest 加一,记录“多少个前缀以这个节点作为最长回文后缀”。这还不是所有出现次数。对每个前缀,其全部较短回文后缀恰在 last 的链接链上,所以把最长后缀的贡献向链接父亲传播即可。
创建节点时,后缀链接的目标必已存在,目标编号小于新节点。于是按创建编号倒序,令 counts[link[v]]+=counts[v],就保证每份子贡献在继续上传前已经汇总完成。代码先复制原始计数,使重复调用 occurrences 不会重复累加;构建仍可继续,下一次查询重新从 as_longest 开始汇总。返回列表中的根槽仅为中间累计,不作为空回文出现数解释。
对主例,a,b,c,d 的计数依次为 a^4 则应报告 a:4,aa:3,aaa:2,aaaa:1。能够从节点创建表、链接关系、原始计数独立复算这个字典,比只报告“有四个节点”多完成了真正的索引接口。
最少回文分解可以在每次追加后沿 last 的链接枚举所有合法末块。树的构建成本虽然线性,这个额外枚举仍可能访问二次方个回文出现;数据结构小,不意味着所有基于它的算法都会自动变快。
参考资料
- [1] Mikhail Rubinchik and Arseny M. Shur, EERTREE: An Efficient Data Structure for Processing Palindromes in Strings, arXiv:1506.04862v2, 2015,§2.2 Lemmas 1–2、Propositions 1–2:节点、扩展、线性规模与构造;§2.4 Proposition 3:出现次数;§3.2:基本版本单步最坏界与删除边界。正式发表版本见 European Journal of Combinatorics 68, 2018, pp. 249–265,DOI。
- Gabriele Fici, Travis Gagie, Juha Kärkkäinen and Dominik Kempa, A Subquadratic Algorithm for Minimum Palindromic Factorization, 2014,§3 Lemma 1:回文的回文后缀也是 border;该结构支持上述“一次至多一个新节点”的证明。