文章目录
回文子串是字符串处理中的经典问题,本文将通过动态规划、中心扩展和马拉车算法三种方法,详细解析如何高效求解最长回文子串,并对比各方法的优劣。
题目描述

方法一:动态规划
我们定义一个二维布尔数组 dp,其中:
dp[i][j] = true表示子串s[i..j]是回文串。dp[i][j] = false表示子串s[i..j]不是回文串。
状态转移方程:
- 基础情况 :
- 单个字符总是回文串:
dp[i][i] = true。 - 两个相同字符是回文串:若
s[i] == s[j]且j - i == 1,则dp[i][j] = true。
- 单个字符总是回文串:
- 扩展情况 :
- 如果
s[i] == s[j]且子串s[i+1..j-1]是回文串(即dp[i+1][j-1] = true),那么s[i..j]也是回文串。 - 如果
s[i] != s[j],则 s[i...j] 不是回文串。
- 如果
状态转移公式:
cpp
dp[i][j] = (s[i] == s[j]) &&
(j - i <= 1 || dp[i+1][j-1])
算法步骤
- 初始化 :
- 若字符串长度小于 2,直接返回字符串本身。
- 初始化最长回文子串为第一个字符(
ret = s.substr(0,1))。 - 创建二维数组
dp,大小为n x n,初始值为false。
- 填充 DP 表 :
- 外层循环遍历子串的结束位置
j(从 1 到 n-1)。 - 内层循环遍历子串的起始位置
i(从 0 到 j)。 - 根据状态转移方程计算
dp[i][j]。
- 外层循环遍历子串的结束位置
- 更新最长回文子串 :
- 若
dp[i][j] = true且当前子串长度(j - i + 1)大于记录的最长子串长度,则更新最长回文子串。
- 若
- 返回结果 :
- 循环结束后返回记录的最长回文子串
ret。
- 循环结束后返回记录的最长回文子串
代码实现:
cpp
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n < 2) return s;
string ret = s.substr(0, 1); // 初始化最长回文子串为第一个字符
vector<vector<bool>> dp(n, vector<bool>(n, false));
for (int j = 1; j < n; ++j) { // j 是结束位置
for (int i = 0; i <= j; ++i) { // i 是起始位置
if (s[i] == s[j]) {
// 子串长度 ≤ 2 或内部子串是回文串
if (j - i <= 1 || dp[i + 1][j - 1]) {
dp[i][j] = true;
// 更新最长回文子串
if (j - i + 1 > ret.size()) {
ret = s.substr(i, j - i + 1);
}
}
}
}
}
return ret;
}
};
复杂度分析
- 时间复杂度 :
O(n²),其中n是字符串长度。需要两层循环遍历所有子串。 - 空间复杂度 :
O(n²),用于存储动态规划表dp。
使用滚动数组优化空间
原始动态规划解法使用 O(n²) 空间存储二维数组 dp,但观察状态转移方程 dp[i][j] = dp[i+1][j-1] 可知:计算当前行 i 的状态时,仅依赖于下一行 i+1 的状态 。因此,可通过 滚动数组 将空间复杂度优化至 O(n)。
优化原理
- 使用一维数组
dp[j]表示子串s[i..j]是否为回文。 - 遍历顺序改为 从下到上(i 从 n-1 到 0) 和 从左到右(j 从 i 到 n-1) ,确保计算
dp[j]时,dp[j-1]已更新为当前行的状态,而dp[j]仍保留上一行(i+1)的状态。 - 使用额外变量
prev记录dp[i+1][j-1]的值(即上一轮的dp[j-1])。
代码实现
cpp
class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n < 2) return s;
string ret = s.substr(0, 1); // 初始最长回文为第一个字符
vector<bool> dp(n, false); // 滚动数组,dp[j] 表示 s[i..j] 是否为回文
// 从下到上遍历行(i 从 n-1 到 0)
for (int i = n - 1; i >= 0; --i) {
bool prev = false; // 记录 dp[i+1][j-1] 的值(即上一轮的 dp[j-1])
// 从左到右遍历列(j 从 i 到 n-1)
for (int j = i; j < n; ++j) {
bool curr = dp[j]; // 暂存当前 dp[j](即 dp[i+1][j])
if (i == j) {
dp[j] = true; // 单字符是回文
} else if (j == i + 1) {
dp[j] = (s[i] == s[j]); // 双字符需相等
} else {
dp[j] = (s[i] == s[j]) && prev; // 依赖 dp[i+1][j-1](即 prev)
}
prev = curr; // 更新 prev 为下一轮的 dp[i+1][j-1]
// 更新最长回文串
if (dp[j] && (j - i + 1) > ret.size()) {
ret = s.substr(i, j - i + 1);
}
}
}
return ret;
}
};
方法二:中心扩展法
在解决最长回文子串问题时,中心扩展法通常比动态规划更简单直观。
核心思想
利用回文串的对称特性,从每个可能的中心点(单个字符或两个字符之间)向两侧扩展,寻找最长回文子串。
算法步骤
- 初始化 :
- 设置起始位置
start = 0和最大长度maxLen = 1
- 设置起始位置
- 遍历中心点 :
- 对每个字符
s[i]作为奇数长度中心 - 对每对相邻字符
s[i], s[i+1]作为偶数长度中心
- 对每个字符
- 扩展检查 :
- 从中心向左右扩展,直到字符不匹配或到达边界
- 更新结果 :
- 当发现更长回文子串时更新
start和maxLen
- 当发现更长回文子串时更新
代码实现
cpp
class Solution {
public:
string longestPalindrome(string s) {
if (s.empty()) return "";
int start = 0; // 最长回文串的起始位置
int maxLen = 1; // 最长回文串的长度
// 中心扩展函数:从中心扩展,返回当前中心的最长回文长度
auto expandAroundCenter = [&](int left, int right) {
while (left >= 0 && right < s.size() && s[left] == s[right]) {
--left;
++right;
}
return right - left - 1; // 返回长度,因为这里的 left 和 right 越界,所以是 -1
};
// 遍历每个可能的中心位置
for (int i = 0; i < s.size(); ++i) {
// 1. 以单个字符为中心的回文串
int len1 = expandAroundCenter(i, i);
// 2. 以两个字符为中心的回文串
int len2 = expandAroundCenter(i, i + 1);
// 取最大值
int Len = max(len1, len2);
// 更新起始位置和长度
if (Len > maxLen) {
maxLen = Len;
start = i - (Len - 1) / 2;
}
}
return s.substr(start, maxLen);
}
};
复杂度分析
- 时间复杂度 :
O (n²),每个中心最多扩展O (n)次,共O (n)个中心。 - 空间复杂度 :
O (1),仅需常数级空间。
方法三:马拉车算法
马拉车算法通过预处理字符串和利用回文串的对称性,将时间复杂度优化至线性,适用于大规模字符串。
算法思路
- 预处理 :在字符串每个字符间插入特殊符号(如 '#'),将奇偶长度的回文串统一为奇数长度(如 "abc" →
"^#a#b#c#$",^和$为边界符)。 - 核心数组 :
p[i]表示以i为中心的最长回文半径(包含i本身)。 - 对称优化 :利用已知回文串的右边界
right和中心center,通过对称点mirror快速初始化p[i],减少重复扩展。
代码实现
cpp
class Solution {
public:
string longestPalindrome(string s) {
if (s.empty()) return "";
// 预处理:插入特殊字符统一奇偶长度
string t = "#";
for (char c : s) {
t += c;
t += '#';
}
int n = t.size();
vector<int> p(n, 0); // 回文半径数组
int center = 0, right = 0; // 当前最右回文的中心和右边界
int maxLen = 0, maxCenter = 0; // 最长回文的半径和中心
for (int i = 0; i < n; ++i) {
// 利用对称性初始化 p[i]
if (i < right) {
int mirror = 2 * center - i; // i 关于 center 的对称点
p[i] = min(right - i, p[mirror]);
}
// 中心扩展
while (i - p[i] - 1 >= 0 && i + p[i] + 1 < n &&
t[i - p[i] - 1] == t[i + p[i] + 1]) {
p[i]++;
}
// 更新最右回文边界
if (i + p[i] > right) {
center = i;
right = i + p[i];
}
// 更新最长回文记录
if (p[i] > maxLen) {
maxLen = p[i];
maxCenter = i;
}
}
// 转换回原字符串的起始位置
int start = (maxCenter - maxLen) / 2;
return s.substr(start, maxLen);
}
};
复杂度分析
- 时间复杂度 :
O (n),每个字符最多被访问一次。 - 空间复杂度 :
O (n),用于存储预处理后的字符串和p数组。
三种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 动态规划 | O(n²) |
O(n²) |
思路直观,易于理解 | 空间开销大 |
| 中心扩展 | O(n²) |
O(1) |
空间高效,实现简单 | 时间复杂度仍为 O (n²) |
| 马拉车算法 | O(n) |
O(n) |
线性时间,适合大规模数据 | 预处理复杂,理解难度高 |