回文树 (Palindrome Tree / PAM)和AC自动机

回文树 (Palindrome Tree / PAM) 完全指南

回文树(又称回文自动机,PAM),是解决回文串问题的终极武器。

你可能会问:Manacher 已经能 O(N)O(N)O(N) 解决最长回文子串了,为什么还需要回文树?

答案很简单:Manacher 只能处理“最长”和“以某点为中心”的问题,但它无法处理“本质不同回文串”的统计和结构问题。

回文树能解决 Manacher 解决不了的难题:

  1. 字符串里有多少个 本质不同 的回文串?(Manacher 做不到)
  2. 统计每个回文串出现的 次数
  3. 动态维护:支持在字符串末尾追加字符,动态更新回文串信息。(Manacher 是离线的)

一、核心结构:两棵树 + Fail 指针

回文树的结构非常独特,它由 两棵树 组成,并通过 Fail 指针 编织在一起。

1.1 两棵树(奇偶分家)

回文串分为奇数长度 (aba) 和偶数长度 (abba)。回文树用两个根节点来分别管理它们:

  • 偶根 (Even Root, 0):代表长度为 0 的空回文串。它的子节点都是偶数长度的回文串。
  • 奇根 (Odd Root, 1):代表长度为 -1 的虚回文串。它的子节点都是奇数长度的回文串。

边 (Edge)
节点 uuuvvv 连一条字符 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-1i1 个字符的回文树,现在加入第 iii 个字符 XXX
我们要找到以 XXX 结尾的 最长回文后缀

算法流程

  1. 找爸爸:从上一个字符的最长回文后缀节点 last 开始,沿着 Fail 指针往上跳。
  2. 判断:假设跳到了节点 uuu(长度 lenlenlen),我们要检查 S[i - len - 1] 是否等于 S[i](即 XXX)。
    • 如果相等,说明 X+回文u+XX + \text{回文}u + XX+回文u+X 构成了新的回文串!
    • 如果不相等,u = fail[u],继续找更短的。
  3. 新建节点
    • 如果这个新回文串节点已经存在,直接走到它。
    • 如果不存在,新建节点 newnewnew
  4. 计算 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(Nlog⁡N)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 的回文树:

  1. 初始状态:0 号节点(偶根),1 号节点(奇根)。
  2. 加入 ‘a’
    • getFail 最终跳到奇根(因为 ‘a’ 自身是回文)。
    • 新建节点 “a” (len 1),挂在奇根下。
    • fail 指向偶根。
  3. 加入 ‘b’
    • getFail 再次跳到奇根。
    • 新建节点 “b” (len 1),挂在奇根下。
  4. 加入 ‘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 指针进行状态转移,避免无效扫描。但它们的"定义域"和"目标"不同。

特性KMPAC 自动机回文树 (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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值