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

方法一:动态规划
我们定义一个二维布尔数组 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) |
线性时间,适合大规模数据 | 预处理复杂,理解难度高 |