☰
回文串算法三大核心性质与Python实现:从中心扩展到Manacher
2026/10/20 6:12:15 网站建设 项目流程

回文串这个题目,基本是算法题单里绕不开的常客。无论是准备技术面试、刷题入门,还是打算法竞赛,你迟早会和“最长回文子串”“回文子串计数”这类问题正面相遇。我见过不少同学把回文串题当成“玄学”:靠肉眼观察对称、靠枚举所有子串去碰运气,结果要么超时,要么边界情况处理得头皮发麻。其实回文串的三大核心性质——对称扩展性、首尾递推子结构、回文半径覆盖——早就把回文串从“凭直觉认对称”变成了“可以计算、可以递推、可以优化的对称”。这篇内容想把这三条性质掰开揉碎讲清楚,再配上 Python 里三种由浅入深的实现,从最容易写对的中心扩展法,到动态规划,再到工程师面试里常用的 Manacher 算法。不管你现在是刚接触回文串,还是想彻底弄懂线性算法背后的原理,这篇内容都能给你一个可以直接落地的参考。

1. 回文串的定义与判定边界

1.1 什么样的字符串才算回文串

回文串的定义一句话就能说完:正着读和倒着读完全一样的字符串。比如 "abba"、"racecar"、"level",汉字里也有“上海自来水来自海上”这类经典例子。它本质上是一种轴对称结构,轴的位置可能在某个字符上,也可能在两个字符中间的空隙上。这个“轴”的两种存在形式,直接决定了后面所有算法里“奇数长度和偶数长度要分别处理”的麻烦。

你可能会觉得,定义这么简单,有什么好讲的?真正写代码的时候麻烦事全在边界上。比如空字符串算不算回文?按算法竞赛和 LeetCode 的惯例是算的,因为空串正读反读都为空,自然满足“相等”的条件。单个字符也算回文,它正反都是自己。这两个边界如果没想清楚,后面中心扩展法和动态规划里很多状态就不知道该不该初始化成 True。

1.2 空串、单字符与奇偶长度的坑

回文串长度的奇偶性是个绕不开的细节。奇数长度的回文串,比如 "abcba",它的对称轴是一个具体的字符 "c";偶数长度的回文串,比如 "abba",对称轴在 "bb" 中间的那条虚线上。许多初学者实现最长回文子串时,只考虑了奇数情况,结果遇到 "abba" 这种用例直接返回错误答案。

这个问题还引出一个很重要的计数结论:长度为 n 的字符串,有 n 个字符中心和 n-1 个字符间隙中心,总共 2n-1 个“回文中心”。后面要讲的中心扩展法,就是围绕这 2n-1 个中心向外扩散的。如果你能把这个数字记牢,写代码时就知道循环要从 0 跑到 2n-2,或者直接用“左指针 i、右指针 i 与 i+1”的写法把两种中心一起覆盖。

1.3 判断单个字符串回文的基本姿势

在展开核心性质之前,先写写最基础的判断方法,因为后面很多变体题都会复用这一套。判断一个字符串是不是回文串,用双指针从两头往中间扫:

def is_palindrome(s: str) -> bool: left, right = 0, len(s) - 1 while left < right: if s[left] != s[right]: return False left += 1 right -= 1 return True

这个函数的时间复杂度 O(n),空间复杂度 O(1)。它利用的就是“回文串首尾字符必须相等”这一条最直接的对称性质。只要有一次不相等,就能立刻判定不是回文,不需要再继续扫下去。这个双指针写法在“验证回文串”“回文链表”等问题里还会反复出现,值得形成肌肉记忆。

2. 三大核心性质拆解

2.1 性质一:中心对称与扩展性

回文串最直观的性质是中心对称:从它的对称中心向外看,左边第 k 个字符一定等于右边第 k 个字符。反过来,如果已知一个短回文串,只要它左右两侧的字符相等,那么把这两个字符分别拼到两边后,得到的新字符串依然是一个回文串。

这个扩展性是后面所有“以中心为起点找最长回文”算法的地基。想象一个向两边同步扩张的窗口:初始时窗口就是中心本身(长度 1),或者中心两侧的空隙(长度 0)。不断比较左右两侧新进入窗口的字符,如果相等就继续扩展,直到不相等或越界为止。这时候窗口对应的字符串,就是以这个中心为对称轴的最大回文串。

这里有个很妙的点:回文中心一共只有 2n-1 个,所以你不需要去枚举 O(n^2) 个子串,只需要枚举全部中心并对每个中心做扩展,最坏情况下的比较次数也就是 O(n) 量级乘以中心数。这就是为什么中心扩展法虽然最坏时间复杂度是 O(n^2),但它实现简单、常数小,在多数工程场景里反而表现很好。

我用生活里的例子打个比方:中心扩展就像照镜子,镜子里你和镜子外你的动作总是对称的,你往前走一步、镜子里的人也往前走一步。只要两侧“同步走”还能保持相同,镜像就一直成立;一旦有一侧已经走出边界或者内容对不上,镜像就碎了。

2.2 性质二:首尾递推与子结构

回文串的第二条核心性质,是把回文和非回文用递归的方式联系起来:一个子串 s[i..j] 是回文串,当且仅当 s[i] == s[j],并且去掉首尾后的子串 s[i+1..j-1] 也是回文串。这里的边界条件是:长度为 1 的子串一定是回文;长度为 2 的子串,只要两个字符相等就是回文。

这条性质的价值在于它给了回文串一个清晰的“子结构”。判断长串回文之前,不用重新扫一遍整个串,只要查一下去掉首尾后的短串是不是回文就行。这就是动态规划能用来解回文串问题的根本原因:大问题的最优解依赖于小问题的最优解,并且小问题之间存在重叠,可以缓存起来反复使用。

举一个例子,判断 "abca" 是否为回文:首尾都是 'a',所以问题变成判断中间的 "bc" 是否为回文;而 "bc" 首尾 'b' 和 'c' 不相等,所以它不是回文,于是 "abca" 也不是回文。整个过程只需要一次字符比较和一次查表,非常快。

我把这条性质单独拎出来强调,是因为很多人做回文子串计数或最长回文子串时,第一反应总是“枚举所有子串,每个都调用一遍双指针判定””,复杂度直接到了 O(n^3)。如果改用递推子结构,就能用一张二维状态表记录所有子串的回文性,把复杂度降到 O(n^2)。

2.3 性质三:回文半径与覆盖边界

前两条性质能把回文串问题做到 O(n^2) 级别,但第三条性质才是突破到 O(n) 的关键。这就是回文半径以及它的“镜像覆盖”特性。

想象你已经找到了一个较长的回文串,它的右边界是 right,中心是 center。因为整个 [left, right] 范围内是完全对称的,所以当你要计算对称中心右侧某个位置 i 的最大回文半径时,可以先去左侧找它的镜像点 mirror。由于回文内部完全对称,位置 i 的回文半径至少不会小于位置 mirror 的回文半径,当然前提是它不能超出当前回文的右边界。

这个“至少”并不是凭空猜出来的,它是由回文内部的对称性严格保证的。Manacher 算法正是利用这一点,在 O(n) 时间内跳过大量重复比较,让每个位置只需借助已有的回文半径信息,再加上极少量的实际字符比较,就能确定自己的半径。换句话说,每条“回文臂”最多被比较一次,所以总体是线性的。后面到实现部分,我会把这个过程走一遍,你会发现代码很短,但每一步都印证了这个性质。

三个性质放在一张表里,思路会清楚很多:

性质名称核心表述衍生算法时间复杂度
中心对称扩展性回文中心最多 2n-1 个,从中心向外扩展可得到最长回文中心扩展法O(n^2)
首尾递推子结构s[i..j] 回文当且仅当首尾相等且内部回文动态规划O(n^2)
回文半径覆盖右侧位置可从左侧镜像偷取初始半径,且不超过右边界Manacher 算法O(n)

这三条性质并不是互相排斥的,它们是从不同角度描述同一件事。实际解决回文串题目时,你可能同时用到性质一和性质二,也可能故意用性质三追求更极致的性能。理解它们各自的适用场景,比背模板代码重要得多。

3. Python 实现:从 O(n^2) 到 O(n)

3.1 中心扩展法:最容易写对的版本

中心扩展法直接对应性质一。枚举所有可能的回文中心,然后从这个中心向左右两边同时扩展,记录扩展后的最大长度。

def expand_around_center(s: str, left: int, right: int) -> int: while left >= 0 and right < len(s) and s[left] == s[right]: left -= 1 right += 1 return right - left - 1 def longest_palindrome_expand(s: str) -> str: if not s: return "" start, end = 0, 0 for i in range(len(s)): # 奇数长度回文,中心是 s[i] len1 = expand_around_center(s, i, i) # 偶数长度回文,中心在 s[i] 和 s[i+1] 之间的空隙 len2 = expand_around_center(s, i, i + 1) max_len = max(len1, len2) if max_len > end - start: start = i - (max_len - 1) // 2 end = i + max_len // 2 return s[start:end + 1]

为什么 expand_around_center 返回的是 right - left - 1?因为循环结束时,left 和 right 分别指向“第一个不满足相等条件”的左右位置,真正的回文区间是 [left+1, right-1],长度就是 right - left - 1。

这段代码里最容易被忽略的是更新 start 和 end 时的索引计算。奇数长度回文和偶数长度回文的情况不同:奇数长度时中心是字符串里的某个字符,起始位置要向左偏移 (max_len - 1) // 2;偶数长度时中心是两个字符之间的空隙,结束位置是中心索引加上 max_len // 2。我一开始写的时候直接用 max_len // 2 去算两端,结果奇数长度下总偏一位,后来用 "abcba" 和 "abba" 两个用例手动推了一遍才想明白。

提示:中心扩展法天然适合“以某个中心向外扩散”的题目,比如计算回文子串的数量。它的空间复杂度只有 O(1),这点比动态规划舒服很多。

3.2 动态规划:用状态表记录每个子串是否回文

动态规划的思路对应性质二。我们开一张二维布尔表 dp,其中 dp[i][j] 表示子串 s[i..j] 是否为回文。状态转移方程就是那两条递推关系:长度为 1 恒为回文,长度大于等于 2 时,首尾相等并且内部子串回文。

def longest_palindrome_dp(s: str) -> str: n = len(s) if n < 2: return s dp = [[False] * n for _ in range(n)] for i in range(n): dp[i][i] = True start, max_len = 0, 1 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: start, max_len = i, length return s[start:start + max_len]

这里有几个实现细节值得单独说。

遍历顺序必须按子串长度从小到大来,而不是按左端点 i 的顺序。原因很直接:要算 dp[i][j],你得先知道内部子串 dp[i+1][j-1] 的结果,而那个子串的长度比当前长度短。如果外层循环是 i,内层循环是 j,你在计算较长区间时,内部的短区间可能还没被计算过,查表就是错误的。

初始化只设置了 dp[i][i] = True,那么长度为 2 的子串怎么处理?它走的是 length == 2 这个分支,只要 s[i] == s[j] 就直接置为 True,不需要查内部,因为内部根本不存在。这个分支条件如果不加,dp[i+1][j-1] 在 length == 2 时会访问到未定义或者语义不对的位置,非常容易出错。

动态规划的优点是思路直观,尤其适合变体题,比如统计回文子串数量。缺点也明显:二维表占了 O(n^2) 的空间,n 到几千时还能扛,n 上万内存就爆炸了。不过它的优化空间也很大,因为 dp[i][j] 只依赖 dp[i+1][j-1],也就是上一层的“ i+1 行 j-1 列”,理论上可以用滚动数组把空间压到 O(n)。实际刷题时如果不要求输出具体子串,只要求“有多少个回文子串”,可以只保存两个一维数组,代码会更紧凑。

3.3 Manacher 算法:用回文半径覆盖实现线性时间

要说回文串算法里的“天花板”,还得是 Manacher。它的代码量比动态规划短,运行时间是 O(n),但理解门槛也高。我建议先彻底搞懂性质三,再来读这段代码。

Manacher 的核心思路是:先对原字符串做预处理,在字符之间插入一个不会出现在原串中的分隔符(比如 "#")。这样所有回文串都统一成了奇数长度,偶数长度回文串的中心会落在这个分隔符上,问题被简化成“只处理奇数回文半径”。

def longest_palindrome_manacher(s: str) -> str: if not s: return "" # 预处理,统一奇偶长度 t = ['#'] for ch in s: t.append(ch) t.append('#') 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 # 更新 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] > max_len: max_len = p[i] center_idx = i start = (center_idx - max_len) // 2 return s[start:start + max_len]

我按运行顺序解释一下这段代码在干什么。

预处理后的数组 t,长度是 2n+1。p[i] 记录的是以 t[i] 为中心、向两边扩展的“回文半径”,注意这里的半径长度包括中心本身,所以 p[i] 至少为 0,也就是没有任何扩展。

循环里先判断当前位置 i 是否落在当前维护的最右回文边界 right 之内。如果在,那就找到它在当前回文内部的镜像位置 mirror,p[i] 可以直接初始化成一个非零值,取值是 min(right - i, p[mirror])。这个 min 非常关键:镜像位置的回文半径可能很大,但当前位置不能超过当前回文的右边界,否则超出部分无法被覆盖,必须老老实实去字符比较。

然后进入 while 循环,它才是真正需要比较字符的地方。因为有了第一步的初始值,这个 while 循环在很多位置上一开始就不满足条件,直接跳过,这就省下了大量比较。有些讲解把这一步称为“扩展剩余部分”,我觉得很准确。

更新 center 和 right 的时机是:如果当前位置的回文右边界比之前的更靠右,就把当前中心设为新的 center。也就是说,Manacher 始终维护“能覆盖最右边界的那条回文臂”,它是一切镜像快速初始化的保障。

预处理后的串里,分隔符 "#" 还有额外的好处:p[i] 里恰好记录了对应原串中的回文半径长度。比如 t 中某个字符位置的回文半径是 k,那么它在原串中对应的回文长度为 k。最终我们遍历一遍 p,找到最大的半径和它的中心,就能反推出最长回文子串在原串中的起点。

这里还有一个容易踩到的细节:start = (center_idx - max_len) // 2 这个公式,很多人不理解为什么直接整数除法就行。因为在预处理串里,所有信息都被强制对齐到了“以字符为中心”的结构上,中心索引减去半径正好对应原串回文起点的两倍索引关系,所以除以 2 取整就是原起点。

注意:Manacher 的代码我建议不要只背,而是每次运行前在心里走一遍 p 数组的生成过程。一个很好的验证方法是拿 "abba" 手算一遍。预处理串是 ['#','a','#','b','#','b','#','a','#'],中心是索引 4 的 '#',它的 p 值最终会扩展到 4,对应原串回文长度正好是 4。你把这个过程推一遍,比背十遍模板都有用。

4. 实操中的坑与性能观察

4.1 索引越界与奇偶长度的一线之隔

回文串算法里的错误,十有八九出在索引边界上。中心扩展法会忘掉偶数中心;动态规划会忘记 length == 2 的特殊分支;Manacher 会在 while 循环里少写一个越界判断。我把这些高频错误整理成了一张速查表:

场景典型错误正确处理
中心扩展只调用 expand(s, i, i),漏掉 i 和 i+1 的偶数中心两个方向都要试
中心扩展更新 start/end 时奇偶公式混用奇数用 (max_len-1)//2,偶数用 max_len//2
动态规划遍历顺序按 i 从小到大,导致内部子串状态未计算外层按子串长度从小到大遍历
动态规划length == 2 时查 dp[i+1][j-1]长度 2 单独处理,直接比较两端
Manacherwhile 循环里没有检查 i-p[i]-1 和 i+p[i]+1 的边界两个边界条件必须写全
Manacher忘记预处理后返回区间和原串下标的映射关系用 (center_idx - max_len) // 2 换算起点

排查这些问题的通用方法,我个人强烈推荐一种:构造一个长度很短的字符串,比如 "a"、"aa"、"aba"、"abba"、"abcba",全部跑一遍,然后手动打印出每个中心/每个状态的中间值。回文串算法的调试,最怕就是只看最终答案。因为最长回文子串偶尔会歪打正着返回正确结果,但计数变体题立马就会暴露问题。把中间输出打出来的习惯,能帮你省下至少一半的 Debug 时间。

4.2 三种实现的实测表现怎么理解

虽然从复杂度上看,Manacher 的 O(n) 碾压中心扩展和动态规划的 O(n^2),但实际跑起来并没有那么绝对。

用随机生成的小写字母字符串做对比,在 n = 1000 时,中心扩展法因为常数小、操作简单,速度可能比动态规划还快。动态规划因为要初始化整个二维表,内存访问密集,反而显得更慢。Manacher 在这个规模下优势不明显,代码里还有预处理和数组访问的额外开销。

n 到 5000 以上时差距开始拉开。中心扩展大致是几十毫秒到百毫秒级别,动态规划随内存区域扩大越来越吃力,Manacher 依然是十几毫秒到几十毫秒级别。n 到 10000 甚至更大时,复杂度差异已经不可忽略了。具体数字随机器和 Python 解释器版本浮动,建议不要只看绝对数值,而是记住趋势:输入规模越大,O(n) 的优势越明显。

我的经验是,在笔试和面试场景里,如果 n 不超过几千,直接写中心扩展法或动态规划完全够用,代码短、不容易错,面试官也好理解。只有当 n 上到几十万,或者题目要求你在 O(n) 时间内解决时,才需要上 Manacher。盲目追求最优算法,反而容易在边界条件上翻车。

4.3 回文串变体题目怎么用这三条性质

理解了三大核心性质之后,回文串题目的“套路感”会强很多。我把常见的变体题和它们对应的性质列一下,方便你刷题时对号入座。

回文子串数量统计,用中心扩展法最舒服,从每个中心向外数一下有多少个能扩展成功的回文,加起来就是答案。动态规划也可以做,但空间占用大一些。

分割回文串问题,也就是把一个字符串切成若干段,每段都是回文。这类题几乎必用性质二来预处理所有子串是否回文,然后再做深搜回溯或动态规划。预处理阶段就是在填那张二维布尔表。

最长回文子序列问题,注意它不要求连续,所以不能用“回文半径”或“中心扩展”的思路。它本质上是动态规划,状态转移来自“首尾字符相等与否”的决策,可以看成性质二在非连续场景下的推广。很多刷题的人在这个地方栽跟头,是因为把“子串”和“子序列”混为一谈,上来就枚举中心,结果自然不对。

把字符串变成回文最少需要插入几次字符,这类题通常转化成“原串与反转串的最长公共子序列长度”,核心也不是回文算法本身,但它背后的对称观念和性质二的重叠子问题思想是相通的。

如果题目要求一个字符串里的最长回文前缀或回文后缀,Manacher 的预处理方案会很方便,因为处理后的 p 数组可以直接告诉你从开头开始最长的回文有多长,不需要再单独做 KMP 或者哈希。

把这些变体题放在一起看,你会发现掌握了三大性质,本质上就是掌握了一套“从不同角度利用对称性”的思维方式。刷题时先冷静判断:这题要求连续子串还是子序列?最长一个还是所有?输出答案还是只计数?选对性质,再选算法,思路就顺了。

5. 收尾前再分享一点个人体会

我在刚开始刷回文串问题时,最大的误区就是总想一次性写出最优解。后来踩过几次坑,调整成一条很简单的策略:先确认边界要求,再选择算法。凡是 n 不大或者只要求返回一个结果的题,我优先写中心扩展法,因为它的正确性最好验证;凡是要求计数且 n 中等,动态规划写起来更快;只有当题目明确卡了 O(n) 复杂度,或者数据范围大到 O(n^2) 完全跑不过去时,才掏出 Manacher。这套取舍在面试和实际工程里都适用,因为代码的可读性和可维护性很多时候比常数优化更重要。

最后分享一个小技巧:如果你经常记混预处理后的索引映射,可以在 Manacher 实现里多加一个辅助函数,把预处理串的索引和原串索引互转的逻辑封装起来,比如 idx = (i - 1) // 2。这样主逻辑只管半径计算,回文起点的换算交给专门的函数处理,出错概率会小很多。这个习惯后来帮我解决了不少回文串变体题的调试时间。

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

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

立即咨询