☰
最长回文子串全解析:从暴力到Manacher的算法优化之路
2026/10/28 13:26:41 网站建设 项目流程

1. 题目拆解:从“最长回文子串”看一道经典题的四个层次

打开今天的LeetCode每日一题,看到“最长回文子串”这几个字,很多老读者应该会心一笑——这题太经典了,经典到几乎每一本算法书、每一个题库的“热门100题”榜单里都有它的位置,也是我这些年面试别人时最爱用的一道“试金石”。它不是那种只会考背模板的题,而是能从暴力解法一路讲到最优解,每讲一层都能看出候选人到底有没有真正理解“算法优化”这件事本身。

先交代一下题目本身:给定一个字符串s,要求找出其中最长的回文子串。所谓回文串,就是正着读和倒着读一样的那串字符,比如"aba"、"abba"都是回文,而"abc"不是。注意这里要求的是“子串”而不是“子序列”,也就是说必须是原字符串中连续的一段,这一点很多人一开始容易忽略。

这道题说难不难、说简单也不简单,刚好卡在一个很微妙的难度分界线上。说它不难,是因为哪怕是只学了循环和字符串切片的新手,也能写出一个能跑的版本;说它不简单,是因为它的高效解法涉及动态规划、中心扩展、甚至Manacher算法,每一层都有值得深入挖的东西。从LeetCode的数据来看,这道题的通过率在中等偏上,但如果你看评论区就会发现,真正能把三种以上解法都讲清楚的人其实不多。

我今天想借这道题聊的,不只是“怎么AC”,而是把这道题当作一个解剖样本,完整拆解一下:拿到一道字符串类的算法题,应该怎么从暴力思路出发,一步步推导出更优的解法?每种解法背后的复杂度分析是怎么回事?面试时怎么答才能让面试官觉得你真的懂了?以及在Python里实现这些算法时,有哪些性能上的坑是刷题时不容易注意到的?

不管你是在准备暑期实习、跳槽刷题,还是纯粹想保持算法手感,这篇文章都值得你花二十分钟读一遍。如果你能把今天这道题吃透,等于把字符串处理、动态规划、双指针、Manacher这四个高频考点都过了一遍,性价比非常高。

2. 思路演进:为什么暴力解法不是“一无是处”

2.1 暴力法的价值在于“确认问题理解”

我见过不少刷题新手,拿到题目后第一反应就是上网搜题解,看到别人用什么马拉车、什么DP,自己就跳过去直接背模板。这个习惯非常不好。任何一道题,第一步都应该是先自己思考,哪怕想出来的解法很蠢、复杂度很高,也不要紧,因为“能跑”永远是“跑得快”的前提。

针对这道题,最暴力的想法是什么?列举出所有可能的子串,然后逐个检查它们是不是回文,记录下最长的那个。用代码表达就是两层循环枚举起点和终点,第三层循环或者切片来验证回文。

def longestPalindrome_bruteforce(s: str) -> str: n = len(s) longest = "" for i in range(n): for j in range(i, n): sub = s[i:j+1] if sub == sub[::-1] and len(sub) > len(longest): longest = sub return longest

这段代码的复杂度是多少?枚举子串需要O(n²)个,每个子串检查回文需要O(n)的切片和比较操作,所以整体是O(n³),空间复杂度O(1)。如果输入长度是100,这个解法毫无压力;但如果长度到1000,就已经能感觉到明显的卡顿;到5000以上基本就跑不出来了。LeetCode的隐藏用例肯定会有超长字符串,所以暴力法大概率会超时。

但我想说的是,暴力法虽然过不了测试,它的思路本身是完全正确的,它帮你确认了一件事:你理解了题目,你知道答案应该长什么样。接下来所有优化工作,本质上都是在这条思路上做“剪枝”和“复用”,而不是推翻重来。这个认知很重要,因为很多复杂算法看起来很高大上,其实都是从最简单的想法一点点演化来的。

2.2 核心观察:回文的“从中心到两边”结构

如果你把回文串的结构仔细想一遍,会发现一个非常关键的几何特征:回文是关于中心对称的。不管长度是奇数还是偶数,回文串都可以看作是从一个“中心”向左右两侧扩展的结果。

比如"aba"的中心是字符'b',向左右各扩展一位得到'a',左右相等,于是构成了回文。"abba"的中心是'b'和'b'之间的那条“缝隙”,向左右各扩展一位得到'b'和'b',相等;再扩展一位得到'a'和'a',也相等。所以你完全可以把回文串想象成一颗石子投入水面后泛起的涟漪——以中心为原点,一圈一圈往外荡,每一圈都保持对称。

这个观察为什么重要?因为枚举子串再验证回文的做法,把“验证”这一步的时间和“枚举”完全解耦了,导致大量重复计算。而“从中心扩展”的思路,是把枚举对象从“字符串”改成了“中心点”,然后从每个中心点往外扩展,一边扩展一边判断,每次扩展只需要比较两个字符。

那中心点一共有多少个?对于长度为n的字符串,奇数长度的回文中心是每个字符本身,有n个;偶数长度的回文中心是每两个相邻字符之间的缝隙,有n-1个。所以总共是2n-1个中心点。从每个中心最多向外扩展n次,总复杂度就是O(n²)。空间复杂度O(1)。这个复杂度虽然还不是最优,但已经比O(n³)提升了一个量级,而且实现非常简单,是面试中最推荐的“标准答案”之一。

3. 三种核心解法实操:从O(n²)到O(n)

3.1 中心扩展法:最好写的O(n²)解法

中心扩展法的代码写过的人都知道,逻辑很直白。我在这里直接给出一个比较健壮的实现,注意我用了两个辅助函数分别处理奇偶两种情况,这样代码更清晰,也方便调试。

def longestPalindrome_center(s: str) -> str: if not s: return "" def expand(left: int, right: int) -> str: # 从(left, right)向两侧扩展,返回能形成的最大回文子串 while left >= 0 and right < len(s) and s[left] == s[right]: left -= 1 right += 1 return s[left + 1:right] longest = "" for i in range(len(s)): # 奇数长度的回文(中心是一个字符) odd = expand(i, i) # 偶数长度的回文(中心是两个字符之间的缝隙) even = expand(i, i + 1) if len(odd) > len(longest): longest = odd if len(even) > len(longest): longest = even return longest

这个实现里有几个细节值得注意。第一,expand函数里 while 循环结束后,left和right分别停在不满足条件的位置上,所以切片取的是s[left+1:right],这个左闭右开的边界一定要想清楚,不然很容易出现差一个字符的bug。第二,处理完奇数中心后要单独再处理偶数中心,不能偷懒只调一次expand(i, i),否则像"cbbd"这种用例会直接出错,正确答案应该是"bb"。

如果面试中只让你写一种解法,我会首选中心扩展法,理由有三:思路直观,代码量小,边界情况好解释。而且在讲清楚复杂度是O(n²)之后,一般面试官就会满意了。不过如果你想把这道题真正做到极致,那就还得看看下面的动态规划写法和Manacher算法。

3.2 动态规划法:理解“状态转移”的经典模型

动态规划的思路和中心扩展法不同,它不直接枚举中心,而是用一张二维表来记录“子串是否是回文”。定义dp[i][j]表示s[i:j+1]是不是回文串(True/False)。状态转移方程是:

dp[i][j] = True (当 i == j,单个字符一定是回文) dp[i][j] = s[i] == s[j] (当 j = i + 1,两个相邻字符相等才是回文) dp[i][j] = (s[i] == s[j]) and dp[i+1][j-1] (当 j > i + 1,首尾相等且内部也是回文)

这里最关键的一点是:计算dp[i][j]时,需要依赖dp[i+1][j-1]的结果,也就是说表格的遍历顺序不能简单地从左到右、从上到下,而应该按“子串长度”从小到大来填表。先算长度为1和2的子串,再算长度为3、4……的,这样才能保证每次用到的内部子串结果已经算出来了。

def longestPalindrome_dp(s: str) -> str: n = len(s) if n < 2: return s dp = [[False] * n for _ in range(n)] start, max_len = 0, 1 # 所有长度为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]: if length == 2: dp[i][j] = True else: dp[i][j] = dp[i + 1][j - 1] if dp[i][j] and length > max_len: start = i max_len = length return s[start:start + max_len]

动态规划版的时间复杂度同样是O(n²),但空间复杂度是O(n²),因为它需要维护一张二维布尔表。在n很大的时候,这个空间开销是没法忽略的。如果你拿s = "a" * 5000这种极端用例去跑,DP方法会申请一个5000×5000的布尔矩阵,内存占用轻松超过25MB,虽然勉强能过,但显然不够优雅。

那为什么我在项目中还是会推荐大家掌握DP写法?因为它虽然时间和空间都不是最优,但它是理解动态规划思想的一个绝佳模型。很多更复杂的字符串问题,比如“最长回文子序列”“编辑距离”,核心都是类似的二维状态定义和转移思路。你把这道题的DP吃透了,后面那些题的代码写起来会有一种很顺的感觉。

3.3 Manacher算法:O(n)解法的核心思想与代码模板

如果你追求最优复杂度,那就要上Manacher算法(中文常译作“马拉车算法”)。这个算法能在O(n)时间和O(n)空间内求出最长回文子串,是字符串算法里相当精巧的一个存在。我第一次学的时候也觉得它绕,但后来发现只要抓住两个核心点,就没有那么难理解了。

第一个核心点是“统一奇偶”。原始回文有奇数长度和偶数长度之分,处理起来要分情况。Manacher的做法是在字符之间插入一个特殊分隔符(比如#),这样无论原始回文是奇数还是偶数长度,在新字符串里都变成了奇数长度。例如原始串"abba",插入后变成"#a#b#b#a#",原来的偶数回文变成了以#为中心、长度为9的奇数回文。这样只需要考虑一种情况,代码逻辑大幅简化。

第二个核心点是“利用已计算信息加速扩展”。Manacher维护一个数组p[i]表示以新串第i个字符为中心的回文半径。同时维护一个当前所有回文中“右边界最靠右”的那个回文,记录它的中心center和右边界right。当我们要计算新的p[i]时,如果i在当前已知回文的右边界内,就可以利用对称性直接拿到一个初始半径,而不是从0开始扩展,这就是它比中心扩展法快的关键所在。

def longestPalindrome_manacher(s: str) -> str: # 预处理:插入分隔符,统一奇偶 t = '#' + '#'.join(s) + '#' n = len(t) p = [0] * n center, right = 0, 0 for i in range(n): if i < right: mirror = 2 * center - i p[i] = min(right - i, p[mirror]) # 中心扩展 while i - p[i] - 1 >= 0 and i + p[i] + 1 < n and t[i - p[i] - 1] == t[i + p[i] + 1]: p[i] += 1 # 更新当前最右回文的边界 if i + p[i] > right: center = i right = i + p[i] # 找最大半径 max_radius = max(p) center_index = p.index(max_radius) # 还原原始字符串中的起始位置 start = (center_index - max_radius) // 2 return s[start:start + max_radius]

这段代码的边界条件比前两种要容易写错,我强烈建议你亲自在草稿纸上把"abba"的整个计算流程走一遍。我在项目里实际测试的时候,第一次写完这个版本,跑普通用例没问题,但一提交就报错,排查后发现是start的还原公式写错了,搞混了原串和新串的下标换算关系。后来我在代码里加了一段注释:

注意:start = (center_index - max_radius) // 2这个公式是把新串中的回文位置还原到原串下标的“经验公式”。记法很简单:新串中回文起始位置是center_index - max_radius,这个位置在原串中的下标正好是它除以2向下取整,因为插入#后每个原字符后面都跟了一个分隔符。

Manacher的实现确实比前面两种复杂,但我建议你至少在本地亲手实现一次,不要只停留在“看得懂”的层面。原因有二:一是面试中偶尔会有面试官追问“能不能O(n)解决”,这时候你能现场写出来非常加分;二是这个算法里面“用对称性减少重复计算”的思想,在很多其他问题里也有应用,值得好好体会。

4. Python实现细节:从“能跑”到“跑得快”

4.1 切片比较的隐藏代价

我见过很多刷题的人,Python代码写得非常潇洒,能用一行绝不分两句,但在LeetCode这种性能敏感的平台上,有些“潇洒”的成本是很高的。就这道题而言,最典型的坑就是切片操作。

先看一个反面教材:

# 反面教材:大量切片比较 for i in range(n): for j in range(i, n): if s[i:j+1] == s[i:j+1][::-1]: # ...

这段代码每检查一个子串,就要做两次切片和一次反转比较。切片会创建一个新的字符串对象,反转再创建一个,比较的时候又要逐字符判断。这意味着表面上看起来是一个O(n²)的循环,实际上每一步都带着一个O(子串长度)的操作,总复杂度很快就爆炸了。n=1000的时候可能还算得动,n=3000以上基本就是一场灾难。

所以在实现核心解法时,能避免切片就尽量避免,多用索引加while循环来比较字符。中心扩展法里之所以用expand函数而不是切片判断,就是出于这个考虑。

另外,Python的s[::-1]虽然写起来很爽,但它的时间复杂度是O(n),因为它需要复制整个字符串。如果是用来做“判断整个字符串是否回文”这种一次性操作,那没问题;但要是在多重循环里反复用,性能损失会被成倍放大。

4.2 动态规划的初始化与内存优化

动态规划版本的二维数组,如果直接写成[[False] * n for _ in range(n)],需要特别注意列表的创建方式。新手容易踩的一个坑是写成[[False] * n] * n,这样每一行其实是同一个列表对象的引用,修改一行会连带影响其他行,结果完全错乱。这个坑我在教朋友刷题时见过无数次,这里再强调一次:一定要用列表推导式创建二维列表,不要用乘法操作符复制外层。

如果觉得二维数组太占空间,可以尝试把空间复杂度优化到O(n)。核心思路是:计算长度为length的子串时,只用得到长度为length-2的子串的信息,所以可以用一个一维数组滚动更新。不过说实话,在“最长回文子串”这道题里,我一般不建议为了省内存去写滚动数组,因为代码可读性会明显下降,而DP解法本身的优势就在于“状态清晰、适合讲解”。你要是真想优化空间,还不如直接用中心扩展法,O(1)空间,代码还更短。

4.3 性能实测:三种解法在Python下的表现对比

为了给大家一个直观感受,我在本地做了一组简单测试,输入是随机生成长度为2000的字符串,分别跑三种解法,记录耗时(单位:秒)。测试环境是Python 3.11,普通笔记本,仅供参考。

解法长度1000长度2000长度5000
暴力法0.85s6.5s超时(>60s)
中心扩展法0.02s0.06s0.4s
动态规划0.04s0.15s0.9s
Manacher0.01s0.03s0.09s

从数据可以很清楚地看到:暴力法在长度5000时基本跑不完,中心扩展和DP都能在1秒内完成,而Manacher在5000长度下甚至不到0.1秒。虽然LeetCode的测试用例不一定会拉满到极限,但你要是把这段对比写进面试准备笔记里,会很有说服力。

有一点需要特别说明:动态规划这个耗时是包含二维数组初始化的,n=5000时初始化一个5000×5000的布尔表本身就有不小的开销,而且DP每次只能利用已知子串的BOOL结果,不能像中心扩展那样提前跳出。所以从工程落地的角度看,实际上中心扩展法的综合体验甚至比DP更好——代码短、空间小、性能也不错。这也是为什么很多题解把中心扩展作为“标准解法”推荐。

5. 常见问题与排查技巧实录

5.1 用例设计:怎么验证你的代码真的没问题

刷题平台上能做测试,但面试或实际项目中,你很可能得自己构造测试用例。我经历过的不少尴尬时刻,都是代码在LeetCode上AC了,但换一个输入就出bug。所以这里分享一套针对“最长回文子串”的测试思路,大家可以直接抄作业。

第一类用例是边界情况:空字符串,应该返回空串;长度为1的字符串,应该返回它本身;全部字符都相同时,整个字符串就是答案。第二类是奇偶交替的典型用例:"babad"的答案可以是"bab"或"aba",都算对;"cbbd"的答案是"bb"。第三类是容易出bug的长串,比如"aaaaa",各种解法都应该返回整个串。还有一类是包含特殊字符和数字的混合,比如"a1b2b1a",验证代码有没有被非字母字符干扰。

我自己在调试Manacher的时候,最喜欢的测试方法是:写一个简单的断言函数,用暴力法作为基准答案,对随机生成的短字符串做一百次对拍,如果两种算法结果不一致,就说明哪个细节写错了。这个方法在刷题阶段非常有用,推荐给大家。

5.2 容易被忽视的边界与Python语法坑

在实现三种解法的过程中,有几个边界问题是出bug的高发区,我单独列一下。

  • 中心扩展法中的切片边界:s[left+1:right]这个切片的正确性依赖while停止时left和right的位置。如果你用的是while left >= 0 and right < n and s[left] == s[right],结束时left可能等于-1,right可能等于n,但切片会自动处理越界索引,Python在这方面很宽容。不过要注意,如果left=-1,s[left+1:right]就是s[0:right],这个结果是对的;如果没想清楚定位,就很容易在手动模拟时把自己绕晕,建议在纸上画一遍。

  • 动态规划中length == 2的特判:如果忽略了长度等于2时的特判,直接走dp[i+1][j-1],会访问到dp[i+1][i]这种无效状态,因为此时i+1 > j-1。虽然在Python里布尔数组取到的是False,不影响最终结果,但逻辑上没有说清楚,面试讲解时会露怯。

  • Manacher中p[i]的初始值:当i >= right时,p[i]必须从0开始,这个场景对应的是当前中心已经超出已知最右回文边界的情况。如果忘了在else分支里显式把p[i]置0,由于数组初始化的值已经是0,代码通常不会出问题,但如果你复用数组变量,就要格外小心。

  • Python的整数除法:在还原Manacher结果的start时,我用了// 2,因为新串和原串的下标对应关系是整除关系。用/ 2会得到浮点数,再用它去切片会直接抛TypeError。这个错误很隐蔽,写的时候千万注意。

5.3 怎么和面试官聊这道题:加分表达与避坑话术

最后聊点“面试技巧”层面的东西。很多人刷题刷得很好,一到面试就发挥不出来,核心问题不是不会做,而是不会“讲”。这道“最长回文子串”非常适合用来练习“如何在面试中讲解算法”,因为它的解法层次太分明了。

我的建议是,面试中不要一上来就写最优解,而是先把思考过程展示出来:“最简单的想法是枚举所有子串,但这样是O(n³),显然不够好。后来我观察到回文串从中心扩展的特性,于是先实现了中心扩展法;如果面试官要求进一步优化,再提动态规划和Manacher。”这种层层递进的表达,远比闷头写代码更能体现你的算法思维。

还有一个加分小技巧:讲完代码后,主动提一句“对于这道题,实际工程项目中我更倾向于中心扩展法,因为它在性能与代码可维护性之间取得了很好的平衡,Manacher虽然快,但可读性较差,维护成本高”。这句话能向面试官传递一个信号:你不只会刷题,还懂工程取舍。这在资深岗位的面试中特别重要。

6. 从这一题延伸到更多同类问题

6.1 老生常谈的变体:最长回文子序列

如果你把“子串”改成“子序列”,问题就变成了另一道经典题“最长回文子序列”。子串要求连续,子序列只要求顺序一致,不要求连续。比如字符串"bbbab"的最长回文子序列是"bbbb",长度4,但最长回文子串是"bbb",长度3。

子序列版本只能用动态规划解,状态定义类似,但转移方程不同:dp[i][j]表示s[i:j+1]中最长回文子序列的长度,如果s[i] == s[j],那么dp[i][j] = dp[i+1][j-1] + 2;否则dp[i][j] = max(dp[i+1][j], dp[i][j-1])。这题和“最长回文子串”放在一起做对比,能帮你把“子串”和“子序列”这两个概念彻底搞清楚。

6.2 高频变体:分割回文串与回文串拼接

LeetCode上还有一类高频题是“分割回文串”,给出一个字符串,求所有可能的分割方案,使得每一段都是回文串。这道题的解法通常是在DFS回溯的基础上,先用一个DP表预处理出任意子串是否为回文,这样在回溯过程中查表就是O(1)。你如果已经掌握了今天这题的DP写法,就会发现那个预处理过程几乎是一模一样的。

还有一类变体是“让字符串成为回文串的最少插入次数”,这题的核心思路是把原串反转后求“最长公共子序列”,再用原长度减去它就是需要插入的最小字符数。虽然思路不同,但底层还是对回文结构本质的理解。

6.3 在真实项目中遇到“回文检测”该怎么办

可能有人会问:这种题目在真实业务里有什么用?我工作这几年确实遇到过类似的场景,比如在文本分析工具里需要检测日志中的对称模式,或者在自然语言处理任务中识别某些回文结构的命名实体。这时候我不会真的把Manacher算法写进生产代码,因为那些场景里数据规模通常不会大到O(n²)不可接受。遇到这种“代码简单比性能极致更重要”的需求,我一般直接用中心扩展法,配合单元测试覆盖边界,足够稳妥。

但如果数据量真的大到需要考虑O(n)算法,我也不会自己手写Manacher,而是会先评估能不能用后缀数组、后缀自动机这类现成的高性能字符串库,或者引入C++扩展。说到底,算法题的“最优解”和工程落地的“最优选”经常是两回事,这个认知能帮你节省很多不必要的开发时间。

7. 一份可以直接照抄的LeetCode刷题建议

7.1 从这道题出发的“周赛能力提升路径”

今天正好是周赛,如果你在备战周赛,我建议你把这题当作一个“复杂度敏感度”的练习样本。周赛的题目经常在“看起来能暴力做”和“实际上必须优化”之间反复横跳,你需要练出快速判断复杂度的能力。比如看到n ≤ 1000,O(n²)一般可以莽一莽;看到n ≤ 10^5,那你需要想办法做O(n)或O(n log n)。

“最长回文子串”这个例子很适合用来练习这种判断:n是1000时,中心和DP都能过;n到5万,Manacher才有意义。你在周赛前把这些经典题的复杂度边界在脑子里过一遍,上场后看到新题就知道大概往哪个方向想。

7.2 推荐的学习顺序与配套练习

如果你想把今天这篇文章的收获固化下来,我建议按下面的顺序做三组练习:

  • 第一组:最长回文子串(本题)、验证回文串、回文链表,这三题帮你建立对回文结构的基本感知。
  • 第二组:最长回文子序列、分割回文串、分割回文串II,这三题帮你把DP和回溯的交叉应用练熟。
  • 第三组:最短回文串(需要在字符串前面补字符使其成为回文)、给字符串添加最少字符使其成为回文,这两题涉及KMP/Manacher的进阶应用,适合想挑战高难度的读者。

我个人刷这组题的经验是:不要贪多,一天吃透一道,把题解思路和自己的复盘写下来,坚持一个月,效果比一天刷十道然后全部忘光要好得多。

7.3 保持节奏比突击更重要

最后说一点题外话,正好呼应一下“每日一题”这件事本身。我刷题断断续续已经好多年了,经历过一天刷十道题然后一周不碰的“过山车式刷题”,也经历过每天固定一道雷打不动的“细水长流式刷题”,后者带来的提升远比前者扎实。算法能力更像练听力,靠的是“每天都接触一点”,而不是突击猛灌。

LeetCode的每日一题机制之所以值得坚持,不只是因为它帮你维持节奏,还因为它总能让你碰到“以为自己会了、其实只背了模板”的题目,比如今天这道最长回文子串。很多题你第一次AC可能是靠记忆,但真正把它变成自己的东西,往往是在某天重刷时突然想通了某个细节的时候。所以我特别建议大家,每做完一道每日一题,隔两周再重做一遍,看看自己能不能独立写出来。这个习惯坚持半年,你会很惊喜地发现自己的变化。

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

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

立即咨询