Skip to content

模型Model

回文树与回文后缀链接

Palindromic tree · Eertree · 回文自动机

用至多 n 个普通节点表示文本中的不同非空回文,在线维护最长回文后缀,并沿后缀链接汇总每种回文的出现次数。

aaa 的六次回文出现中,三个 a 应归到同一内容下,两个 aa 也应归到同一内容下。半径数组按位置组织,不能直接读出这种去重结果。回文树改为“一种回文内容,一个节点”,文本右边每加一个字符,最多新建一个节点。

形式陈述 ​

两种边和两个特殊根 ​

回文树为文本的每种不同非空回文保存一个普通节点 v。节点记录长度 len[v]、最长真回文后缀节点 link[v],以及按字符索引的扩展边 next[v]。若 P 和 cPc 都出现,扩展边 P→ccPc 把它们相连;它一次向两端各添一个字符。

另外保留两个特殊节点。本实现编号零的虚根长度为 −1,编号一的空根长度为零。虚根经字符 c 的边通向单字符 c,空根经 c 的边通向 cc。长度负一只是把单字符的边界情况纳入统一代码的哨兵,不存在长度负一的真实词。

普通节点的后缀链接指向最长的真回文后缀;单字符链接到空根。空根链接到虚根,虚根链接自身。扩展边长度增加二,后缀链接长度严格减少,虚根自环除外。把两种边混成一种树边,会既算错导航方向,也误解“回文树”这个名称;整个结构是叠加了后缀链接的图。

操作与输出接口 ​

初始化只有两个根,last 指向空根。add(c) 把一个字符追加到文本,维护整个结构和新前缀的最长回文后缀 last,并返回是否新建了普通节点。节点数减二就是不同非空回文数。

节点不保存整段回文副本,只保存首次出现的末位置 first_end[v]。长度与一个末位置足以定位代表区间;真的输出文本时再切片。对 an,不同内容虽然只有 n 个,但复制 a,aa,…,a^n 共需 Θ(n2) 个字符,不能把这种输出也算作线性存储。

直觉

一次追加为什么最多只多一种回文 ​

设旧文本为 T,新文本为 Tc。任何新内容都必须有一次出现碰到新加的末字符,否则它早已在 T 中。因此新内容只能是新前缀的回文后缀。

令 P 为最长回文后缀,Q 是一个更短的回文后缀。由于 P 回文且 Q 本身回文,Q 同时是 P 的前缀。这个前缀出现严格早于 P 的末端,已经完全位于旧文本 T。所以 Q 不是新内容,唯一可能新建的是 P。[1]

从空文本连续追加 n 次,普通节点至多 n 个。每个非单字符回文去掉首尾后,内部回文与外字符唯一确定它的入扩展边;单字符则唯一来自虚根。因此扩展边也至多 n 条,而不是“节点线性,所以边当然线性”。

沿后缀链接寻找能被新字符包住的内层 ​

新最长回文若不止一个字符,必形如 cQc,其中 Q 是旧前缀的回文后缀。由长到短沿旧 last 的后缀链接检查:在 Q 前面紧邻的字符是否也是 c?第一个能通过者形成最长答案。若一直失败,虚根的长度 −1 让比较位置恰好落在刚加入的字符本身,必然通过,输出单字符。

如果扩展边 next[Q][c] 已存在,直接复用那个节点。否则创建 P=cQc。为了确定新节点的后缀链接,从 link[Q] 继续同样的查找;找到次长的可扩张内层,再沿其 c 边,就得到 P 的最长真回文后缀。该目标早已存在,因为上一段证明它在旧文本中已经出现。

扩展边按两端加字符,后缀链接按最长真回文后缀回退

完整的追加实现 ​

python
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 的通常期望查找成本为常数,不能据此声称任意哈希碰撞输入下也有最坏常数界。若换成有序平衡树字典,则字符查询带 O(log⁡(σ+1)) 因子,σ 为已用字符数。

例子与边界

从最长后缀恢复一个可检查的节点表 ​

依次读入 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,仍只有三种不同回文。空串则没有普通节点,出现次数列表虽保留根槽,但两个根槽都不属于非空回文统计结果。

在线总成本不是每次追加的最坏成本 ​

先读入 am,再追加 b,就会从长度 m 逐一退到虚根,单次追加可花 Θ(m) 时间。基本版本只保证整段只追加过程的摊还界。若反复“加 b、删除 b”,同样的长链可能被重新走许多遍;本页没有提供删除操作,不能把只追加的证明直接搬到支持回滚的接口。[1, §3.2]

推论与应用

把两次查找都纳入摊还证明 ​

第一轮查找可用摊还分析直接记账。令 ℓt 为长 t 前缀的最长回文后缀长度,ℓ0=0。每沿一次后缀链接,候选长度至少减少一;最后一次扩展增加二。因此第 t 次追加的失败回退数不超过 ℓt−1+2−ℓt,全程相加至多 2n。

第二轮查找不能在证明中漏掉。令 ht 为第二长的回文后缀长度,允许空回文;没有非空真回文后缀时为零。若第一轮选中的内层是 Q,第二轮从 link[Q] 开始,其长度至多 ht−1。查找成功后扩展二,得到长度 ht,所以这轮失败回退数至多 ht−1+2−ht。

未新建节点时不执行第二轮,但仍有 ht≤ht−1+2:删去新第二长后缀的首尾,所得内层是旧前缀的真回文后缀;若它反而是旧最长后缀,那么扩展出的就会是新最长后缀,与“第二长”矛盾。长度零或一的情况直接满足不等式。因此可以对所有追加相加,把第二轮总回退也界为 2n,而不是只在创建节点的零散时刻错误地望远镜求和。

每次追加只做常数次字典操作,故平衡树版本总时间 O(nlog⁡(σ+1))、空间 O(n);常数字母表或合适哈希假设下总时间为 O(n)。若每个节点都开 σ 个稠密槽,空间应记为 O(nσ),只有 σ 是固定常数时才简写成线性。

出现次数沿链接汇总 ​

追加后只给当前 last 的 as_longest 加一,记录“多少个前缀以这个节点作为最长回文后缀”。这还不是所有出现次数。对每个前缀,其全部较短回文后缀恰在 last 的链接链上,所以把最长后缀的贡献向链接父亲传播即可。

创建节点时,后缀链接的目标必已存在,目标编号小于新节点。于是按创建编号倒序,令 counts[link[v]]+=counts[v],就保证每份子贡献在继续上传前已经汇总完成。代码先复制原始计数,使重复调用 occurrences 不会重复累加;构建仍可继续,下一次查询重新从 as_longest 开始汇总。返回列表中的根槽仅为中间累计,不作为空回文出现数解释。

对主例,a,b,c,d 的计数依次为 4,3,2,1,其余六种回文各出现一次,总和十六,与半径数组独立得到的结果一致。a^4 则应报告 a:4,aa:3,aaa:2,aaaa:1。能够从节点创建表、链接关系、原始计数独立复算这个字典,比只报告“有四个节点”多完成了真正的索引接口。

最少回文分解可以在每次追加后沿 last 的链接枚举所有合法末块。树的构建成本虽然线性,这个额外枚举仍可能访问二次方个回文出现;数据结构小,不意味着所有基于它的算法都会自动变快。

参考资料
关系图谱7 个相邻概念 · 2 类关系

拖动节点调整位置。

显示关系

显示:依赖

  1. 前置三跳
  2. 前置二跳
  3. 前置一跳
  4. 当前条目
  5. 后续一跳
  6. 后续二跳
  7. 后续三跳
文字版关系按与当前条目的最短距离分组
类型化关系