☰
Manacher算法详解:线性时间求最长回文子串
2026/10/28 15:18:38 网站建设 项目流程

回文子串这个问题,我最早是在刷题时撞上的。当时用最朴素的中心扩展法,写完还挺得意,结果一提交,数据量一上来直接超时,卡在最后几个大用例上。后来翻题解才知道有个叫 Manacher 的算法,能把时间复杂度压到线性,第一次看那堆下标变换和半径数组,说实话是懵的。这篇就把我踩过的坑、绕过的弯、以及最后真正理解它的那个瞬间,完整地摊开讲一遍。

Manacher 算法(圈内常叫马拉车)解决的核心问题就一个:给定一个字符串,在 O(n) 时间内求出它的最长回文子串。它适合所有正在啃字符串算法、被中心扩展的 O(n²) 卡住、想搞懂线性解法到底“线性”在哪里的朋友。哪怕你之前完全没接触过回文相关算法,只要会写基本的 C++ 循环和数组,跟着往下走也能吃透。下面我会从设计思路、核心原理、C++ 实现、边界处理到常见坑,一层层拆开。

1. 为什么需要 Manacher:从暴力到线性的思路演进

1.1 先看清暴力解法的瓶颈在哪

求最长回文子串,最直觉的做法是枚举所有子串,再判断每个子串是不是回文。枚举子串本身是 O(n²),每次判断又要 O(n),总复杂度 O(n³),字符串长度上千就基本没法看了。稍微聪明一点的是中心扩展法:枚举每一个可能的回文中心,然后向两边同时扩展,直到字符不相等为止。这个思路把判断和枚举合并了,复杂度降到 O(n²)。

中心扩展的关键洞察是:回文串一定有一个“中心”。奇数长度的回文中心是一个字符,偶数长度的回文中心是两个字符之间的空隙。所以中心一共有 2n-1 个(n 个字符中心加 n-1 个空隙中心)。对每个中心向外扩,最坏情况下每个中心都要扩到边界,比如字符串全是同一个字符aaaa...a,每个中心都能扩到两端,总工作量就是 O(n²)。

我当初就是栽在这个“全相同字符”的用例上。表面上看平均情况很快,但算法复杂度看的是最坏情况,出题人只要给一串相同字符,中心扩展立刻现原形。这就是必须上 Manacher 的直接原因——它要做的,就是把“每个中心重复扩展”这件事里的大量冗余计算砍掉。

1.2 Manacher 的核心思想:复用已经算过的信息

Manacher 的聪明之处,和 KMP 利用已匹配前缀信息是同一个哲学:不重复计算已经确定的东西。中心扩展时,我们从左到右依次处理每个中心,当处理到靠右的中心时,左边很多中心的回文半径其实已经算出来了。如果当前中心落在某个已知回文串的内部,那么它关于那个回文串中心的对称点,信息是可以直接拿来用的。

打个生活化的比方:你在一条街上从左往右量每个路灯到某栋标志建筑的距离,量到后面发现某段路是对称的,那么右边某个路灯到标志建筑的距离,其实和它左边镜像位置那个路灯的距离是一样的,根本不用重新走一遍。Manacher 干的就是这件事,用一个不断向右推进的“最右回文边界”作为参照,把每个中心能扩的起点直接跳到对称点告诉我们的位置,从而避免从头扩展。

这个“复用”带来的效果是:虽然每个中心看起来还在向外扩,但整体上右边界只增不减,最多推进到 n,所以总扩展次数是线性的。这就是 O(n) 的来源,也是理解整个算法的钥匙。很多人背下了代码却说不清为什么是 O(n),症结就在于没抓住“右边界单调右移”这个不变量。

1.3 奇偶长度统一处理:插入分隔符的巧思

中心扩展还有个烦人的地方:奇数长度和偶数长度要分开处理,代码里到处是两套逻辑,容易写错。Manacher 用了一个非常漂亮的技巧——在字符之间以及首尾插入统一的分隔符,把任意字符串都变成奇数长度。

具体做法是:在原串每个字符左右都插一个特殊符号,比如用#,同时在首尾也加上,这样长度变成 2n+1,必然是奇数。比如aba变成#a#b#a#,abba变成#a#b#b#a#。这样一来,原来偶数长度的回文abba,在新串里对应的是以中间#为中心的回文,所有回文都统一成“以某个字符为中心”的奇数长度形式,一套逻辑通吃。

这里有个细节值得说清楚:分隔符必须是一个在原串中绝对不会出现的字符。如果原串只含小写字母,用#没问题;但如果原串可能包含任意 ASCII 字符,就得选一个确定不冲突的符号,或者干脆用整数数组重新编码。我见过有人图省事直接用原串里可能出现的字符当分隔符,结果在某些用例上答案莫名其妙偏大,排查半天才发现是分隔符和真实字符撞了,导致虚假回文。这个坑后面还会细说。

2. 核心原理拆解:半径数组与对称复用

2.1 半径数组 p[i] 到底记录了什么

Manacher 维护一个数组p,p[i]表示以处理后的字符串第 i 个位置为中心,能向外扩展的回文半径。注意这里的“半径”定义要统一:我习惯的定义是包含中心在内的回文长度的一半(向下取整再加一),也就是从中心到回文最右端(含)的字符个数。比如#a#b#a#中以b(下标 3)为中心,回文是#a#b#a#,从中心到右端是#a#共 3 个字符,所以p[3] = 3。

这个定义下,p[i] - 1恰好就是原串中以该中心对应的最长回文子串长度。为什么?因为处理后的串里,回文半径每多一个字符,对应到原串就多一个真实字符(分隔符不算),所以p[i] - 1就是原串回文长度。这个换算关系是整个算法输出答案的关键,一定要记牢。我一开始用的是另一种半径定义(不含中心),结果换算公式对不上,答案总是差一,折腾了好久才统一。

2.2 最右回文边界与中心:两个必须维护的变量

除了半径数组,算法还要维护两个变量:center(当前已知回文中能延伸到最右的那个回文的中心)和right(这个回文能到达的最右位置,即center + p[center] - 1)。这两个变量是“复用信息”的载体。

处理每个新位置 i 时,先判断 i 是否落在[center, right]这个区间内。如果i < right,说明 i 在已知最右回文的内部,那么它关于 center 的对称点mirror = 2 * center - i的回文信息可以部分复用。具体能复用多少,取决于对称点的回文是否超出了当前回文的左边界:

  • 如果p[mirror] < right - i + 1,说明对称点的回文完全被当前回文包住,那么p[i]至少等于p[mirror],可以直接赋值,不用扩展。
  • 如果p[mirror] >= right - i + 1,说明对称点的回文超出了当前回文范围,那么p[i]至少等于right - i + 1(即 i 到右边界的距离),超出的部分需要继续向外扩展验证。

如果i >= right,说明 i 在已知回文之外,没有任何信息可复用,p[i]从 1 开始,老老实实向外扩。

这段逻辑是 Manacher 的心脏,也是最容易写错的地方。我建议初学时把mirror的推导在纸上画一遍:center 在中间,i 在右边,mirror 在左边,三者满足i + mirror = 2 * center,所以mirror = 2 * center - i。画过一次就再也不会记错了。

2.3 为什么整体复杂度是 O(n):右边界单调性

很多人会疑惑:代码里明明有while循环在向外扩展,怎么保证不是 O(n²)?关键在于right这个变量只增不减。每次 while 循环成功扩展一次,right就会向右推进至少一格。而right最大只能到处理后字符串的长度 2n+1,所以整个算法过程中,while 循环体内成功执行的次数总和不超过 2n+1。

那些“失败”的扩展(即比较一次发现字符不等就退出)呢?每个位置 i 最多触发一次失败的比较,总共 n 个位置,所以失败比较也是 O(n)。成功扩展 O(n) 加上失败比较 O(n),总复杂度就是 O(n)。这个论证是理解 Manacher 线性的核心,比死记“它是 O(n)”有意义得多。我当时就是把这个“势能分析”想通了,才真正敢在比赛里放心用。

3. C++ 实现:从预处理到答案提取的完整代码

3.1 预处理:构造带分隔符的新串

先看预处理。假设原串是s,长度 n。我们构造一个新串t,长度2n + 1,规则是:t[0] = '#',然后对每个原字符,依次放入s[i]和#。这样t的下标 0、2、4... 是分隔符,1、3、5... 是原字符。

string preprocess(const string& s) { string t = "#"; for (char c : s) { t += c; t += '#'; } return t; }

这段代码简单,但有个隐含前提:分隔符#不能和原串字符冲突。如果原串可能包含#,就得换一个。更稳妥的做法是用一个原串里绝对没有的字符,或者干脆用整数编码。我在实际项目里处理可能含任意可见字符的输入时,会先扫描一遍原串,选一个没出现过的 ASCII 字符作为分隔符,虽然多一次 O(n) 扫描,但换来的是绝对安全。

提示:分隔符冲突是隐蔽性极强的 bug。它不会让程序崩溃,只会让某些用例的答案偏大,因为分隔符和真实字符相等时会产生本不存在的“回文”。调试时如果发现答案比预期大,优先怀疑分隔符。

3.2 主循环:半径计算与边界更新

主循环是算法的核心,我把它拆成清晰的几段来写,方便对照理解:

string longestPalindrome(string s) { if (s.empty()) return ""; string t = preprocess(s); int m = t.size(); vector<int> p(m, 0); int center = 0, right = 0; int maxLen = 0, maxCenter = 0; for (int i = 0; i < m; ++i) { // 步骤一:利用对称性确定初始半径 if (i < right) { int mirror = 2 * center - i; p[i] = min(right - i + 1, p[mirror]); } else { p[i] = 1; } // 步骤二:尝试向外扩展 while (i - p[i] >= 0 && i + p[i] < m && t[i - p[i]] == t[i + p[i]]) { ++p[i]; } // 步骤三:更新最右边界 if (i + p[i] - 1 > right) { center = i; right = i + p[i] - 1; } // 步骤四:记录全局最优 if (p[i] > maxLen) { maxLen = p[i]; maxCenter = i; } } // 提取答案 int start = (maxCenter - maxLen + 1) / 2; return s.substr(start, maxLen - 1); }

逐段解释一下。步骤一的min(right - i + 1, p[mirror])就是前面讲的复用逻辑的代码化:能复用的上限是“对称点半径”和“i 到右边界距离”两者的较小值。这里用right - i + 1而不是right - i,是因为半径定义包含了中心,边界处理要一致,差一错误往往就出在这里。

步骤二的 while 循环负责扩展,注意边界检查i - p[i] >= 0 && i + p[i] < m必须放在字符比较之前,否则会越界。C++ 里&&有短路特性,所以这样写是安全的。

步骤三更新边界,条件是i + p[i] - 1 > right,即新的回文右端超过了旧的 right 才更新。这个“严格大于”保证了 right 单调右移,是复杂度分析的依据。

步骤四记录最优,用p[i] > maxLen而不是>=,这样在长度相同时保留最先出现的那个,结果稳定。

3.3 答案提取:从处理后下标还原到原串

答案提取是最容易出错的一步,因为涉及两套坐标系的换算。maxCenter是处理后串的中心下标,maxLen是处理后的半径。原串中回文的起始位置是(maxCenter - maxLen + 1) / 2,长度是maxLen - 1。

这个公式怎么来的?处理后串的回文左端是maxCenter - maxLen + 1,这个下标对应到原串,因为每两个处理后的字符对应一个原字符,所以除以 2 向下取整就是原串起点。长度方面,处理后半径maxLen里包含了分隔符,真实字符数是maxLen - 1。我建议你拿aba和abba两个例子手动代入验证一遍,把maxCenter、maxLen、start都算出来,验证一次就彻底记住了。

注意:整数除法在 C++ 里是向下取整,对于非负下标没问题。但如果你的实现里下标可能为负(一般不会),要小心。另外maxLen - 1当maxLen为 1 时结果是 0,对应空串,虽然实际不会出现(至少有一个字符),但边界上心里要有数。

4. 实操验证与常见问题排查

4.1 用典型用例做手动走查

光看代码不够,我习惯拿几个典型串手动走一遍,确认每一步的p值符合预期。下面这张表是我当初验证时记录的,以abba为例,处理后是#a#b#b#a#:

下标 i字符 t[i]对称点 mirror初始 p[i]扩展后 p[i]centerright
0#-1100
1a-1212
2#01324
3b12224
4#01548
5b32248
6#23348
7a12248
8#01148

最终maxLen = 5,maxCenter = 4,start = (4 - 5 + 1) / 2 = 0,长度5 - 1 = 4,得到abba,正确。这张表建议你自己也画一遍,尤其是下标 3 和 5 这两个位置,它们展示了“对称点半径被边界截断”的情况,是理解复用逻辑的关键样本。

4.2 常见问题速查表

实际写和调的过程中,我整理了一份高频问题清单,基本都是新手(包括当年的我)会踩的:

问题现象可能原因排查与解决
答案比预期大分隔符与原串字符冲突换一个原串中不存在的分隔符
答案比预期小 1半径定义与长度换算不一致统一用“含中心”定义,长度取 p[i]-1
程序越界崩溃while 边界检查顺序写反先检查下标范围再比较字符
某些用例超时误用了 O(n²) 的中心扩展确认复用了 mirror 信息,right 单调
偶数长度回文丢失预处理漏了首尾分隔符确保新串长度是 2n+1
结果不稳定更新最优用了 >=改用 > 保留首次出现

这张表里,前两个是我亲身踩过的。尤其是“答案比预期大”那个,当时排查了很久,最后发现是测试数据里恰好含有我选的分隔符,导致虚假回文。从那以后我养成了习惯:分隔符要么用确定不冲突的,要么先扫描确认。

4.3 几个容易被忽略的实操心得

第一个心得是关于半径定义的统一。网上不同博客对p[i]的定义五花八门,有的含中心有的不含,有的从 0 开始有的从 1 开始。你抄代码时如果混用了两种定义,答案一定错。我的建议是:选定一种定义后,把“初始值、扩展条件、边界更新、答案换算”四处全部按这个定义对齐,写完后用aba和abba各验证一次,确认无误再往下走。

第二个心得是关于空串和单字符的边界。空串直接返回空,单字符返回它自己,这两个特判虽然简单,但漏了会在某些平台上触发越界。我在longestPalindrome开头加了if (s.empty()) return "";,单字符其实会被主逻辑正确处理(处理后是#a#,半径 2,长度 1),所以不用额外特判,但心里要清楚它能过。

第三个心得是性能验证。Manacher 是 O(n),但常数不小(预处理、数组分配、多次比较)。如果你在极端性能场景下用它,可以预先reserve新串的容量避免反复扩容,或者用静态数组代替vector。我实测过,对百万级长度的串,reserve能省下可观的时间,虽然不影响复杂度,但工程上值得做。

5. 算法边界与延伸思考

5.1 Manacher 适合什么、不适合什么

Manacher 专治“单个字符串的最长回文子串”,在这个问题上它是最优的线性解法,没有之一。但它不是万能的:如果你要统计所有回文子串的个数,或者求每个位置为中心的回文数量,Manacher 的半径数组其实已经包含了这些信息(p[i]就反映了以 i 为中心的回文个数),可以直接复用。但如果你要处理的是“最长回文子序列”(不要求连续),那是另一个问题,得用区间 DP,Manacher 帮不上忙。

还有一个容易混淆的点:Manacher 求的是“最长”,如果你需要“所有本质不同的回文子串”,那要用回文自动机(Eertree),复杂度也是 O(n),但数据结构和 Manacher 完全不同。选型时先明确需求是“最长”还是“全部”,别拿错工具。

5.2 和其他回文算法的对比

为了让你对 Manacher 的定位更清晰,我列个简单对比:

算法解决的问题时间复杂度空间复杂度适用场景
中心扩展最长回文子串O(n²)O(1)短串、快速实现
Manacher最长回文子串O(n)O(n)长串、性能敏感
区间 DP最长回文子序列O(n²)O(n²)子序列问题
回文自动机本质不同回文子串O(n)O(n)统计类问题

从表里能看出,Manacher 的定位很明确:连续子串、求最长、要线性。它的空间开销是 O(n) 的半径数组加预处理串,对内存敏感的场景要注意,但一般竞赛和工程场景都能接受。

5.3 一个值得动手的扩展练习

如果你想真正吃透,我建议做这个练习:改造 Manacher,让它不仅返回最长回文子串,还返回所有长度大于等于 2 的回文子串的起始位置和长度。提示:在记录最优的那一步,不要只记全局最大,而是遍历p数组,对每个p[i] > 1的位置,用同样的换算公式还原出原串中的回文。这个练习能逼你把坐标换算彻底搞明白,做完之后你对 Manacher 的理解会上一个台阶。

我自己做完这个练习后,最大的收获是意识到:Manacher 的半径数组其实是一个信息密度极高的结构,它把“以每个中心的最长回文”都存下来了,最长回文只是从中取了个最大值而已。理解了这一点,你就能根据需求灵活地从p数组里挖出想要的信息,而不是死记一个模板。

最后分享一个我调试时的小技巧:写个暴力中心扩展的版本作为对照,随机生成大量小字符串,两个版本对拍。Manacher 的 bug 往往只在特定对称结构下暴露,随机对拍能快速定位。我当初就是靠对拍发现了分隔符冲突那个隐蔽 bug,比盯着代码看效率高多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询