📌 题目链接:5. 最长回文子串 - 力扣(LeetCode)
🔍 难度:中等 | 🏷️ 标签:字符串、动态规划、中心扩展、Manacher 算法
⏱️ 目标时间复杂度:O(n²)(常规解法),O(n)(Manacher)
💾 空间复杂度:O(n²)(DP),O(1)(中心扩展),O(n)(Manacher)
🧩 题目分析
给定一个字符串 s,要求找出其中最长的回文子串。回文串是指正读和反读都相同的字符串,如 "aba"、"bb"。
- 输入范围:
1 <= s.length <= 1000 - 字符集:仅包含数字和英文字母
- 输出要求:返回任意一个最长回文子串即可(存在多个时)
💡 面试高频点:本题是考察字符串处理 + 回文性质 + 多种算法思维的经典题,常出现在大厂一面/二面。面试官通常希望你至少掌握两种解法,并能分析其优劣。
🧠 核心算法及代码讲解
本题有三种主流解法,按面试推荐程度排序:
1️⃣ 动态规划(Dynamic Programming)✅【最稳妥】
✅ 核心思想:
利用回文串的子结构性质:
若
s[i...j]是回文,且s[i] == s[j],则s[i+1...j-1]也必须是回文。
定义状态:
dp[i][j] = true表示子串s[i..j]是回文串
状态转移方程:
dp[i][j] = (s[i] == s[j]) && (j - i < 3 || dp[i+1][j-1])
📌 为什么
j - i < 3就直接为 true?
- 当
j - i == 0→ 单字符,必回文j - i == 1→ 两字符,相等即回文j - i == 2→ 三字符,首尾相等即回文(中间一个无所谓)
所以j - i < 3等价于长度 ≤ 3,无需依赖更小子问题。
✅ 代码实现(带详细行注释):
// dp[i][j] 表示 s[i..j] 是否为回文串
vector<vector<int>> dp(n, vector<int>(n));
// 初始化:所有单字符都是回文
for (int i = 0; i < n; i++) {
dp[i][i] = true;
}
// 枚举子串长度 L 从 2 到 n(从小到大保证子问题已计算)
for (int L = 2; L <= n; L++) {
for (int i = 0; i < n; i++) {
int j = i + L - 1; // 右边界
if (j >= n) break; // 越界剪枝
if (s[i] != s[j]) {
dp[i][j] = false;
} else {
// 长度 ≤ 3 或内部是回文
if (j - i < 3) {
dp[i][j] = true;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
// 更新最长回文子串
if (dp[i][j] && L > maxLen) {
maxLen = L;
begin = i;
}
}
}
⚠️ 注意循环顺序:必须先枚举长度,再枚举起点。若先枚举
i, j,可能dp[i+1][j-1]还未计算!
2️⃣ 中心扩展法(Expand Around Center)✅【空间最优】
✅ 核心思想:
回文串一定围绕某个“中心”对称。
- 奇数长度:中心是一个字符(如
"aba",中心是'b') - 偶数长度:中心在两个字符之间(如
"abba",中心在'b'和'b'之间)
因此,我们枚举所有可能的中心(共 2n - 1 个),对每个中心向两边扩展,直到不匹配为止。
✅ 优势:
- 空间复杂度 O(1)
- 代码简洁,逻辑清晰
- 面试时最容易手撕
✅ 代码实现:
// 辅助函数:从 left, right 向外扩展,返回最长回文区间 [l, r]
pair<int, int> expandAroundCenter(const string& s, int left, int right) {
while (left >= 0 && right < s.size() && s[left] == s[right]) {
--left;
++right;
}
return {left + 1, right - 1}; // 回退一步
}
// 主函数:枚举所有中心
for (int i = 0; i < n; ++i) {
auto [l1, r1] = expandAroundCenter(s, i, i); // 奇数中心
auto [l2, r2] = expandAroundCenter(s, i, i + 1); // 偶数中心
// 更新全局最长
}
💡 面试技巧:可强调“中心数量是
2n-1而非n”,体现细节把控。
3️⃣ Manacher 算法(马拉车)🚀【线性时间,高阶技巧】
✅ 核心思想:
- 统一奇偶:通过插入
#将所有回文转为奇数长度(如"abba"→"#a#b#b#a#") - 利用对称性:维护当前最右回文边界
right和其中心j,利用镜像位置加速计算
✅ 关键变量:
arm_len[i]:位置i的“臂长”(即回文半径)right:当前所有回文能到达的最右边界j:对应right的中心
✅ 状态转移:
if (right >= i) {
int i_sym = 2 * j - i; // i 关于 j 的对称点
int min_arm = min(arm_len[i_sym], right - i);
cur_arm_len = expand(s, i - min_arm, i + min_arm);
} else {
cur_arm_len = expand(s, i, i);
}
⚠️ 面试建议:除非应聘算法岗或明确要求 O(n),否则不必强求手写 Manacher。但需知道其存在及核心思想。
🧭 解题思路(分步骤)
🔹 方法一:动态规划(推荐掌握)
- 特判:若字符串长度 < 2,直接返回
- 初始化 DP 表:所有
dp[i][i] = true - 按子串长度递增枚举(L = 2 → n)
- 计算右边界
j = i + L - 1 - 若越界则
break - 若
s[i] != s[j]→dp[i][j] = false - 否则:
- 若长度 ≤ 3 →
true - 否则 →
dp[i][j] = dp[i+1][j-1]
- 若长度 ≤ 3 →
- 若当前是回文且更长 → 更新答案
- 计算右边界
- 返回结果
🔹 方法二:中心扩展(推荐掌握)
- 初始化:
start = 0, end = 0 - 遍历每个可能的中心(0 到 n-1):
- 扩展奇数中心
(i, i) - 扩展偶数中心
(i, i+1) - 比较两者长度,更新全局最长
- 扩展奇数中心
- 返回子串
📊 算法分析
| 方法 | 时间复杂度 | 空间复杂度 | 面试推荐度 | 适用场景 |
|---|---|---|---|---|
| 动态规划 | O(n²) | O(n²) | ⭐⭐⭐⭐ | 要求输出所有回文子串、或需记录结构 |
| 中心扩展 | O(n²) | O(1) | ⭐⭐⭐⭐⭐ | 最常用,空间敏感场景 |
| Manacher | O(n) | O(n) | ⭐⭐ | 算法岗、追求极致性能 |
💬 面试官可能追问:
- “能否优化 DP 的空间?” → 答:可以滚动数组,但因依赖
i+1,需倒序遍历i,且仍需 O(n) 空间。- “中心扩展最坏情况是不是 O(n³)?” → 答:不是!每个中心最多扩展 O(n) 次,共 O(n) 个中心,总 O(n²)。
💻 代码
C++
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n < 2) {
return s;
}
int maxLen = 1;
int begin = 0;
// dp[i][j] 表示 s[i..j] 是否是回文串
vector<vector<int>> dp(n, vector<int>(n));
// 初始化:所有长度为 1 的子串都是回文串
for (int i = 0; i < n; i++) {
dp[i][i] = true;
}
// 递推开始
// 先枚举子串长度
for (int L = 2; L <= n; L++) {
// 枚举左边界,左边界的上限设置可以宽松一些
for (int i = 0; i < n; i++) {
// 由 L 和 i 可以确定右边界,即 j - i + 1 = L 得
int j = L + i - 1;
// 如果右边界越界,就可以退出当前循环
if (j >= n) {
break;
}
if (s[i] != s[j]) {
dp[i][j] = false;
} else {
if (j - i < 3) {
dp[i][j] = true;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
// 只要 dp[i][j] == true 成立,就表示子串 s[i..j] 是回文,此时记录回文长度和起始位置
if (dp[i][j] && j - i + 1 > maxLen) {
maxLen = j - i + 1;
begin = i;
}
}
}
return s.substr(begin, maxLen);
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
Solution sol;
// 测试用例
cout << sol.longestPalindrome("babad") << "\n"; // "bab" 或 "aba"
cout << sol.longestPalindrome("cbbd") << "\n"; // "bb"
cout << sol.longestPalindrome("a") << "\n"; // "a"
cout << sol.longestPalindrome("ac") << "\n"; // "a" 或 "c"
return 0;
}
JavaScript
/**
* @param {string} s
* @return {string}
*/
var longestPalindrome = function(s) {
const n = s.length;
if (n < 2) return s;
let maxLen = 1;
let begin = 0;
const dp = Array.from({ length: n }, () => Array(n).fill(false));
// 初始化单字符
for (let i = 0; i < n; i++) {
dp[i][i] = true;
}
// 枚举长度 L 从 2 到 n
for (let L = 2; L <= n; L++) {
for (let i = 0; i < n; i++) {
let j = i + L - 1;
if (j >= n) break;
if (s[i] !== s[j]) {
dp[i][j] = false;
} else {
if (j - i < 3) {
dp[i][j] = true;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
if (dp[i][j] && L > maxLen) {
maxLen = L;
begin = i;
}
}
}
return s.substring(begin, begin + maxLen);
};
// 测试
console.log(longestPalindrome("babad")); // "bab"
console.log(longestPalindrome("cbbd")); // "bb"
console.log(longestPalindrome("a")); // "a"
console.log(longestPalindrome("ac")); // "a"
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!