☰
最长回文子串全解析:从动态规划到Manacher的进阶之路
2026/10/13 3:33:48 网站建设 项目流程

1. 从一道高频面试题说起

最长回文子串,几乎每个刷过LeetCode的人都见过它,第5题,难度中等。但你要是真去统计一下,这道题在面试里出现的频率,绝对能排进字符串类题目的前三。我见过不少候选人,能把动态规划的代码背得滚瓜烂熟,但被问到“为什么要从长度短的子串开始遍历”就卡住了;也有人写出了中心扩展,却说不清为什么时间复杂度是O(n²)。这恰恰说明,很多人只是记住了代码,并没有真正理解这个问题的本质。

这篇东西不是单纯给你讲题解,我想把这道题彻底拆开:从暴力解法一步步推导到最优解,把每种思路的动机、细节、坑都讲透。无论你是准备面试、刷竞赛,还是想系统理解字符串处理,这篇文章都值得看完。我把话放在前面:最长回文子串不是一道需要死记硬背的题,它是一把理解动态规划、双指针和字符串优化的钥匙。

先说一个很多人问过我的问题:最长回文子串和最长回文子序列有什么区别?子串要求连续,子序列不要求连续。比如字符串“abca”,它的最长回文子串是“a”或“b”或“c”,长度1;但最长回文子序列是“aba”或“aca”,长度3。很多初学者在这里就绕晕了,所以先把这个边界划清楚,后面的讨论才不跑偏。

2. 暴力法:最容易想到,也最容易超时

2.1 暴力法到底在枚举什么

暴力法的思路非常直白:枚举所有可能的子串,检查每一个子串是不是回文,记录其中最长的那个。假设字符串长度为n,子串总数是O(n²),检查一个子串是否是回文需要O(n)的时间,所以总复杂度是O(n³)。

用代码写出来就是:

def longestPalindrome(s: str) -> str: n = len(s) if n < 2: return s max_len = 1 begin = 0 for i in range(n - 1): for j in range(i + 1, n): if j - i + 1 > max_len and isPalindrome(s, i, j): max_len = j - i + 1 begin = i return s[begin:begin + max_len] def isPalindrome(s: str, left: int, right: int) -> bool: while left < right: if s[left] != s[right]: return False left += 1 right -= 1 return True

这段代码逻辑本身没错,但在LeetCode上跑,基本就是超时的命。n稍微大一点,比如1000,三层循环就是10亿次操作,放在任何语言里都扛不住。

2.2 为什么暴力法依然值得分析

我不建议你直接跳过暴力法。原因有两个:

第一,暴力法是所有优化方案的“基准线”。动态规划、中心扩展、Manacher算法,本质上都是在做同一件事:减少“枚举子串”和“判断回文”这两部分的开销。你只有清楚暴力法慢在哪里,才能理解后续每一点优化到底优化了什么。

第二,暴力法提供了一个最简单的验证方法。我在实际调试优化算法时,经常写一个暴力版的结果作为“标准答案”,拿优化算法的输出跟它对比。特别是Manacher这种容易出边界bug的算法,没有暴力法做参照,错了都不知道错在哪。

3. 动态规划:用空间换时间的经典思路

3.1 状态定义与状态转移

动态规划的核心,是把“判断一个子串是不是回文”这件事拆成更小的子问题。

定义一个二维布尔数组dp[i][j],表示子串s[i:j+1](i和j是闭区间)是否是回文。那么状态转移方程是:

dp[i][j] = (s[i] == s[j]) and dp[i+1][j-1]

这个方程的理解很关键:一个子串是回文,必须同时满足两个条件——首尾字符相等,并且去掉首尾之后剩下的部分也是回文。

举个例子,判断“ababa”是否为回文:首尾都是‘a’,相等;再看“bab”,首尾都是‘b’,相等;再看“a”,单个字符,是回文。一层层往里剥,这就是动态规划的思维。

3.2 为什么必须按长度遍历

这几乎是这道题面试时必问的一个点。

先看错误示范:按i从小到大、j从大到小(或从小到大)遍历。比如先算dp[0][4],需要用到dp[1][3];但此时dp[1][3]还没算出来,拿到的就是初始值false,结果自然错了。

正确做法是按子串长度从小到大遍历:先算所有长度为1的子串,再算所有长度为2的,然后是长度为3的……这样当计算dp[i][j]时,dp[i+1][j-1]对应的子串长度为j-i-1,一定比当前子串长度小,必然已经计算过。

def longestPalindrome(s: str) -> str: n = len(s) if n < 2: return s dp = [[False] * n for _ in range(n)] max_len = 1 begin = 0 # 长度为1的子串都是回文 for i in range(n): dp[i][i] = True # 按长度递增遍历 for length in range(2, n + 1): for i in range(n - length + 1): j = i + length - 1 if s[i] != s[j]: dp[i][j] = False else: if length == 2: dp[i][j] = True else: dp[i][j] = dp[i + 1][j - 1] if dp[i][j] and length > max_len: max_len = length begin = i return s[begin:begin + max_len]

3.3 动态规划的两个隐藏重点

说两个容易忽略的细节。

第一个是长度为2的边界处理。当s[i]==s[j]且length==2时,dp[i+1][j-1]实际上是dp[i+1][i],这在我们的表中没有被初始化,直接访问会得到false。所以必须单独处理:长度为2且首尾相等,就是回文。

第二个是空间复杂度的代价。dp表是n×n的布尔矩阵,空间复杂度O(n²)。当n=5000时,就是2500万个布尔值,虽然Python的False/True是单例,但二维列表本身的开销也不小。这也是为什么实际工程中,动态规划不是首选——它虽然把时间复杂度降到了O(n²),但空间开销太大。

有人会问,能不能把dp表压缩成一维?答案是不能简单地滚动数组,因为dp[i][j]依赖的是dp[i+1][j-1],在二维表里是“左下角”的值。如果你按长度遍历,每一层的计算会跨行访问,滚动数组没法保证被依赖的值不被覆盖。这也是动态规划解法的一个天然局限。

4. 中心扩展:常数级优化的胜利

4.1 中心扩展的基本思路

动态规划的O(n²)时间和O(n²)空间,其实还有优化空间。换个角度想:回文串的本质是什么?是“围绕中心对称”。那与其枚举子串再判断,不如直接枚举“中心”,然后向两边扩展,直到不能扩展为止。

这个思路看起来很简单,但它避开了动态规划最耗内存的dp表,空间复杂度直接降到O(1),时间复杂度依然是O(n²)——因为枚举中心是O(n)的,每个中心最多扩展O(n)次。

这里有个容易踩坑的地方:回文中心有两种。奇数长度的回文,中心是单个字符;偶数长度的回文,中心是两个字符之间的空隙。比如“aba”的中心是‘b’,“abba”的中心是“bb”中间。所以枚举中心时,要分别处理这两种情况。

def longestPalindrome(s: str) -> str: n = len(s) if n < 2: return s max_len = 1 begin = 0 for center in range(n): # 奇数长度:中心是center left, right = center, center while left >= 0 and right < n and s[left] == s[right]: left -= 1 right += 1 # 扩展结束后,回文范围是[left+1, right-1] length = right - left - 1 if length > max_len: max_len = length begin = left + 1 # 偶数长度:中心在center和center+1之间 left, right = center, center + 1 while left >= 0 and right < n and s[left] == s[right]: left -= 1 right += 1 length = right - left - 1 if length > max_len: max_len = length begin = left + 1 return s[begin:begin + max_len]

4.2 为什么中心扩展实际表现优于动态规划

只看大O的话,动态规划和中心扩展都是O(n²),但实际跑起来,中心扩展明显更快。原因有两个:

一是常数因子小。动态规划有两层循环,每层循环内部都要做数组访问、赋值和判断;中心扩展只有一层循环(枚举中心),内部的while虽然也可能扩展很多次,但平均下来,对于随机字符串,扩展几次就停了。

二是缓存友好。动态规划要频繁随机访问二维数组的各个位置,CPU缓存的命中率不高;中心扩展访问的是连续的字符串切片,内存访问模式更线性。

我实测过LeetCode上的长测试用例,比如一个超长的“aaaaaaaaaa……”这样的字符串,中心扩展的每个中心都会扩展到边界,最坏情况依然是O(n²),但跑起来还是远快于动态规划。原因就在于它的内存访问模式更好。

4.3 中心扩展的一个优化技巧

对于全相同的字符串,比如n=5000的全‘a’串,中心扩展的时间确实会退化。每个中心的扩展长度都接近n,总操作次数是O(n²)。不过实践中这种用例不多,而且即使在最坏情况下,中心扩展也依然能跑完,只是时间稍长。

如果真想加速,可以在中心扩展之前做一步处理:把所有连续相同的字符压缩成一个候选中心。因为“aaaa”这样的串,无论从哪个‘a’开始扩展,结果都一样。但这样做会改变字符串的索引对应关系,实现起来复杂度上升不少,性价比不高。我自己的经验是,面试时能写出标准中心扩展就够了,这个优化可以作为加分项提一嘴,但不要在现场写。

5. Manacher算法:O(n)的真正最优解

5.1 为什么需要Manacher

中心扩展虽然常数小,但最坏情况还是O(n²)。在字符串长度达到10⁵甚至10⁶的场景下,O(n²)是绝对不可接受的。这个时候就要请出Manacher算法了。

Manacher算法由Manacher在1975年提出,核心思想是利用已经计算过的回文信息,避免重复扩展。它能在O(n)时间内找出最长回文子串,是理论上限——因为任何算法至少要读一遍字符串。

Manacher的第一反应都是“这东西好难”。我一开始也这么觉得,直到我把它的逻辑拆成三步,才发现它其实没有那么神秘:预处理、利用对称性、维护右边界。

5.2 第一步:预处理,统一奇偶

前面说过,回文中心有两种:单字符和双字符空隙。Manacher用了一个巧妙的方法统一处理:在字符串的所有字符之间(包括首尾)插入一个特殊字符,比如‘#’。

原始串:abcba 预处理后:#a#b#c#b#a#

插入之后,原串中所有回文子串,在预处理串中都变成了奇数长度,中心都变成了某个具体字符(可能是原字符,也可能是‘#’)。这样一来,我们只需要处理一种情况。

这里有个细节:插入的字符必须不在原字符串中出现。我在一次交流中看到有人用空格插入,结果原字符串里正好有空格,导致算法出错。选‘#’、‘$’这类不常见的字符都可以,但注意别选ASCII码跟原字符冲突的。

5.3 第二步:维护臂展数组和右边界

预处理后的字符串记为t,我们需要计算一个数组p,其中p[i]表示以t[i]为中心的最大回文半径(臂展),这个概念可能有点抽象,我实际举例说明。

比如t =#a#b#a#,以中间的‘b’为中心,p[3] = 3,表示半径覆盖3个字符,即#a#这一小段。真正回文串的原始长度,可以用p[i] - 1反推回来。

算法的核心是维护两个变量:

  • center:当前能覆盖到最右边界的回文串的中心位置
  • right:这个回文串的右边界(开区间)

当我们在遍历i时,如果i < right,说明i在当前已知最大回文的覆盖范围内。根据回文的对称性,i关于center的对称点j = 2 * center - i,我们之前已经计算过p[j]。那么p[i]至少可以取min(p[j], right - i)。

这句话就是Manacher的全部精髓。它利用了“回文内部的对称性”——既然center左右的字符是完全对称的,那以i为中心的回文,至少跟以j为中心的回文一样长,除非j的回文超出了right的边界,这时候就只能保守地取right - i,再继续扩展。

看代码更直观:

def longestPalindrome(s: str) -> str: # 预处理 t = ['#'] for ch in s: t.append(ch) t.append('#') t = ''.join(t) n = len(t) p = [0] * n center, right = 0, 0 for i in range(n): # 利用对称性,初始化p[i] if i < right: mirror = 2 * center - i p[i] = min(p[mirror], right - i) else: p[i] = 1 # 中心扩展,尝试扩大回文 while i - p[i] >= 0 and i + p[i] < n and t[i - p[i]] == t[i + p[i]]: p[i] += 1 # 更新center和right if i + p[i] > right: center = i right = i + p[i] # 找出最优结果 max_len = 0 center_idx = 0 for i in range(n): if p[i] - 1 > max_len: max_len = p[i] - 1 center_idx = i # 映射回原串 begin = (center_idx - max_len) // 2 return s[begin:begin + max_len]

5.4 为什么Manacher是O(n)

你可能已经注意到,while循环里每一步都会把right往右推。而right最多从0增加到n,所以整个算法中,所有while循环的总执行次数是O(n)。再加上外层循环也是O(n),总复杂度就是O(n)。

这个推理非常关键。Manacher并不是“每次扩展都成功”,而是“扩展成功的次数加起来不超过n”。它巧妙地利用了一个事实:right只会向右移动,不会回退。这是它线性复杂度的根本保证。

5.5 Manacher的实际应用场景

有些人觉得Manacher太冷门,面试很少考。这句话只对了一半。确实,大多数面试官不会要求你手写Manacher,能写出中心扩展就已经很好了。但Manacher的思路在很多字符串题目中都有变体:

  • 求字符串的“最长双回文子串”(两个相邻回文串的最大总长)
  • 求前缀或后缀中的最长回文
  • 在字符范围受限的场合,求解回文相关问题

更重要的是,Manacher体现的“利用对称性避免重复计算”的思想,在KMP、Z算法、AC自动机等字符串算法中都能看到影子。理解它,对整体提升字符串算法能力很有帮助。

6. 横向对比与工程选型建议

6.1 四种解法全对比

我用一张表把这四种方案梳理清楚:

方法时间复杂度空间复杂度实现难度适用场景
暴力枚举O(n³)O(1)极低仅用于正确性验证
动态规划O(n²)O(n²)中等n ≤ 5000的笔试
中心扩展O(n²)O(1)低n ≤ 5000,面试首选
ManacherO(n)O(n)高n ≥ 10⁵,竞赛或工程

实际开发中,字符串长度通常不会太大,中心扩展完全够用。但如果你在处理基因序列、长文本分析这类超长字符串,Manacher就是必须掌握的了。

6.2 面试时怎么写,才能拿高分

我的建议是:面试时优先写中心扩展,然后用口述把Manacher的思路讲清楚。这是性价比最高的策略。

原因很实在:中心扩展代码短、逻辑清晰、两分钟就能写完,面试官马上能看懂,不容易出错。而Manacher虽然更优,但边界条件极多,现场手写出bug的概率很大。一旦写错,面试官对你的印象反而会打折扣。

我在面试别人的时候,见过不少候选人信誓旦旦说“我会Manacher”,结果写到一半卡住,最后代码没调通。这种情况很尴尬。反倒是老老实实写中心扩展、然后清晰说明Manacher原理的候选人,我会给更高的评价。

6.3 工程上怎么选

如果是在项目里真需要处理回文相关逻辑,我看过一些开源的实现,比如某些字符串匹配库,选的是Manacher算法的变体。原因很简单:高性能要求的场景,O(n)和O(n²)在数据量上来之后差距非常悬殊。

但如果只是偶尔用一次,不要为了炫技引入一个难维护的算法。维护成本也是成本。代码里写一个注释清晰的中心扩展,比写一个没人能改的Manacher要实用得多。

7. 代码实现的细节与验证

7.1 环境准备与测试数据

这道题在几乎所有主流语言里都能写,我平时用Python和C++刷。Python写起来最快,C++跑起来性能最好。这里给出C++的Manacher实现,方便对比:

class Solution { public: string longestPalindrome(string s) { // 预处理 string t = "#"; for (char c : s) { t += c; t += '#'; } int n = t.size(); vector<int> p(n, 0); int center = 0, right = 0; for (int i = 0; i < n; i++) { if (i < right) { int mirror = 2 * center - i; p[i] = min(p[mirror], right - i); } else { p[i] = 1; } while (i - p[i] >= 0 && i + p[i] < n && t[i - p[i]] == t[i + p[i]]) { p[i]++; } if (i + p[i] > right) { center = i; right = i + p[i]; } } int max_len = 0, center_idx = 0; for (int i = 0; i < n; i++) { if (p[i] - 1 > max_len) { max_len = p[i] - 1; center_idx = i; } } int begin = (center_idx - max_len) / 2; return s.substr(begin, max_len); } };

7.2 测试用例设计心得

刷题时很多人只跑示例就交了,这是大忌。这道题的边界条件非常多,我整理了一套固定的测试集,每次写完必跑:

  • 空字符串:输入“”,输出“”
  • 单字符字符串:输入“a”,输出“a”
  • 全相同字符:输入“aaaa”,输出“aaaa”
  • 两个字符:输入“ab”,输出“a”;输入“aa”,输出“aa”
  • 回文在开头:输入“abacdfg”,输出“aba”
  • 回文在结尾:输入“xyzcbaabc”,输出“baa”
  • 偶数长度回文:输入“cbbd”,输出“bb”

这些用例基本覆盖了所有分支。特别是“ab”这种两个字符的用例,很能暴露那些没考虑偶数回文的小bug。

7.3 如何快速定位问题

如果代码跑错了,先别急着看算法逻辑,用最简单的排查法:加打印,输出每个中心附近的p值或者dp表的值。

比如写中心扩展时,我会在每次更新max_len的地方打印当前回文串,看它是不是真的是回文。这样能很快定位到“扩展边界是否越界”或者“索引映射是否正确”这些问题。

Manacher则更依赖对“right - i”这个边界条件的理解。如果发现某次计算结果偏大,大概率是初始化p[i]时取了不该取的min值,或者while循环的边界写错了。

8. 常见问题排查与避坑指南

8.1 动态规划的三个常见错误

动态规划的写法,新手很容易栽在下面这几个地方:

第一个错误是遍历顺序不对。有些同学写出双层循环,i从0到n,j从i到n,然后直接套状态转移方程。这样算dp[i][j]时,dp[i+1][j-1]还没算,结果全错。原因在于状态转移依赖的是“长度更短”的子串,必须先算短的再算长的。

第二个错误是dp表的初始化。如果只用默认的False,长度为2且首尾相等的回文判定就会出错。我的建议是:先初始化对角线为True,再在循环里单独处理length == 2的情况,这样逻辑最清晰。

第三个错误是记录答案的时机。有些人只更新dp表,忘了同步更新最长长度和起始位置。最后算完再扫描一遍dp表,当然也能得到答案,但平白多了一轮O(n²)的扫描,不值。

8.2 中心扩展的两个常见错误

中心扩展相对简单,但有两个经典坑:

第一个坑是偶数回文没有处理。只枚举单字符中心,导致“abba”这类偶数长度回文永远找不到。这属于思路不完整,写之前就应该想到。

第二个坑是while循环里的边界条件。我见过有人写成while left <= right而不是while left >= 0 and right < n,结果要么数组越界,要么死循环。记住:left是向左扩展的指针,right是向右扩展的指针,它们的上下界必须同时检查。

8.3 Manacher的三个进阶防错点

Manacher的坑更多,我列三个最隐蔽的:

第一个是关于p[i]的初始化。p[i] = min(p[mirror], right - i)是核心,但很多人不理解为什么是right - i而不是right - i + 1。这取决于right的定义:如果right是开区间(不包含回文串最后一个字符的位置),那么i到right的距离就是right - i个字符。用闭区间定义的话,就变成right - i + 1。两种定义都行,但必须前后一致。我自己习惯用开区间定义,代码里也这么写。

第二个是关于下标映射。预处理串中回文中心是center_idx,回文半径是p[center_idx],原始串中回文的起始位置是(center_idx - max_len) // 2。很多人在这个映射上出错,多试试几个用例就能发现规律。

第三个是中心扩展后更新right的时机。必须在while循环结束后更新,因为while循环可能把p[i]扩大,这时候i + p[i]才是真正的右边界。

8.4 复盘总结:我踩过的那些坑

我自己第一次完整跑通Manacher,花了不少时间。最大的教训是,不要死记代码,要把right、center、p[i]这些变量在纸面上一层层推演一遍,推完再写代码就顺了。

给大家一个推演建议:拿一个短字符串比如“abba”,手工模拟Manacher的每一步。把t数组写在纸上,依次计算每个i对应的p[i],标记center和right的变化。整个过程大概10分钟,做完之后你就发现,这个算法真的没有想象中那么难。

另一个经验是,学习和实际应用结合,写完之后找几个不同的题源做同类题巩固一下。比如求前缀回文、求至少添加多少字符能使整个字符串变成回文串,这类变体都建立在Manacher之上。做两三道变体题,你对Manacher的理解会上一个台阶。

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

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

立即咨询