Leetcode 0005. Longest Palindromic Substring (python)
5. Longest Palindromic Substring
题目
Given a string s, return the longest palindromic substring in s.
Example 1:
1 | Input: s = "babad" |
Example 2:
1 | Input: s = "cbbd" |
Constraints:
1 <= s.length <= 1000sconsist of only digits and English letters.
题目大意
在给定字符串中找出最长的回文子串(palindromic substring)。回文串是指正读反读都一样的字符串。若存在多个等长的最长回文子串,返回任意一个即可。字符串长度上限为 1000,仅包含数字和英文字母。
你选用何种方法解题?
本题的核心是寻找所有可能回文子串中长度最大的那一个。回文的本质特征是从中心向两侧对称扩展,因此我们有三种主流思路:
| 方法 | 核心思路 | 时间复杂度 | 说明 |
|---|---|---|---|
| 方法一:中心扩展法(推荐) | 枚举每个可能的回文中心,向外扩展 | O(n²) | 最优工程解:空间 O(1)、代码简洁、实际运行快 |
| 方法二:Manacher 算法 | 预处理后利用回文对称性跳过重复扩展 | O(n) | 理论最优:大数据量或竞赛场景的首选 |
| 方法三:动态规划 | 用二维 DP 表记录子串是否回文 | O(n²) | 空间 O(n²),仅用于理解回文的递推性质 |
方法一是推荐解:虽然时间复杂度和 DP 同为 O(n²),但中心扩展法无需 O(n²) 的额外空间,且实际运行常数因子极小——对于 n = 1000 的上限,最坏仅约 5×10⁵ 次字符比较,远低于 DP 的矩阵填充开销。
Manacher 是理论最优解(O(n)),它的核心洞察非常精彩:预处理插入分隔符统一奇偶回文后,利用已计算回文的对称性,"免费"获取当前中心的初始半径,从而将每个字符的扩展次数均摊为 O(1)。下文会给出完整的手推演算和逐行注释的实现。
解题过程
问题分析
- 输入:字符串
s,长度 1 ≤ n ≤ 1000 - 输出:最长的回文子串(任意一个即可)
- 回文定义:
s[l:r+1]满足s[l] == s[r],s[l+1] == s[r-1], …,即从两端向中心字符一一相等
核心洞察
回文串的对称轴有两种情况:
- 奇数长度(如
"aba"):对称轴落在一个字符上,中心是(i, i) - 偶数长度(如
"abba"):对称轴落在两个字符之间,中心是(i, i+1)
暴力枚举所有子串需要 O(n³),但如果固定中心后向外扩展,每个中心只需 O(n),总共 n 个奇数中心 + n-1 个偶数中心 = 2n-1 个中心,总复杂度 O(n²)。
手推演示例:中心扩展法
以 s = "babad" 为例,追踪算法在每个中心的扩展过程:
1 | s = "b a b a d" |
关键观察:中心 (1,1) 向外扩展一轮得到 "bab"(长度 3),中心 (2,2) 同样得到 "aba"(长度 3)。两者等长,算法保留先发现的 "bab"。
再以 s = "cbbd" 为例:
1 | s = "c b b d" |
偶数长度回文的对称轴在字符之间——(1,2) 对应 s[1]='b' 和 s[2]='b' 的中间位置。
手推演示例:Manacher 算法
Manacher 的核心洞察有两个:① 插入 # 统一奇偶回文;② 利用已知回文的对称性,跳过冗余的字符比较。以 s = "babad" 为例逐步追踪。
第一步:预处理——为什么插入 #?
1 | 原串下标: 0 1 2 3 4 |
插入 # 后,所有回文的中心都落在某个确定的数组下标上——原奇数回文 "bab" 的中心是字符 b(在预处理串中也是 b,下标 5),原偶数回文 "abba" 的中心落在两个 b 之间的 # 上。一个统一的下标模型处理两种情况。
定义 p[i] = 以预处理串下标 i 为中心的最长回文半径(不含中心自身)。预处理串中的回文半径 恰好等于 原串中对应回文的长度——这是 Manacher 最优雅的性质。
第二步:核心机制——对称性如何"免费"获取半径?
Manacher 维护两个核心变量:
center:当前已知右边界最远的回文中心right:该回文的右边界,即center + p[center]
对于当前位置 i,设其关于 center 的镜像点 mirror = 2 × center - i:
1 | mirror center i right |
根据 i 与 right 的关系以及 p[mirror] 的大小,分为三种情况:
| 情况 | 条件 | p[i] 初始值 | 说明 |
|---|---|---|---|
一:i 在已知回文外 |
i >= right |
0 |
无对称信息可用,从零开始老老实实扩展 |
| 二:镜像回文完全在边界内 | i < right 且 p[mirror] < right - i |
p[mirror] |
由于左右完全对称,p[i] 直接等于 p[mirror],零次扩展 |
| 三:镜像回文触及或超出边界 | i < right 且 p[mirror] >= right - i |
right - i |
p[i] 至少为 right - i,从该值起步继续扩展 |
这就是 Manacher O(n) 的关键:情况二完全免除了扩展操作,情况三从较大的初始值起步——每个字符平均只扩展常数次。
第三步:完整推演 s = "babad"
预处理串 t = "#b#a#b#a#d#"(长度 11),初始 center=0, right=0, p 全为 0:
1 | i=0, t[0]='#' |
第四步:映射回原串
max_center=3, max_len=3:
1 | t = # b # a # b # a # d # |
映射公式:start = (max_center - max_len) // 2,长度 = max_len。
1 | start = (3 - 3) // 2 = 0 |
第五步:对比——Manacher 省了什么?
回顾整个推演,对比中心扩展法(每个中心都从头扩展):
| i | 中心扩展法比较次数 | Manacher 比较次数 | 节省原因 |
|---|---|---|---|
| 0 | 1 | 1 | 无历史信息 |
| 1 | 2 | 2 | 无历史信息 |
| 2 | 2 | 2 | 无历史信息 |
| 3 | 4 | 4 | 无历史信息(首个大回文) |
| 4 | 2 | 0 | 情况二:镜像 p[2]=0 直接复用 |
| 5 | 4 | 3 | 情况三:从半径 1 起步(省了首轮) |
| 6 | 2 | 0 | 情况二:镜像 p[4]=0 直接复用 |
| 7 | 3 | 1 | 情况三:从半径 1 起步(省了两轮) |
| 8 | 2 | 2 | 回文缩小,无历史可用 |
| 9 | 2 | 2 | 无历史信息 |
| 10 | 1 | 1 | 无历史信息 |
| 总计 | 25 | 18 | 节省约 28% |
随着 n 增大且回文密度增加,节省比例会急剧上升——对全相同字符 "aaaa…a",Manacher 的比较次数为 O(n),而中心扩展法为 O(n²)。
这些方法具体怎么运用?
方法一:中心扩展法(推荐)
数据结构:无需额外数据结构,仅用两个指针 left 和 right 向两侧扩展。
核心逻辑——内部的 expand 函数:
- 接收中心参数
(left, right),分别代表奇数中心的同一位置(i, i)或偶数中心的相邻位置(i, i+1) - 向外扩展:当
left >= 0且right < n且s[left] == s[right]时,left -= 1,right += 1 - 循环结束时
left和right停在回文区域之外一步,因此合法回文子串为s[left+1 : right]
边界情况处理:
| 场景 | 输入示例 | 行为 | 结果 |
|---|---|---|---|
| 单字符串 | "a" |
奇数中心扩展一次即越界 | "a" |
| 全相同字符 | "aaaa" |
多个中心扩展出不同长度的回文,取最长 | "aaaa" |
| 无回文(长度 > 1) | "ab" |
所有中心扩展均只有单字符回文 | "a" 或 "b" |
| 最长回文在末尾 | "abcba" |
中心 (2,2)="c" 扩展出 "abcba" |
"abcba" |
| 偶数回文比奇数长 | "cbbd" |
偶数中心 (1,2) 发现 "bb",奇数中心最多 "b" |
"bb" |
expand 返回切片的解释:
1 | def expand(left, right): |
最后一次匹配成功时 left 和 right 各向外多走了一步(不匹配或越界),所以回文区域是 (left+1, right-1) 闭区间,Python 切片 s[left+1:right] 正好对应左闭右开。
方法二:Manacher 算法
预处理:插入分隔符 #
1 | # "babad" → "#b#a#b#a#d#" |
为什么首尾也要加 #?确保预处理串长度恒为奇数(2n+1),从而每个回文的中心都恰好落在一个字符上。同时,首尾的 # 充当哨兵,扩展时自然越界,无需额外边界判断。
核心逻辑:三步循环
1 | 对于预处理串的每个位置 i: |
关键细节解析
为什么 p[i] = min(right - i, p[mirror])?
right - i 是 i 到已知回文右边界的距离——我们对边界外的字符一无所知,所以初始半径不能超过这个距离。p[mirror] 是镜像位置的回文半径。取两者最小值:
- 若
p[mirror] < right - i:镜像回文完全被已知回文包裹,由对称性直接得p[i] = p[mirror] - 若
p[mirror] >= right - i:镜像回文可能延伸到已知回文之外,但i侧对应位置的字符尚未验证,保守取right - i
为什么扩展循环用 t[i - p[i] - 1] 而不是 t[i - p[i]]?
p[i] 是已知半径,下一对待比较的字符在 p[i] + 1 的位置。例如 p[i]=2 时已经确认 t[i-2] 到 t[i+2] 是回文,现在检查 t[i-3] 和 t[i+3]。
为什么映射公式是 (max_center - max_len) // 2?
预处理串长度为 2n+1。在预处理串中,回文区域为 [max_center - max_len, max_center + max_len]。这个区间内的原串字符对应于 t 中下标为奇数的位置 1, 3, 5, …。区间内有 max_len 个原串字符(因为 # 和原字符交替,且半径恰好等于原串回文长度),起始原串下标为 (max_center - max_len) // 2。这个公式在奇偶回文上都正确,无需分情况讨论。
边界情况
| 场景 | 输入 | Manacher 行为 | 结果 |
|---|---|---|---|
| 空串 | "" |
直接返回 "" |
"" |
| 单字符 | "a" |
预处理 "#a#",p[1]=1,映射 s[0:1] |
"a" |
| 全相同 | "aaaa" |
p 数组持续扩大,中心在一处即可覆盖全串 | "aaaa" |
| 全不同 | "abcd" |
每个位置 p[i] 仅为 0 或 1,无 O(n) 加速 | "a" |
方法三:动态规划
数据结构:dp[i][j] 表示 s[i:j+1] 是否为回文串。dp 是一个 n × n 的布尔矩阵。
状态转移:
dp[i][j] = True当且仅当s[i] == s[j]且(j - i < 3或dp[i+1][j-1] == True)j - i < 3意味着子串长度为 1、2 或 3——长度 ≤ 3 时只需两端字符相等
核心逻辑:
- 初始化:所有
dp[i][i] = True(单字符回文) - 按子串长度从小到大递推:先处理长度为 2、再 3、…、n
- 每次发现更长的回文子串时更新答案
缺点:需要 O(n²) 空间存储整个 DP 表,且必须填充 n²/2 个格子,常数因子显著大于中心扩展法。对于 n = 1000,DP 表约占 1MB 内存,虽可接受但无必要。
复杂度
| 方法 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 中心扩展法 | O(n²) | O(1) | 2n-1 个中心,每个中心最坏扩展 n/2 步 |
| Manacher | O(n) | O(n) | 内层 while 的扩展次数均摊 O(1) 每字符 |
| 动态规划 | O(n²) | O(n²) | 填满半个 n×n 矩阵,无法优化空间至 O(1) |
中心扩展法复杂度详析:
- 最坏情况(如全相同字符
"aaaa…a"):每个中心的扩展都进行到边界为止,第 k 个中心扩展约 min(k, n-k) 步,总比较次数 ≈ n²/4 - 最好情况(如
"abcd…z"无回文):每个中心仅比较一次即失败,总比较次数 ≈ 2n-1 = O(n) - 平均情况:在随机英文文本中,大多数中心在第一轮就因字符不匹配而停止,实际运行接近 O(n)
Manacher 复杂度详析——为什么内层 while 不会导致 O(n²)?
内层 while 每成功扩展一次,right 必然向右移动一位(因为 i + p[i] 增加了)。而 right 最多从 0 走到 n-1,总共移动 O(n) 次。失败扩展(不匹配)每轮最多发生一次。因此总比较次数严格 ≤ 2n。
换句话说:每个字符最多被成功匹配一次(推进 right),最多被失败匹配一次(停止扩展),从而均摊 O(1)。
总结与最佳选择
最快算法:
- n ≤ 10³(LeetCode 约束):中心扩展法实际最快(< 5ms),常数因子极小。Manacher 的预处理和复杂分支逻辑在此规模下反而拖累性能。
- n ≥ 10⁵(大数据/竞赛场景):Manacher 是唯一可行的线性解。中心扩展法的 O(n²) 在此规模下完全不可用(10¹⁰ 次操作 → 数秒甚至数十秒)。
工程最优选择:中心扩展法(方法一),理由:
- 空间 O(1):不需要任何额外数组,原地操作
- 代码极简:核心逻辑仅 7 行,5 分钟内写出无 bug
- LeetCode 实际最快:n ≤ 1000 下跑完所有测试用例在 5ms 以内
- 可扩展性:稍加修改即可同时返回所有最长回文子串或统计回文子串总数
各方法的使用场景:
| 场景 | 推荐方法 | 理由 |
|---|---|---|
| LeetCode / 面试 | 中心扩展法 | 最短代码、最直观思路、空间 O(1) 是加分项 |
| 大数据量(n ≥ 10⁵) | Manacher | O(n) 是唯一选择,O(n²) 在这个量级不可接受 |
| 学习回文递推 | 动态规划 | 为回文分割、回文子序列等变种题打基础 |
| 竞赛(n 不明确) | Manacher | 永远不超时,不用估数据规模 |
一句话:面试写中心扩展(稳),竞赛用 Manacher(快),DP 用来理解回文递推(学)。
Code
方法一:中心扩展法(推荐)
1 | class Solution: |
方法二:Manacher 算法
1 | class Solution: |
方法三:动态规划(仅供对比)
1 | class Solution: |

