回文树 (Palindrome Tree / PAM) 完全指南
回文树(又称回文自动机,PAM),是解决回文串问题的终极武器。
你可能会问:Manacher 已经能 O(N)O(N)O(N) 解决最长回文子串了,为什么还需要回文树?
答案很简单:Manacher 只能处理“最长”和“以某点为中心”的问题,但它无法处理“本质不同回文串”的统计和结构问题。
回文树能解决 Manacher 解决不了的难题:
- 字符串里有多少个 本质不同 的回文串?(Manacher 做不到)
- 统计每个回文串出现的 次数。
- 动态维护:支持在字符串末尾追加字符,动态更新回文串信息。(Manacher 是离线的)
一、核心结构:两棵树 + Fail 指针
回文树的结构非常独特,它由 两棵树 组成,并通过 Fail 指针 编织在一起。
1.1 两棵树(奇偶分家)
回文串分为奇数长度 (aba) 和偶数长度 (abba)。回文树用两个根节点来分别管理它们:
- 偶根 (Even Root, 0):代表长度为 0 的空回文串。它的子节点都是偶数长度的回文串。
- 奇根 (Odd Root, 1):代表长度为 -1 的虚回文串。它的子节点都是奇数长度的回文串。
边 (Edge):
节点 uuu 向 vvv 连一条字符 ccc 的边,表示:
回文串 uuu = ccc + 回文串 vvv + ccc
(即在 vvv 的两端各加一个字符 ccc)。
1.2 Fail 指针(失配指针)
对于节点 uuu(代表回文串 SSS),fail[u] 指向 SSS 的 最长真回文后缀。
- 这和 AC 自动机、KMP 的 Fail 指针一模一样。
- 作用:当我们试图在 SSS 两端加字符失败时,我们就跳到
fail[u]试试能不能加。
1.3 直观理解:奇偶切换的奥秘
为什么这套结构能自动处理奇偶长度?核心在于 “双层安全网” 机制:
- 第一层(偶根尝试):当你在偶数长度的回文串后匹配失败,最终会跳到 偶根 (0)。偶根长度为 0,它尝试匹配
S[i - 0 - 1]和S[i]。如果失败(s[0]是哨兵位),就会顺着fail[0] = 1跳到奇根。 - 第二层(奇根兜底):奇根 (1) 的长度是 -1。它尝试匹配
S[i - (-1) - 1]即S[i]和S[i]。由于自己必然等于自己,匹配 必定成功。 - 结果:这次匹配产生了一个长度为
-1 + 2 = 1的新节点,即 单字符回文串。
通过这种设计,Fail 指针不仅能在同类中降级,还能在必要时将状态从 “偶数世界” 拽回 “奇数世界”。
二、构建过程:动态生长
假设我们已经构建好了前 i−1i-1i−1 个字符的回文树,现在加入第 iii 个字符 XXX。
我们要找到以 XXX 结尾的 最长回文后缀。
算法流程:
- 找爸爸:从上一个字符的最长回文后缀节点
last开始,沿着 Fail 指针往上跳。 - 判断:假设跳到了节点 uuu(长度 lenlenlen),我们要检查
S[i - len - 1]是否等于S[i](即 XXX)。- 如果相等,说明 X+回文u+XX + \text{回文}u + XX+回文u+X 构成了新的回文串!
- 如果不相等,
u = fail[u],继续找更短的。
- 新建节点:
- 如果这个新回文串节点已经存在,直接走到它。
- 如果不存在,新建节点 newnewnew。
- 计算 Fail:
- newnewnew 的 Fail 指向谁?
- 继续从 uuu 的 fail 开始往上跳,重复步骤 1-2,找到第一个能匹配 XXX 的节点 vvv。
fail[new] = v的子节点(对应字符 XXX)。
三、它解决了什么?(经典应用)
3.1 本质不同回文串个数
- 结论:回文树上的节点个数(除去两个根)就是本质不同回文串的个数。
- 原因:每个节点代表一种独特的回文串。
3.2 统计每个回文串出现次数
- 构建时,每到一个节点,计数器 +1。
- 关键:构建完成后,要逆序遍历 Fail 树,将子节点的计数累加到父节点(因为
aba出现一次,意味着b也出现了一次)。
3.3 回文划分 (Palindrome Partitioning)
- 求把字符串切分成若干回文串的最小切割数。
- 利用 Fail 指针优化 DP,可以在 O(NlogN)O(N \log N)O(NlogN) 甚至 O(N)O(N)O(N) 解决。
四、代码模板 (Java)
public class PalindromeTree {
static final int MAXN = 100005;
static final int ALPHABET = 26;
// next[i][c]: 节点 i 代表的回文串,两端加上字符 c 构成的新回文串的节点编号
// 直观理解:给回文串 "穿衣服",如 "b" -> "aba"
int[][] next = new int[MAXN][ALPHABET];
int[] fail = new int[MAXN]; // Fail 指针
int[] len = new int[MAXN]; // 回文串长度
// cnt[i]: 节点 i 代表的回文串出现的次数
// 注意:构建过程中 cnt[i] 仅表示作为"最长后缀回文"出现的次数
// 完整次数需要在构建结束后通过 fail 树从下往上累加
int[] cnt = new int[MAXN];
int[] s = new int[MAXN]; // 原始字符串(数字版)
int last; // 上一个字符处理完后的节点
int n; // 当前字符串长度
int p; // 节点总数
/**
* 索引细节:PAM 通常采用 1 索引存储原始字符 s,
* s[0] 放置一个特殊哨兵字符,s[1...n] 存储实际内容。
*/
public PalindromeTree() {
newNode(0); // 偶根 (node 0)
newNode(-1); // 奇根 (node 1)
last = 0;
n = 0;
s[0] = -1; // 哨兵,防止越界
fail[0] = 1; // 偶根的 fail 指向奇根
}
private int newNode(int length) {
for (int i = 0; i < ALPHABET; i++) next[p][i] = 0;
cnt[p] = 0;
len[p] = length;
return p++;
}
/**
* 核心:寻找能扩展的后缀回文节点
* 直观理解:
* 我们想在当前字符 s[n] 结尾形成一个新的回文串 s[n]...s[n]。
* 这要求之前的回文串 X 满足:s[n] + X + s[n] 也是回文。
* 即检查 X 的前一个字符 s[n - len[X] - 1] 是否等于 s[n]。
*/
private int getFail(int x) {
// s[n]: 当前添加的字符
// s[n - len[x] - 1]: 回文串 x 前面的那个字符
// 如果不匹配,就沿着 fail 指针找更短的后缀回文,直到找到匹配或跳到奇根
while (s[n - len[x] - 1] != s[n]) {
x = fail[x];
}
return x;
}
public void add(char c) {
int charIndex = c - 'a';
s[++n] = charIndex;
// 1. 找父节点:寻找能通过两端加 c 扩展的最长后缀回文
// 比如 ... c [X] c ...,我们要找这个 X
int cur = getFail(last);
if (next[cur][charIndex] == 0) {
int now = newNode(len[cur] + 2);
// 2. 计算 Fail 指针(精髓):
// 目标:找到 now (即 cXc) 的最长真后缀回文。
// 逻辑推导:
// 1. 这个真后缀回文形式肯定是 cYc。
// 2. 去掉两端的 c,中间的 Y 必须是 X 的后缀回文。
// 3. X 的后缀回文有哪些? fail[cur], fail[fail[cur]]...
// 4. 我们要找最长的 Y,使得 s[n] + Y + s[n] 存在。
// 5. 所以从 fail[cur] 开始,利用 getFail 找到符合条件的 Y。
// 6. 找到 Y 后,next[Y][c] 就是我们要找的 cYc (即 fail[now])。
fail[now] = next[getFail(fail[cur])][charIndex];
next[cur][charIndex] = now;
}
last = next[cur][charIndex];
cnt[last]++;
}
// 统计本质不同回文串个数:return p - 2;
}
五、直观理解示例
假设我们构建字符串 aba 的回文树:
- 初始状态:0 号节点(偶根),1 号节点(奇根)。
- 加入 ‘a’:
getFail最终跳到奇根(因为 ‘a’ 自身是回文)。- 新建节点 “a” (len 1),挂在奇根下。
fail指向偶根。
- 加入 ‘b’:
getFail再次跳到奇根。- 新建节点 “b” (len 1),挂在奇根下。
- 加入 ‘a’:
getFail从 last=“b” 开始。检查s[3-len("b")-1]即s[1](‘a’) 与当前s[3](‘a’)。- 匹配! 发现 “aba” 可以由 “b” 扩展而来。
- 新建节点 “aba” (len 3),挂在 “b” 下。
- 计算 fail:从 “b” 的 fail (偶根) 开始找,找到 “a”。
- 所以 “aba” 的 fail 指向 “a”。
六、深度对比:PAM vs KMP vs AC 自动机
这三者是字符串算法中的"三剑客",它们的核心思想惊人地相似:利用 Fail 指针进行状态转移,避免无效扫描。但它们的"定义域"和"目标"不同。
| 特性 | KMP | AC 自动机 | 回文树 (PAM) |
|---|---|---|---|
| 处理对象 | 单模式串 | 多模式串 (字典) | 单字符串的所有回文子串 |
| 基础结构 | 线性数组 (Next) | Trie 树 | 两棵树 (奇/偶根) |
| Fail 指针含义 | 最长前缀也是后缀 | 最长后缀也是某模式串的前缀 | 最长后缀也是回文串 |
| 节点含义 | 前缀长度 | 某个模式串的前缀 | 本质不同的回文串 |
| 转移边 (Edge) | 字符追加 | 字符追加 | 前后同时追加字符 (回文特性) |
6.1 Fail 指针的"家族相似性"
-
KMP 的 Next[i]:
- 指向
S[0...i]的最长公共前后缀。 - 意图:匹配失败时,我不必从头开始,因为后缀我已经匹配过了,它正好也是前缀,所以我跳到前缀位置继续。
- 指向
-
AC 自动机的 Fail[u]:
- 指向节点
u代表字符串的最长后缀(且该后缀在 Trie 中存在)。 - 意图:当前分支走不通了(失配),看看我的后缀能不能匹配上其他模式串的前缀。
- 指向节点
-
PAM 的 Fail[u]:
- 指向节点
u代表回文串的最长真后缀回文。 - 意图:我想在
u两边加字符c构成cuc,如果失败了(说明u左边的字符不是c),那我就找u的最长后缀回文v,看看cvc能不能构成。
- 指向节点
6.2 为什么 PAM 需要两棵树?
KMP 和 AC 自动机都是处理"前缀"(从左往右),起点只有一个(空串)。
但回文是"中心对称"的,中心可能在字符上(奇数长),也可能在字符缝隙间(偶数长)。
为了统一处理这两种对称中心,PAM 必须有两个根:
- 偶根:处理如
abba,中心在缝隙。 - 奇根:处理如
aba,中心在b。
这是 PAM 与 KMP/AC 最本质的结构区别。
6.3 附录:KMP 极简实现
KMP 的核心是计算 next 数组。
索引细节:本实现采用 0 索引 约定。
pattern.charAt(0)是字符串首位,next[i]对应pattern[0...i]的最长公共前后缀长度。
// KMP Next数组计算 (Java) - 0 索引版
// next[i]: P[0...i] 的最长公共前后缀长度
public int[] getNext(String pattern) {
int[] next = new int[pattern.length()];
int j = 0; // j 代表"前缀的末尾位置" (同时也等于当前匹配长度)
// i 代表"后缀的末尾位置",从 1 开始
for (int i = 1; i < pattern.length(); i++) {
// 不匹配就回退,找更短的公共前后缀
while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
j = next[j - 1]; // 回退到上一个可能的位置
}
// 匹配成功,长度 +1
if (pattern.charAt(i) == pattern.charAt(j)) {
j++;
}
next[i] = j;
}
return next;
}
/**
* KMP 匹配过程演示
* 演示如何利用 next 数组进行高效跳转
*/
public void search(String text, String pattern) {
int[] next = getNext(pattern);
int j = 0;
for (int i = 0; i < text.length(); i++) {
while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
j = next[j - 1]; // 失配时,j 跳回到上一个匹配的前缀位置
}
if (text.charAt(i) == pattern.charAt(j)) {
j++;
}
if (j == pattern.length()) {
System.out.println("Found at index: " + (i - j + 1));
j = next[j - 1]; // 寻找下一个匹配
}
}
}
6.4 附录:AC 自动机极简实现
AC 自动机 = Trie 树 + Fail 指针 (BFS 构建)。这里展示了包含 路径压缩优化 (Trie 图) 的构建方式。
// AC 自动机核心构建 (Java)
class Node {
Node[] children = new Node[26];
Node fail; // 失配指针
boolean isEnd;
}
public void buildFail(Node root) {
Queue<Node> queue = new LinkedList<>();
// 1. 第一层节点的 fail 指向 root
for (int i = 0; i < 26; i++) {
if (root.children[i] != null) {
root.children[i].fail = root;
queue.add(root.children[i]);
} else {
root.children[i] = root; // 路径压缩优化:空子节点直接指向 root
}
}
// 2. BFS 构建剩余节点
while (!queue.isEmpty()) {
Node u = queue.poll();
for (int i = 0; i < 26; i++) {
if (u.children[i] != null) {
// 子节点的 fail = 父节点 fail 的对应子节点
u.children[i].fail = u.fail.children[i];
queue.add(u.children[i]);
} else {
// 路径压缩:没有路就直接连到 fail 的对应路去
// 这样匹配时不用 while 回跳,O(1) 转移
u.children[i] = u.fail.children[i];
}
}
}
}
/**
* AC 自动机查询演示
* 遍历文本,利用 Trie 和 Fail 指针统计模式串
*/
public void query(String text, Node root) {
Node u = root;
for (int i = 0; i < text.length(); i++) {
int index = text.charAt(i) - 'a';
u = u.children[index]; // 路径压缩后,直接跳转到有效状态
// 遍历 Fail 链收集匹配结果
Node temp = u;
while (temp != root) {
if (temp.isEnd) {
System.out.println("Pattern found ending at index " + i);
}
temp = temp.fail;
}
}
}
七、总结
| 算法 | 能力 | 复杂度 | 核心思想 |
|---|---|---|---|
| Manacher | 最长回文子串 | O(N)O(N)O(N) | 盒子理论,复用半径 |
| 回文树 (PAM) | 所有回文串结构 | O(N)O(N)O(N) | 两棵树 + Fail 指针 |
- 如果你只求一个最长的,用 Manacher。
- 如果你要统计数量、分析结构、或者动态处理,回文树是唯一选择。
- 如果你理解了 KMP/AC 的 Fail 指针,把定义换成"回文后缀",你就掌握了 PAM。


1万+

被折叠的 条评论
为什么被折叠?



