“最长回文子串”这道题,我印象太深了。它在LeetCode热题100里属于那种“看起来很简单,写起来全是坑,讲起来又是个无底洞”的经典代表。我面试过不少候选人,拿到这道题时大部分都能在几秒内说出“我想想”,但真正能在白板上写出一个没有明显bug、还能把复杂度讲清楚的,其实不多。问题不在于难,而在于这道题几乎覆盖了字符串算法里最核心的几类思路:暴力枚举、中心扩展、动态规划、线性时间优化。每一种都是面试的高频考点,组合起来自然就成了刷题绕不开的必经之路。
这篇文章我就围绕它好好聊一聊,把几种解法从原理到代码、从复杂度到面试选型都过一遍。适合正在刷LeetCode热题100的朋友,也适合准备算法面试、想彻底搞清楚回文子串这类问题的人。我会尽量把每一步的“为什么”也讲清楚,而不只是丢一段代码让你背。
1. 题目真正在问什么:回文串、子串和子序列的区别
1.1 先抠清楚概念,不然代码写着写着就跑偏
题目给的是一句话:给你一个字符串 s,找到 s 中最长的回文子串。
先说“回文串”。一个字符串如果正着读和倒着读一样,它就是回文串。比如 "aba"、"abba"、"a",甚至空字符串 "",都可以看作回文。这里的核心是对称性:中心对称或者镜像对称。
再说“子串”和“子序列”。这两个词听起来很像,但区别非常大。子串必须是在原字符串里连续的一段字符,比如 "babad" 的子串有 "bab"、"aba"、"bad";而子序列则不要求连续,只要保持相对顺序就行。如果是求“最长回文子序列”,那 "babad" 的答案可以是 "bab" 或者是 "bad" 里挑出来的 "bd" 之类的,完全不是一回事。
很多人在面试时把“子串”和“子序列”搞混,回答成了最长回文子序列的解法,代码写一半才发现不对,非常尴尬。子序列问题通常用二维 DP,而子串问题既可以用 DP 也可以用中心扩展,更可以用马拉车(Manacher)算法处理,方向完全不同。
1.2 单个字符也是回文,这是容易忽略的隐含条件
这道题还有一个容易被忽略的前提:当字符串长度小于 2 时,答案就是它本身。
s = "",最长回文子串是"",长度为 0;s = "a",最长回文子串是"a",长度为 1;s = "ab",严格来说"a"和"b"都满足条件,返回任意一个都可以。
所以写代码时,开头先处理 n < 2 的情况,能省掉后面一堆边界条件的烦恼。很多人的代码出错,不是因为主体逻辑不行,而是没在一开始处理这些短输入。
1.3 为什么它能挤进热题100,还常年被大厂翻牌
LeetCode热题100里的题,基本都是在某种维度上具有代表性的。这题的代表性就在于:它可以让一个候选人从最容易想到的暴力解法讲起,一路讲到最优解,每一步都有清晰的复杂度变化,面试官可以顺着这条路观察你的思考和沟通能力。
如果候选人只会暴力解,至少说明基础没丢,可以继续引导;如果会中心扩展和动态规划,说明对字符串问题和状态定义有一定理解;如果还能写马拉车,那基本可以确定刷题量或算法功底很不错。这也是这题的热度为什么一直居高不下的原因之一。它不像某些偏怪偏难的题,做不出来就是做不出来;这道题是“每个人都能给点思路,但答到什么深度一眼见高下”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法:把枚举的思路走一遍再扔掉
2.1 最原始的方案:枚举所有子串,逐一判断是否为回文
初学者拿到题之后,最自然的想法就是:把所有子串都列出来,对每个子串检查它是不是回文,记录下最长的那一个。逻辑上没有任何问题,也是“暴力解”的正统思路,所有优化解法都是从它一步步改进来的。
判断一个子串是不是回文,最直观的办法是反转后比较,或者用双指针从两端往中间走。我用 Python 给个最直白的版本:
python复制def longestPalindrome(s: str) -> str:
n = len(s)
if n < 2:
return s
max_len = 1
start = 0
for i in range(n):
for j in range(i + 1, n):
sub = s[i:j+1]
if sub == sub[::-1] and len(sub) > max_len:
max_len = len(sub)
start = i
return s[start:start + max_len]
这里枚举了所有 i 到 j 的组合,i 和 j 分别代表子串的起止位置,对每个子串用 sub[::-1] 反转后判断。代码很短,可读性很好,但在真实面试或刷题时基本没人用它作为最终答案。
2.2 为什么暴力解在 LeetCode 上连中等长度的用例都扛不住
我们来算一下复杂度。外层循环跑 n 次,内层循环平均也要跑 n 次,生成子串并判断回文需要 O(n) 的扫描量。所以整体时间复杂度是 O(n^3),空间复杂度是 O(n)(子串拷贝的空间)。
假设 n = 1000,那运算量大约在 10^9 这个量级,本地跑一下可能会卡到怀疑人生。LeetCode 上如果真把 "a" * 1000 这样的用例丢进去,暴力解基本是超时警告。就算时间上能忍,逻辑上也没有任何扩展性。而热题100里很多题目的测试数据都足够大,暴力解根本无法通过全部的测试用例。
不过我不建议直接跳过暴力解。它的价值在于帮你建立“枚举子串”这个基础概念,后面动态规划的状态定义就是从“某个子串是否回文”这一点出发的。只有先理解暴力解枚举了哪些信息,才能理解 DP 表里到底在记录什么。
3. 中心扩展法:面试中最值得优先掌握的解法
3.1 核心观察:回文串天然是“从中心向外对称生长”的
暴力解浪费在什么地方?它把大量时间花在了重复判断同一个子串的对称性上。比如判断 "abcba" 是回文时,其实已经隐含了 "bcb" 是回文、"c" 是回文这些信息,但暴力解没有利用起来。
换一个角度想:回文串的结构很特殊,它是从一个中心开始,左右两边字符一一对应扩展出来的。比如 "aba" 可以看作从 "b" 这个中心向左右各扩一位得到;"abba" 可以看作从 "bb" 这两个字符中心向左右各扩一位得到。
所以,与其枚举所有子串再判断,不如枚举所有可能的中心点,然后从中心向两边扩展,直到不能扩展为止。中心扩展法的名字就是这么来的。
3.2 为什么必须分别处理奇数长度和偶数长度的中心
这里有个非常关键的问题:回文串的中心可以是一个字符,也可以是两个字符之间的空隙。
- 奇数长度的回文串,比如
"aba",中心是'b'这个字符; - 偶数长度的回文串,比如
"abba",中心是'b'和'b'之间的空隙。
如果你只枚举字符串里的每个字符作为中心,就会漏掉所有偶数长度的回文串。所以实现时,对每个位置 i,我们要分别尝试两种中心:一种是以 i 为中心向两边扩展,另一种是以 i 和 i+1 之间的空隙为中心向两边扩展。
代码实现:
python复制def expand(s: str, left: int, right: int) -> str:
while left >= 0 and right < len(s) and s[left] == s[right]:
left -= 1
right += 1
return s[left + 1:right]
def longestPalindrome(s: str) -> str:
n = len(s)
if n < 2:
return s
res = ""
for i in range(n):
# 奇数长度回文,中心为 i
s1 = expand(s, i, i)
# 偶数长度回文,中心在 i 与 i+1 之间
s2 = expand(s, i, i + 1)
res = max(res, s1, s2, key=len)
return res
3.3 复杂度分析和为什么它在实战中最“能打”
中心扩展法的时间复杂度是 O(n^2),空间复杂度是 O(1)。它比暴力解的 O(n^3) 好了一个数量级,核心在于每个中心扩展时虽然最坏情况要扩到全串长度,但所有中心加起来的总扩展次数不会超过 O(n^2)。
原因很简单:全串一共有 2n - 1 个可能的中心(n 个字符中心 + n - 1 个空隙中心),每个中心最多扩展 n 次,所以总复杂度是 O((2n-1) * n),也就是 O(n^2)。
我在实际刷题和模拟面试中,最推荐的解法就是中心扩展法。原因有三个:
- 代码量少,逻辑直白,不容易在紧张时写出低级错误;
- 空间复杂度低,面试官追问优化时你还能自然接上;
- 它不依赖复杂的线性代数或单调性概念,只要理解了回文的对称结构就能写。
而且这个解法在 LeetCode 上跑数据完全没问题。即使 s 的长度到了几千甚至上万,O(n^2) 在多数情况下也是能接受的。对于热题100来说,这已经是标准答案了。
4. 动态规划解法:从子问题推导全局,但别掉进遍历顺序的坑
4.1 状态定义和状态转移方程
动态规划解决这道题的思路,和暴力解一样建立在“判断子串是否回文”上,但不同的是,它把子串的判定结果记下来供后续使用,避免了重复计算。
定义 dp[i][j] 表示字符串 s[i..j] 是否为回文串。如果 s[i] != s[j],那它肯定不是回文,直接是 False;如果 s[i] == s[j],那么只需要看内部子串是否回文即可:
- 当
j - i <= 2时,即子串长度为 1、2 或 3,那么只要首尾相等,它就是回文。比如"a"、"aa"、"aba"都是回文; - 当
j - i > 2时,需要看dp[i+1][j-1]是否为True。
所以状态转移方程可以写成:
text复制dp[i][j] = (s[i] == s[j]) and (j - i <= 2 or dp[i+1][j-1])
翻译成人话就是:首尾相等,并且去掉首尾之后剩下的子串也是回文,那当前子串就是回文。这个递推关系是动态规划的灵魂,理解了它就会发现代码其实不难写。
4.2 为什么不能简单双层循环,必须按子串长度从小到大遍历
这是做这道题时最容易踩的坑。
很多朋友一看到 dp[i][j],就习惯性地写两个 for:
python复制for i in range(n):
for j in range(i + 1, n):
...
然后发现结果时对时错,尤其是遇到像 "cbbd" 这种偶数回文时特别容易出问题。问题出在哪?在遍历顺序上。
因为 dp[i][j] 依赖的是 dp[i+1][j-1],也就是它左下角的格子。如果外层按 i 增大、内层按 j 增大的普通顺序遍历,那么当你去填 dp[i][j] 时,dp[i+1][j-1] 很可能还没有被计算出来,拿到的就是个初始值 False,导致整个递推链断开。
正确的做法是:按子串长度从小到大枚举。先算长度为 1 的,再算长度为 2 的,再算长度为 3 的……这样保证计算 dp[i][j] 时,长度更短的 dp[i+1][j-1] 已经被更新过了。
python复制def longestPalindrome(s: str) -> str:
n = len(s)
if n < 2:
return s
dp = [[False] * n for _ in range(n)]
start = 0
max_len = 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 or dp[i + 1][j - 1]:
dp[i][j] = True
if length > max_len:
max_len = length
start = i
return s[start:start + max_len]
4.3 动态规划的空间优化方向和适用场景
上面的代码空间复杂度是 O(n^2),因为用了一个 n x n 的布尔矩阵。不过如果你只关心最长长度,而不需要精确截取子串,其实可以优化成 O(n) 的滚动数组,只保留上一轮长度对应的状态。
但说实话,这道题的动态规划在 LeetCode 热题100里,更多是作为“状态定义和转移”的训练题来用的。如果单纯为了求解,中心扩展法的时间和空间综合表现更好;如果为了展示思路层次,DP 也是必要一环。面试时,我一般建议把 DP 作为第二方案提出,既能展示你会动态规划,又能让你在面试官追问“空间能否优化”时再补一句“可以用滚动数组优化到 O(n)”,就能体现得比较完整。
5. Manacher算法:线性时间求最长回文子串的完整推导
5.1 马拉车想解决什么问题
中心扩展法已经能做到 O(n^2) 了,但面试如果继续追问“还能更快吗”,就需要亮出马拉车(Manacher)算法了。它的时间复杂度是 O(n),是目前解决最长回文子串最优的线性算法。
马拉车算法的核心思想,是利用回文串的对称性,避免中心扩展法在扩展过程中产生的重复比较。简单理解:中心扩展法在枚举每个中心时,都是从半径 1 开始往外扩;但假如我们之前已经处理了一个很大的回文区间,而当前位置正好落在这个区间内,那么根据对称性,它的初始回文半径可以借用对称位置的半径信息,而不是从 0 开始重新扩展。这个优化把很多重复工作直接省掉了。
5.2 用分隔符统一奇偶回文的处理
实现马拉车之前,需要先解决一个麻烦:回文有奇数长度和偶数长度之分,处理起来很不统一。马拉车的做法是在所有字符之间以及首尾都插入一个特殊字符,比如 '#'。
比如原始字符串 "babad",变换后变成:
text复制# b # a # b # a # d #
插入之后,原串中的所有回文子串,无论原来是奇数长度还是偶数长度,在这个新串里都会对应一个以某个中心(可能是原始字符,也可能是 #)为中心的奇数长度回文。这样就不用再分别处理奇偶两种中心了。
注意:这个特殊字符在原串中不会出现,你可以随意选一个,常用 '#'。
预处理数组长度变成 2 * n + 1,每个位置 i 记录以它为中心的最大回文半径(包含中心自身)。最终原串的最长回文长度就是数组中最大的 p[i] - 1。
5.3 三个核心变量:p[i]、right、mid
马拉车需要维护三个东西:
p[i]:以新串中第i个字符为中心的回文半径;right:当前所有已处理回文区间中,最靠右的右边界;mid:对应这个最右边界所属回文串的中心。
每次都维护 right 和 mid,在处理到新位置 i 时,如果 i 还处于 [mid - p[mid], right] 这个已知回文区间内,就可以利用对称性快速给 p[i] 一个初始值:
text复制p[i] = min(p[2 * mid - i], right - i)
这里 2 * mid - i 是 i 关于 mid 的对称位置。之所以要取 min,是因为 i 的对称点 j 的回文半径再大,也不能超出当前 right 的边界;超出部分没有验证过,不能贸然相信,只能先取一个相对保守的初始值。
拿到初始值之后,再继续向两边扩展,不断更新 p[i],同时实时更新 right 和 mid。
5.4 完整实现和代码解析
python复制def longestPalindrome(s: str) -> str:
if len(s) < 2:
return s
# 预处理:插入 '#'
t = '#' + '#'.join(s) + '#'
n = len(t)
p = [0] * n
mid = 0
right = 0
max_len = 0
start = 0
for i in range(n):
if i < right:
p[i] = min(p[2 * mid - i], 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
# 更新最右回文边界
if i + p[i] > right:
right = i + p[i]
mid = i
# 记录最长结果
if p[i] - 1 > max_len:
max_len = p[i] - 1
start = (i - p[i] + 1) // 2
return s[start:start + max_len]
这代码里有几个细节值得展开。
为什么 p[i] - 1 是原串的回文长度?因为 t 中的回文半径 p[i] 对应到原串中,实际长度是 p[i] - 1。比如 "aba" 插入后是 "#a#b#a#",以中间 'b' 为中心的回文半径是 4,而 p[i] - 1 = 3,正好等于 "aba" 的长度。
为什么 start = (i - p[i] + 1) // 2?因为 i - p[i] + 1 是 t 中回文串的左边界索引,如果这个左边界是 #,除以 2 后正好对应到原串左边界加 1 的位置;如果左边界是原始字符,除以 2 后正好是对应原串位置。这个除法天然完成了从 t 下标到原串下标的映射。如果觉得这里绕,你也可以在扩展的时候动态记录最长回文的左右端点,就不用记这个“玄学公式”。
我不建议第一次接触马拉车就直接背代码。正确打开方式是:先找几个例子手跑一遍,比如 "babad" 和 "cbbd",把 p 数组一步步算出来,感受 right 和 mid 的更新过程,再看代码就顺了。
5.5 马拉车的复杂度证明为什么成立
很多人疑问:while 循环不还是可能扩展很多次吗?为什么复杂度能到 O(n)?
关键就在于 right 这个变量。每次 while 成功扩展一个字符,right 就会往右移动一位。而 right 在整个算法中最多从 0 移动到 n,不会回头。每个位置被访问和扩展的总次数是线性级别的。所以即使最外层循环有 n 次,内层 while 的总执行次数也是 O(n),整体就是 O(n)。
这也是马拉车算法的精妙之处:它把“扩展”的代价和“右边界右移”绑定在一起,而右边界总共只能右移 n 次,自然就压到了线性复杂度。
6. 实操中踩过的坑、边界条件和面试选型建议
6.1 边界条件对照表
这道题的边界条件非常值得单独整理一份表。我每次写之前都会快速过一遍,能省掉很多无谓的调试。
| 输入 | 预期结果 | 需要特别注意的点 |
|---|---|---|
"" |
"" |
返回空串,不崩溃 |
"a" |
"a" |
单字符也是回文 |
"ab" |
"a" 或 "b" |
任意单字符都合法 |
"aa" |
"aa" |
偶数中心不能漏 |
"aba" |
"aba" |
奇数中心扩展 |
"abba" |
"abba" |
偶数中心扩展 |
"aaaa" |
"aaaa" |
全相同字符,扩展会到边界 |
"abcde" |
"a" 等任意单字符 |
最长回文长度为 1 |
"babd" |
"bab" |
注意不要误选 "ab" 等非回文串 |
在做单元测试或面试白板验证时,我建议至少把 "a"、"ab"、"aa"、"aba"、"abba"、"aaaa" 这六个用例跑一遍。它们几乎能把所有常见错误都暴露出来。
6.2 中心扩展、动态规划、马拉车,面试时到底选哪个
面试中选哪种解法,不是越高级越好,而是要看你的沟通节奏和当前情况。
首先是中心扩展法,它是最稳妥的默认选择。因为代码短,思维负担小,你在讲思路时也能讲得很清楚。万一写错,现场调试也很容易看出哪里有问题。
如果面试官进一步问“还能优化空间复杂度吗”,或者题目扩展成“求回文子串个数”之类的问题,这时可以考虑动态规划。虽然 O(n^2) 的时间和 O(n^2) 的空间稍微大一些,但动态规划的好处是状态定义清晰,在解决“回文子串个数”“分割回文串”等问题时能直接复用思维框架。
至于马拉车算法,我建议会做但不要一上来就写。因为它在白板笔试时很容易因为一个下标算错而写崩,且不容易调试。比较自然的节奏是:先给中心扩展,等面试官追问“能不能 O(n) 解决”时,再引出马拉车。这时候你既展示了自己知道优化空间,也避免了第一步就写得过于复杂的风险。
6.3 我个人的刷题建议和一些容易忽略的经验
最后分享一点我刷题和模拟面试的体会。我第一次做这道题时,也是一上来就把马拉车背下来了,结果第二天回头写,p 数组的初始化、right 和 mid 的更新全部忘了。后来我换了策略:先用中心扩展法把题做对,再尝试自己推导一遍马拉车的 p 数组构建过程,直到能手算出一个小例子的完整 p 数组,突然就通了。这个“手推数组”的方法,比反复看题解有效得多。它逼着你去理解每一步更新背后的原因,而不是模糊地记住一个模板。
还有一个小技巧:如果你用的是 Python,字符串切片相比算法本身的开销大得多,所以即使写暴力解或中心扩展,我也尽量不频繁生成新的字符串,而是记录 start 和 max_len,最后再一次性截取。代码更干净,性能也好一点。
如果后续想在回文字符串这个话题上继续深入,可以再看这几道题:最长回文子序列、回文子串个数、分割回文串。它们和本题共享了很多核心思想,但又有各自不同的状态定义和边界处理。把这一组题放在一起刷,比单独刷一百道零散的题要更加高效。
