文章目录
最长回文子串:从暴力破解到动态规划再到中心扩展
回文子串是字符串问题中的经典题型,其核心是找到字符串中 “正读和反读完全一致” 的最长子串(如 “babad” 中的 “bab” 或 “aba”)。本文将从最直观的暴力算法入手,逐步分析其缺陷并优化,最终推导到高效的 “中心扩展法”,带大家理解每一步优化的逻辑和价值。
一、问题定义与核心难点
首先明确问题边界:
- 回文子串:连续的字符序列,正读与反读相同(如 “bb”“aba”,区别于 “abcba” 这种回文串,子串要求连续)。
- 最长:需在所有回文子串中找到长度最大的,若有多个长度相同的,返回任意一个即可。
核心难点:
- 如何高效遍历所有可能的子串,避免遗漏;
- 如何快速判断一个子串是否为回文,减少冗余计算;
- 如何优化时间复杂度,避免暴力算法的低效问题。
二、初代解法:暴力破解(直观但低效)
暴力破解是最直接的思路:枚举所有可能的子串,判断是否为回文,记录最长的回文子串。
1. 思路拆解
- 步骤 1:枚举所有子串的起始索引
i和结束索引j(0 ≤ i ≤ j < n,n为字符串长度),共n*(n+1)/2个可能的子串; - 步骤 2:对每个子串
s[i..j],判断是否为回文(双指针从两端向中间比对); - 步骤 3:记录长度最大的回文子串,若长度相同则保留任意一个。
2. 代码实现
#include <string>
using namespace std;
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n <= 1) return s;
int maxLen = 1; // 最长回文子串长度(至少为1)
int start = 0; // 最长回文子串的起始索引
// 枚举所有子串的起始和结束位置
for (int i = 0; i < n; ++i) {
for (int j = i; j < n; ++j) {
// 判断子串s[i..j]是否为回文
if (isPalindrome(s, i, j)) {
int currLen = j - i + 1;
// 更新最长回文子串
if (currLen > maxLen) {
maxLen = currLen;
start = i;
}
}
}
}
return s.substr(start, maxLen);
}
private:
// 辅助函数:判断s[l..r]是否为回文
bool isPalindrome(const string& s, int l, int r) {
while (l < r) {
if (s[l] != s[r]) {
return false;
}
l++;
r--;
}
return true;
}
};
3. 性能分析
- 时间复杂度:
O(n³)。枚举子串需要O(n²)(n为字符串长度),每个子串的回文判断需要O(n)(最坏情况子串长度为n),总复杂度为O(n² * n) = O(n³)。 - 空间复杂度:
O(1)。仅用几个变量记录状态,无额外空间开销。
4. 缺陷
当字符串长度n=1000时(题目上限),n³=10⁹次操作,远超计算机每秒约10⁸次的处理能力,会直接超时 —— 暴力算法在题目约束下完全不可用,必须优化。
三、第一次优化:减少回文判断的冗余(仍不满足)
暴力算法的核心冗余在于 “回文判断”:对每个子串s[i..j],判断时需要重新比对所有字符,而实际上s[i..j]的回文性与s[i+1..j-1]相关 —— 若s[i] == s[j]且s[i+1..j-1]是回文,则s[i..j]也是回文。
基于此,我们可以用动态规划(DP) 预存子串的回文状态,避免重复判断。
1. 动态规划思路
- 状态定义:
dp[i][j]表示 “子串s[i..j]是否为回文”(true是,false否)。 - 状态转移:
- 当
i == j(子串长度为 1):dp[i][j] = true(单个字符一定是回文); - 当
j = i+1(子串长度为 2):dp[i][j] = (s[i] == s[j])(两个字符相同则是回文); - 当
j > i+1(子串长度≥3):dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1](两端字符相同且中间子串是回文)。
- 当
- 遍历顺序:需先计算短子串的
dp值(因为长串依赖短串),因此按 “子串长度” 从小到大遍历(长度 1→长度 2→…→长度 n)。
2. 代码实现
#include <string>
#include <vector>
using namespace std;
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n <= 1) return s;
// dp[i][j]:s[i..j]是否为回文
vector<vector<bool>> dp(n, vector<bool>(n, false));
int maxLen = 1;
int start = 0;
// 1. 初始化长度为1的子串(i==j)
for (int i = 0; i < n; ++i) {
dp[i][i] = true;
}
// 2. 初始化长度为2的子串(j=i+1)
for (int i = 0; i < n-1; ++i) {
if (s[i] == s[i+1]) {
dp[i][i+1] = true;
maxLen = 2;
start = i;
}
}
// 3. 处理长度≥3的子串(len从3到n)
for (int len = 3; len <= n; ++len) {
// 枚举起始索引i,结束索引j = i + len - 1
for (int i = 0; i <= n - len; ++i) {
int j = i + len - 1;
// 状态转移:两端相同且中间子串是回文
if (s[i] == s[j] && dp[i+1][j-1]) {
dp[i][j] = true;
// 更新最长回文子串
if (len > maxLen) {
maxLen = len;
start = i;
}
}
}
}
return s.substr(start, maxLen);
}
};
3. 性能分析
- 时间复杂度:
O(n²)。枚举子串长度O(n),每个长度枚举起始索引O(n),状态转移O(1)(直接查dp表),总复杂度O(n * n) = O(n²)。 - 空间复杂度:
O(n²)。需要n×n的二维dp数组存储状态。
4. 进步与缺陷
- 进步:时间复杂度从
O(n³)降至O(n²),n=1000时n²=10⁶次操作,完全可通过题目测试。 - 缺陷:空间复杂度
O(n²),n=1000时需要10⁶个bool值(约 1MB,实际可接受),但仍有优化空间 —— 能否在O(1)空间内实现O(n²)时间复杂度?
四、最终优化:中心扩展法(时间O(n²),空间O(1))
回文子串的本质是 “对称”—— 以某一 “中心” 向两端扩展,若两端字符相同则继续扩展,直到字符不同或越界。这种 “中心扩展” 的思路,无需额外存储状态,可将空间复杂度降至O(1)。
1. 核心洞察
回文子串的中心有两种情况:
- 奇数长度回文:中心是单个字符(如 “aba” 的中心是 “b”);
- 偶数长度回文:中心是两个相邻字符(如 “bb” 的中心是 “b” 和 “b” 之间)。
因此,我们只需遍历每个可能的 “中心”,向两端扩展并记录最长回文子串即可。
2. 思路拆解
- 步骤 1:遍历字符串的每个位置
i,将其作为 “奇数长度回文的中心”,向两端扩展(l=i, r=i); - 步骤 2:遍历字符串的每个位置
i,将i和i+1作为 “偶数长度回文的中心”,向两端扩展(l=i, r=i+1); - 步骤 3:对每次扩展的回文子串,记录其长度和内容,更新最长回文子串。
3. 代码实现(对应题目中的最终写法)
#include <string>
#include <utility> // 用于pair
using namespace std;
class Solution {
public:
string longestPalindrome(string s) {
int len = s.size();
if (len == 1) return s;
// pair.first:最长回文子串长度;pair.second:最长回文子串内容
pair<int, string> maxPair;
maxPair.first = 0;
maxPair.second = "";
// 遍历每个可能的中心,分别处理奇数和偶数长度回文
for (int i = 0; i < len; ++i) {
// 1. 处理奇数长度回文(中心为i)
int l = i, r = i;
// 向两端扩展:l >=0、r < len且字符相同
while (l > -1 && r < len && s[l] == s[r]) {
l--;
r++;
}
// 扩展结束后,回文子串的实际范围是[l+1..r-1](因最后一次扩展越界/不匹配)
l++;
r--;
// 更新最长回文子串
if (r - l + 1 > maxPair.first) {
maxPair.first = r - l + 1;
maxPair.second = s.substr(l, r - l + 1);
}
// 2. 处理偶数长度回文(中心为i和i+1)
if (i + 1 < len && s[i] == s[i + 1]) {
l = i;
r = i + 1;
// 向两端扩展
while (l > -1 && r < len && s[l] == s[r]) {
l--;
r++;
}
// 修正范围
l++;
r--;
// 更新最长回文子串
if (r - l + 1 > maxPair.first) {
maxPair.first = r - l + 1;
maxPair.second = s.substr(l, r - l + 1);
}
}
}
return maxPair.second;
}
};
4. 关键细节解释
- 扩展后的范围修正:当
while循环退出时,l可能小于 0、r可能大于等于len,或s[l] != s[r],因此实际回文子串的范围是[l+1..r-1](需将l加 1、r减 1)。 - 偶数长度的前置判断:
if (i + 1 < len && s[i] == s[i + 1])避免越界(i+1不超过字符串长度),且只有相邻字符相同时才需要扩展(否则不可能是偶数长度回文)。
5. 性能分析
- 时间复杂度:
O(n²)。每个中心最多扩展O(n)次(最坏情况字符串全为相同字符,如 “aaaaa”),共有2n-1个中心(n个奇数中心 +n-1个偶数中心),总复杂度O(n * n) = O(n²)。 - 空间复杂度:
O(1)。仅用l、r、maxPair等变量记录状态,无额外空间开销。
五、三种算法对比与选择建议
| 算法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 暴力破解 | O(n³) | O(1) | 逻辑直观,易于实现 | 效率极低,超时风险高 | 仅用于理解问题,不实际使用 |
| 动态规划 | O(n²) | O(n²) | 状态清晰,易于调试 | 空间开销较大 | 字符串长度较小(n≤500),追求代码清晰 |
| 中心扩展法 | O(n²) | O(1) | 时空效率平衡,无额外空间 | 需处理两种中心情况 | 所有场景(尤其是 n=1000 的题目上限) |
选择建议
- 若为学习理解:先掌握暴力破解,再推导动态规划,最后理解中心扩展法,逐步体会优化逻辑;
- 若为实际解题:优先选择中心扩展法 —— 时空效率最优,代码简洁且无超时风险;
- 若为面试回答:需先说明暴力算法的缺陷,再讲解动态规划的优化思路,最后提出中心扩展法并解释其优势(体现思维的完整性)。
六、示例验证(以输入s="babad"为例)
- 奇数中心扩展:
i=0(中心 “b”):扩展后回文 “b”(长度 1);i=1(中心 “a”):扩展到l=0、r=2(“bab”,长度 3),更新最长子串为 “bab”;i=2(中心 “b”):扩展到l=0、r=4(“babad” 不匹配,实际 “aba”,长度 3),与当前最长长度相同,可保留 “bab”;i=3(中心 “a”):扩展后回文 “a”(长度 1);i=4(中心 “d”):扩展后回文 “d”(长度 1)。
- 偶数中心扩展:
i=0(中心 “b” 和 “a”):字符不同,不扩展;i=1(中心 “a” 和 “b”):字符不同,不扩展;i=2(中心 “b” 和 “a”):字符不同,不扩展;i=3(中心 “a” 和 “d”):字符不同,不扩展。
最终最长回文子串为 “bab”(或 “aba”),与示例输出一致。
七、总结
解决 “最长回文子串” 的优化路径,本质是减少冗余计算:
- 暴力算法的冗余在于 “重复判断回文”,动态规划通过预存状态消除了这一冗余;
- 动态规划的冗余在于 “额外空间存储状态”,中心扩展法通过 “利用回文对称性” 消除了空间开销。
中心扩展法作为最终优化方案,既保持了O(n²)的时间复杂度,又实现了O(1)的空间复杂度,是平衡效率与简洁性的最优选择,也是面试和解题中的首选思路。

1015

被折叠的 条评论
为什么被折叠?



