Manacher 算法:在线性时间内寻找最长回文子串

回文串正着读和反着读完全相同,例如 abaabba。寻找一个字符串中的最长回文子串,最直观的做法是枚举中心并向两侧扩展,但像 aaaaaa 这样的输入会反复比较同一批字符,最坏需要 O(n²) 时间。

Manacher(通常读作“马拉车”)算法的关键不是换一种扩展方式,而是保存已经得到的回文信息,用对称位置的答案为新中心提供起点。它把重复比较压缩掉,使最长回文子串可以在线性时间内求出。

核心结论:维护当前到达最右位置的回文区间。新中心落在区间内部时,先把它关于旧中心的镜像半径复制过来;只有可能越过最右边界的部分才真正比较。

问题与三种常见思路

给定长度为 n 的字符串 s,目标是返回其中最长的连续回文子串。注意“子串”必须连续,“子序列”则可以跳过字符,两者是不同问题。

方法时间空间主要思路
枚举所有子串O(n³)O(1)枚举左右端点,再逐字符判断回文
中心扩展O(n²)O(1)枚举中心,向两边扩展
动态规划O(n²)O(n²)由短回文区间推出长回文区间
ManacherO(n)O(n)中心扩展 + 镜像半径复用

第一步:统一奇数和偶数长度

奇数长度回文的中心是字符,例如 aba 的中心是 b;偶数长度回文的中心在两个字符之间,例如 abba 的中心在两个 b 之间。若直接在原串上处理,每个中心都要分两种情况。

在每两个字符之间以及首尾插入分隔符,就能把所有回文统一成奇数长度:

原串 abba 的变换
原字符串abba偶数回文的中心位于两个 b 之间
加入分隔#a#b#b#a#现在中心就是一个真实位置
回文半径#a#b#b#a#从中心向一侧可跨 4 格,原串回文长度也是 4

实际代码还会在两端放入互不相同的哨兵,使扩展时不必反复判断数组是否越界。为了让输入中即使包含 #^$ 也不会发生冲突,后面的实现不直接拼接字符,而是建立 vector<int>

  • -2:左哨兵;
  • -1:字符间的分隔符;
  • 0...255:原字符串中的字节;
  • -3:右哨兵。

负数标记永远不会和 unsigned char 的值相同,因此扩展一定会在哨兵处停止。

第二步:定义回文半径

p[i] 表示变换串中以 i 为中心、向单侧能够扩展的最大格数。也就是说,区间 [i - p[i], i + p[i]] 是一个回文。

有两个很方便的映射公式:

  • 最长回文在原字符串中的长度就是 bestLen,不需要再除以 2;
  • 最长回文的原串起点是 (bestCenter - bestLen) / 2

abba 为例,变换串中心下标为 5,半径为 4,所以原串起点是 (5 - 4) / 2 = 0,长度为 4

第三步:维护最右回文区间

算法从左到右扫描中心,同时维护两个量:

  • center:目前向右延伸最远的回文中心;
  • right:该回文区间的右端点,即 center + p[center]

这里的 right 是已经包含在回文中的位置,不是半开区间的“下一位”。只要全文保持同一约定即可。

镜像位置从哪里来

若新中心 i 位于 right 左侧,它关于 center 的镜像位置是:

mirror = 2 * center - i;

大回文关于 center 左右对称,所以 i 附近已经处于大回文内部的字符,与 mirror 附近完全对应。于是可以先令:

p[i] = std::min(right - i, p[mirror]);

为什么一定要取最小值

p[mirror] 描述镜像中心的完整回文,但镜像回文有可能越过当前大回文的左边界。越界后的字符不再受大回文对称性保证,因此能安全复制的半径最多只有 right - i

镜像回文与边界的关系可以直接确定的半径之后怎么做
p[mirror] < right - ip[mirror]两侧下一字符已知不同,不会继续扩大
p[mirror] > right - iright - i边界之外未知,从边界处继续比较
p[mirror] == right - iright - i恰好贴边,仍需尝试越过边界
镜像复用的直观含义
已知区间L·mirror·center·i·right整个 [L, right] 关于 center 对称
先复制p[mirror]p[i]只复制仍位于已知边界内的部分
再扩展right??真正比较只发生在可能创造新最右边界的位置

完整 C++17 实现

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

std::string longestPalindrome(const std::string& s) {
    // 变换为:左哨兵, 分隔符, 字符, 分隔符, ..., 右哨兵
    std::vector<int> transformed;
    transformed.reserve(s.size() * 2 + 3);
    transformed.push_back(-2);
    transformed.push_back(-1);
    for (unsigned char ch : s) {
        transformed.push_back(static_cast<int>(ch));
        transformed.push_back(-1);
    }
    transformed.push_back(-3);

    std::vector<int> radius(transformed.size(), 0);
    int center = 0;
    int right = 0;
    int bestCenter = 0;
    int bestLen = 0;

    for (int i = 1; i + 1 < static_cast<int>(transformed.size()); ++i) {
        if (i < right) {
            const int mirror = 2 * center - i;
            radius[i] = std::min(right - i, radius[mirror]);
        }

        while (transformed[i + 1 + radius[i]] ==
               transformed[i - 1 - radius[i]]) {
            ++radius[i];
        }

        if (i + radius[i] > right) {
            center = i;
            right = i + radius[i];
        }
        if (radius[i] > bestLen) {
            bestLen = radius[i];
            bestCenter = i;
        }
    }

    const int start = (bestCenter - bestLen) / 2;
    return s.substr(start, bestLen);
}

int main() {
    for (const std::string s : {"babad", "cbbd", "abba", "a", ""}) {
        std::cout << s << " -> "
                  << longestPalindrome(s) << '\n';
    }
}

对于 babad,最长答案既可以是 bab,也可以是 aba。上面的代码只在找到严格更长的回文时更新答案,因此会保留最先遇到的 bab

为什么算法正确

不变量一:已经计算的半径是准确的

处理位置 i 前,所有更小位置的 radius 已经由实际字符比较确定。镜像初始化只复制当前大回文内部由对称性保证相等的部分,所以不会把半径估大;随后的 while 会一直扩展到第一对不同元素,因此最终半径也不会估小。

不变量二:center 对应扫描过的最右回文

若新回文的右端 i + radius[i] 没有超过 right,旧记录仍然最右;若超过,就用新中心和新右端替换。因而每轮结束后,centerright 始终描述已处理中心中向右延伸最远的回文。

最长答案不会遗漏

变换后,每个奇数回文和偶数回文都对应某个位置的奇数长度回文。算法准确求出每个位置的最大半径,再取其中最大值,所以一定得到原字符串的最长回文子串。

为什么是 O(n)

变换串长度是 2n + 3,外层循环显然执行 O(n) 次。看起来每一轮还有一个 while,但镜像初始化已经跳过最右边界内的已知部分:

  • 成功的新比较会把回文推过旧的 right,使 right 单调右移;
  • right 最多从左端移动到变换串末尾,因此所有成功扩展合计只有 O(n) 次;
  • 每个中心至多再做一次导致停止的失败比较,同样是 O(n) 次。

所以总时间为 O(n),半径数组和变换串占用 O(n) 额外空间。

常见错误

  1. 混用 right 的两种定义:有人把它写成回文最右字符,有人写成右侧第一个未包含位置。两种都能实现,但 i < right、初始化半径和更新公式必须配套。
  2. 忘记截断镜像半径:直接写 radius[i] = radius[mirror] 可能复制到已知大回文之外,得到没有依据的结果。
  3. 使用可能与输入冲突的哨兵:若直接拼成 ^#...#$,就要明确输入字符集不包含这些符号;整数标记更稳妥。
  4. 把字节当作 Unicode 字符:std::string 按字节存储 UTF-8。若要判断中文字符回文,应先解码为 Unicode 码点序列,再运行相同算法。
  5. 起点公式写错:在本文变换方式下,原串起点为 (bestCenter - bestLen) / 2,返回长度就是 bestLen

除了最长回文还能做什么

统计所有回文子串

一个中心半径为 r 时,它贡献的原串回文数量是 (r + 1) / 2。把所有中心的贡献相加,就得到回文子串总数;相同内容出现在不同位置时会分别计数。

判断前缀或后缀回文

中心 i 的回文左端为 i - radius[i]、右端为 i + radius[i]。检查端点是否碰到变换串中的首分隔符或尾分隔符,就能筛出回文前缀、回文后缀,常用于最少添加字符构造回文。

建议测试用例

输入期望性质要检查的问题
""返回空串空输入是否越界
"a"返回 a最小非空输入
"cbbd"返回 bb偶数长度回文
"abac"返回 aba奇数长度回文
"aaaaa"返回整个字符串大量重叠回文与复杂度
"a#^$a"按普通字符处理分隔符和哨兵冲突

最后的记忆线索

  1. 插入分隔符,把奇偶回文统一起来;
  2. radius[i] 记录以 i 为中心的回文半径;
  3. centerright 保存当前最右回文;
  4. 区间内部先取 min(镜像半径, 到右边界的距离)
  5. 从已知半径之外继续扩展,并在必要时更新最右边界。

Manacher 的线性速度来自一个很通用的思想:把已经证明过的对称性当作可复用信息,只为未知边界付费。理解这一点,比背下几行模板更重要。