CC++ & Algorithm

Manacher回文算法 —— 镜子里的对称世界

较难4
语言版本:C++
概述:利用回文的对称性,像照镜子一样快速找到所有回文子串。

Manacher算法:用镜像魔法快速找到所有回文

小朋友,你有没有发现有些词语正着读和反着读一模一样?比如 “上海自来水来自海上”“abba” ,这种字符串就叫 回文(palindrome)。在编程中,我们经常需要从一个长字符串里找出 最长的回文子串(比如“abacaba”本身就是回文,长度7)。

最简单的办法是:枚举每个位置作为中心,向两边扩展,检查左右字符是否相等。但这种方法很慢——假如字符串长度是 n,最坏情况要检查差不多 次,比如 "aaaa...a" 这种全是相同字符的串。

Manacher 算法就像一位聪明的魔法师,它利用回文的 对称性,把时间复杂度降到了 O(n)。它的核心思想是:当你已经知道一个回文串时,它左右两边的字符是镜像对称的。那么右边的字符就可以用左边对称位置的信息直接“抄作业”,省去很多次比较。


1. 什么是回文的对称性?—— 照镜子的感觉

想象你站在一面镜子前,你举起左手,镜子里的你举起右手。回文也是这样的:以中心为镜面,左边和右边一模一样。

比如字符串 "abacaba"

  • 中心字符是 'a'(索引3)。
  • 左半边是 "aba",右半边是 "aba",完全对称。
  • 如果我们知道中心左边的某个位置(比如索引2的 'a')的回文半径(即以它为中心能扩展多少),那么它对称到右边(索引4的 'a')的回文半径 至少 和左边一样大,因为整个大回文保证了镜像对称。

生活中的例子:你在操场上排队做操,老师喊:“以中间同学为轴,左右对称!”那么你只要知道左边同学的动作,右边同学就能立刻做出一样的动作,不用老师再教一遍。


2. 如何统一处理奇数和偶数长度的回文?—— 插入隔板

回文有两种:

  • 奇数长度:中心是一个字符,比如 "aba"(中心是 'b')。
  • 偶数长度:中心在两个字符之间,比如 "abba"(中心在 'b' 和 'b' 中间)。

为了用同一套方法处理,Manacher 算法在所有字符之间(包括开头和结尾)插入一个 特殊字符(比如 #)。这样原来的奇数长度和偶数长度都会变成奇数长度。

例如:

  • 原串 "aba""#a#b#a#",每个字符都被 # 隔开,中心是 'b'(原来也是奇数)。
  • 原串 "abba""#a#b#b#a#",中心是中间的 #(原来偶数变成以 # 为中心的奇数)。

插入后,新字符串的每个位置都可以作为中心,回文半径 radius[i] 表示以 i 为中心能向两边扩展多少个字符(包括中心的 # 也算在内)。回文半径减1(或者直接 radius[i])就对应原串中的回文长度。


3. 算法核心:维护最右边界和中心

我们用一个变量 right 表示 当前所有已发现的回文串中,最靠右的右边界(即 center + radius[center]),对应的中心记为 center

当我们从左到右扫描每个位置 i 时:

  1. 如果 i 在右边界的左边i < right),那么可以利用对称性:i 关于 center 的对称位置是 mirror = 2 * center - i。因为 mirror 的回文半径 radius[mirror] 我们已经算过了,而整个大回文(以 center 为中心)保证了 mirrori 的周围字符是对称的,所以 radius[i] 至少等于 min(radius[mirror], right - i)

    • 为什么取最小值?因为如果 mirror 的回文半径超过了 right - i,说明 mirror 的部分超出了大回文的左边界,那么 i 的右边部分就会超出大回文的右边界,我们无法确认超出部分是否对称,只能保证在 right - i 范围内是安全的。
  2. 然后继续向两边扩展:即使利用了对称性,我们还要检查 i - radius[i] - 1i + radius[i] + 1 是否相等,如果相等就增加半径。这一步保证了不会漏掉大回文之外可能存在的更长回文。

  3. 更新 centerright:如果 i + radius[i] > right,说明我们找到了一个更靠右的右边界,就把 center 更新为 iright 更新为 i + radius[i]

  4. 记录最大回文半径radius[i] 对应的原串回文长度是 radius[i](因为插入 # 后,半径减1就是原串长度,但为了方便我们直接记录半径,最后返回最大值即可)。

举个例子:字符串 "babab",插入 # 后变成 "#b#a#b#a#b#"

  • 扫描到中心 i = 3(对应原串的 'a'):此时 center 可能是之前的某个位置,right 覆盖到了哪里?
    我们可以一步一步手算,但核心是:当计算 i = 5(第二个 'b')时,它的镜像位置是 i = 1,而 radius[1] 已知,就能快速得到 radius[5] 的初始值。

4. 完整可运行的代码示例(带详细注释)

下面的 C++ 代码实现了 Manacher 算法,可以返回最长回文子串的长度。

#include <iostream>
#include <string>
#include <vector>
using namespace std;

int manacher(const string& s) {
    // 1. 插入特殊字符 '#',统一奇偶长度
    string t = "#";               // 开头也加 #
    for (char c : s) {
        t += c;
        t += '#';                 // 每个字符后跟一个 #
    }
    int n = t.size();             // 新字符串长度
    vector<int> radius(n, 0);     // radius[i]: 以 i 为中心的回文半径(包括自己)
    int center = 0, right = 0;    // 当前最右回文的中心和右边界(右边界是开区间,即 i+radius[i])
    int maxLen = 0;               // 记录最大回文半径

    for (int i = 0; i < n; i++) {
        // 2. 利用对称性初始化半径
        if (i < right) {
            int mirror = 2 * center - i;                 // i 关于 center 的对称位置
            radius[i] = min(radius[mirror], right - i); // 取最小值保证不越界
        }
        // 3. 向两边扩展,检查是否还能增加半径
        while (i - radius[i] - 1 >= 0 && i + radius[i] + 1 < n &&
               t[i - radius[i] - 1] == t[i + radius[i] + 1]) {
            radius[i]++;
        }
        // 4. 更新最右边界和中心
        if (i + radius[i] > right) {
            center = i;
            right = i + radius[i];
        }
        // 5. 记录最大半径(原串中回文长度 = radius[i])
        if (radius[i] > maxLen) {
            maxLen = radius[i];
        }
    }
    return maxLen; // 最大回文子串的长度
}

int main() {
    string s = "abacaba";
    cout << "最长回文子串长度: " << manacher(s) << endl; // 输出 7

    string s2 = "babad";
    cout << "最长回文子串长度: " << manacher(s2) << endl; // 输出 3 (如 "bab" 或 "aba")

    string s3 = "a";
    cout << "最长回文子串长度: " << manacher(s3) << endl; // 输出 1

    string s4 = "cbbd";
    cout << "最长回文子串长度: " << manacher(s4) << endl; // 输出 2 ("bb")

    return 0;
}

运行结果

最长回文子串长度: 7
最长回文子串长度: 3
最长回文子串长度: 1
最长回文子串长度: 2

5. 新手容易犯的常见错误

  1. 忘记插入特殊字符:直接对原串使用中心扩展,会导致偶数长度回文很难处理,代码复杂化。
  2. 对称位置计算错误mirror = 2 * center - i 是公式,有些同学会写成 center - (i - center),容易算错。
  3. 初始化半径时没有取最小值:如果直接写成 radius[i] = radius[mirror],可能让半径超出右边界,导致后续扩展时数组越界或得到错误结果。
  4. 更新 right 时用 i + radius[i] 还是 i + radius[i] - 1:我们维护的 right开区间,即 center + radius[center] 是不包含的右边第一个位置,这样写 if (i < right) 比较方便。如果不注意,更新时可能多1或少1。
  5. 返回结果时混淆radius[i] 是插入 # 后的回文半径,原串回文长度就是 radius[i](因为中心可能是 # 也可能是字符,但长度计算一致)。例如 "a" 插入后变成 "#a#",以 'a' 为中心半径为 1,返回 1 正确。

6. 相关知识点指引

  • KMP 算法:也是利用“已匹配信息”避免重复比较,但用于字符串匹配。
  • 字符串哈希:可以用预处理哈希值快速判断回文,但时间复杂度 O(n log n)O(n)(配合二分),Manacher 更纯粹。
  • 动态规划:可以用区间 DP 判断回文,但时间复杂度 O(n^2),空间 O(n^2),不如 Manacher。
  • 最长回文子串的变体:比如找出所有回文子串、计算回文子串个数等,Manacher 稍加改造也能完成。

Manacher 算法虽然看起来很巧妙,但只要我们理解了“镜像对称”和“最右边界”的概念,就像学会了用镜子复制信息,再长的字符串也能快速找到所有回文!尝试在自己的代码中用一用吧,下次遇到回文问题,你就是那个聪明的魔法师。

例题精讲

1单选题

在Manacher算法中,向原字符串插入特殊字符(如'#)的主要目的是什么?

A增加字符串长度,使算法更稳定
B将偶回文转换为奇回文,统一处理
C防止字符重复,避免边界冲突
D降低时间复杂度,从O(n^2)降到O(n)
2判断题

Manacher算法中,变量c表示当前已找到的回文子串中,最右边界的中心位置,变量r表示该回文子串的右边界。

3填空题
下面是Manacher算法中计算回文半径数组d的核心代码片段,其中s是已插入特殊字符的新字符串,n是s的长度。请填写空白处的代码,使得当i当前位置没有被当前最右回文覆盖时,进行朴素的中心扩展。

int c = 0, r = 0;
vector<int> d(n);
for (int i = 0; i < n; i++) {
    if (i < r) {
        int j = 2 * c - i;
        d[i] = min(d[j], ___);
    } else {
        d[i] = 1;
    }
    // 尝试扩展
    while (i - d[i] >= 0 && i + d[i] < n && s[i - d[i]] == s[i + d[i]]) {
        d[i]++;
    }
    // 更新c和r
    if (i + d[i] > r) {
        c = i;
        r = i + d[i];
    }
}
4单选题

对于一个长度为n的原始字符串,经过Manacher算法预处理(插入n+1个特殊字符)后,新字符串的长度为2n+1。以下关于Manacher算法时间复杂度的描述,正确的是哪一项?

A最坏情况O(n^2),最好情况O(n)
B严格O(n),因为每个字符最多被访问两次
CO(n log n),因为涉及边界二分
DO(n)只在随机数据下成立,最坏仍是O(n^2)
5填空题
下列代码实现Manacher算法求原始字符串s中的所有回文子串个数(包括长度为1的)。预处理后调用countPalindromes函数返回个数。请补全空缺处的代码,使得函数正确返回奇回文和偶回文的总数。

int countPalindromes(string s) {
    string t = "#";
    for (char ch : s) {
        t += ch;
        t += '#';
    }
    int n = t.size();
    vector<int> d(n);
    int c = 0, r = 0;
    long long ans = 0;
    for (int i = 0; i < n; i++) {
        if (i < r) {
            d[i] = min(d[2*c - i], r - i);
        } else {
            d[i] = 1;
        }
        while (i - d[i] >= 0 && i + d[i] < n && t[i - d[i]] == t[i + d[i]]) {
            d[i]++;
        }
        if (i + d[i] > r) {
            c = i;
            r = i + d[i];
        }
        // 累计回文子串数量:每个中心i贡献d[i]个回文(半径为1到d[i]-1对应原始回文)
        ans += ___;
    }
    return (int)ans;
}