1. 回文串基础与问题定义
回文串(Palindrome)是指正读和反读都相同的字符串。比如"aba"、"abba"、"a"都是回文串,而"abc"、"abca"则不是。在字符串处理问题中,回文串相关算法是面试和竞赛中的高频考点。
最长回文子串问题(Longest Palindromic Substring)要求我们从一个给定的字符串中找出最长的回文子串。例如:
- 输入:"babad",输出:"bab"或"aba"
- 输入:"cbbd",输出:"bb"
这个问题看似简单,但要想出高效的解法却需要深入理解字符串处理的技巧。在实际应用中,回文串检测常用于DNA序列分析、文本处理等领域。
注意:子串(substring)与子序列(subsequence)不同。子串要求字符必须是连续的,而子序列只需要保持相对顺序即可。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法:最直观的解决方案
2.1 暴力解法的基本思路
暴力解法是最容易想到的方法:枚举所有可能的子串,然后检查每个子串是否是回文,最后记录最长的那个。
具体步骤:
- 遍历字符串中所有可能的子串起点i(0 ≤ i < n)
- 对于每个起点i,遍历所有可能的终点j(i ≤ j < n)
- 检查子串s[i...j]是否是回文
- 如果是回文且长度大于当前记录的最大长度,则更新最长回文子串
java复制public String longestPalindrome(String s) {
if (s == null || s.length() < 1) return "";
int start = 0, end = 0;
for (int i = 0; i < s.length(); i++) {
for (int j = i; j < s.length(); j++) {
if (isPalindrome(s, i, j) && (j - i + 1) > (end - start)) {
start = i;
end = j;
}
}
}
return s.substring(start, end + 1);
}
private boolean isPalindrome(String s, int left, int right) {
while (left < right) {
if (s.charAt(left++) != s.charAt(right--)) {
return false;
}
}
return true;
}
2.2 暴力解法的时间复杂度分析
暴力解法的时间复杂度为O(n³):
- 外层两层循环枚举所有子串:O(n²)
- 每个子串检查是否为回文:O(n)
对于LeetCode上的测试用例,当n=1000时,这样的时间复杂度显然无法在合理时间内完成计算。
实际测试:在LeetCode上提交暴力解法,对于长度为1000的字符串会超时(TLE)。
3. 动态规划解法:优化重复计算
3.1 动态规划思路
我们可以利用动态规划来优化暴力解法中的重复计算。定义dp[i][j]表示子串s[i...j]是否是回文。
状态转移方程:
- 基本情况1:单个字符是回文(dp[i][i] = true)
- 基本情况2:两个相同字符是回文(dp[i][i+1] = (s[i] == s[i+1]))
- 一般情况:dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1]
java复制public String longestPalindrome(String s) {
int n = s.length();
if (n < 2) return s;
boolean[][] dp = new boolean[n][n];
int maxLen = 1;
int start = 0;
// 所有长度为1的子串都是回文
for (int i = 0; i < n; i++) {
dp[i][i] = true;
}
// 检查长度为2的子串
for (int i = 0; i < n - 1; i++) {
if (s.charAt(i) == s.charAt(i + 1)) {
dp[i][i + 1] = true;
start = i;
maxLen = 2;
}
}
// 检查长度大于2的子串
for (int len = 3; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
if (s.charAt(i) == s.charAt(j) && dp[i + 1][j - 1]) {
dp[i][j] = true;
if (len > maxLen) {
start = i;
maxLen = len;
}
}
}
}
return s.substring(start, start + maxLen);
}
3.2 动态规划解法的优化
虽然动态规划将时间复杂度降到了O(n²),但空间复杂度也是O(n²),这在处理超长字符串时可能会遇到内存问题。我们可以进一步优化空间复杂度。
观察状态转移方程,我们发现dp[i][j]只依赖于dp[i+1][j-1],即左下角的值。因此可以按子串长度从小到大计算,或者使用滚动数组优化空间。
4. 中心扩展法:更直观的O(n²)解法
4.1 中心扩展法的基本思想
回文串可以从它的中心展开。对于长度为n的字符串,可能有2n-1个可能的中心:
- 奇数长度回文的中心是一个字符(共n个)
- 偶数长度回文的中心是两个字符之间(共n-1个)
我们可以遍历所有这些中心,向两边扩展,直到字符不匹配为止。
java复制public String longestPalindrome(String s) {
if (s == null || s.length() < 1) return "";
int start = 0, end = 0;
for (int i = 0; i < s.length(); i++) {
int len1 = expandAroundCenter(s, i, i); // 奇数长度
int len2 = expandAroundCenter(s, i, i + 1); // 偶数长度
int len = Math.max(len1, len2);
if (len > end - start) {
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
return s.substring(start, end + 1);
}
private int expandAroundCenter(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
left--;
right++;
}
return right - left - 1;
}
4.2 中心扩展法的优势
相比动态规划:
- 时间复杂度同样是O(n²)
- 空间复杂度降为O(1),不需要额外的存储空间
- 代码更简洁直观
在实际编码面试中,中心扩展法通常是更受青睐的解法,因为它更容易在有限时间内正确实现。
5. Manacher算法:线性时间复杂度的终极解法
5.1 Manacher算法的核心思想
Manacher算法由Glenn Manacher于1975年提出,可以在O(n)时间内解决最长回文子串问题。它巧妙地利用了回文串的对称性质,避免了重复计算。
算法的关键步骤:
- 预处理:在字符间插入特殊字符(如#),将字符串长度统一转为奇数
- 维护一个数组P,P[i]表示以i为中心的最长回文半径
- 维护当前已知的最右回文边界R及其中心C
- 利用对称性质减少不必要的计算
5.2 Manacher算法的Java实现
java复制public String longestPalindrome(String s) {
if (s == null || s.length() == 0) return "";
// 预处理字符串,插入#使长度变为奇数
String T = preProcess(s);
int n = T.length();
int[] P = new int[n];
int C = 0, R = 0;
for (int i = 1; i < n - 1; i++) {
int mirror = 2 * C - i; // 计算i关于C的对称点
if (i < R) {
P[i] = Math.min(R - i, P[mirror]);
}
// 尝试扩展以i为中心的回文
while (T.charAt(i + 1 + P[i]) == T.charAt(i - 1 - P[i])) {
P[i]++;
}
// 如果扩展后的回文右边界超过R,则更新C和R
if (i + P[i] > R) {
C = i;
R = i + P[i];
}
}
// 找出P数组中的最大值
int maxLen = 0;
int centerIndex = 0;
for (int i = 1; i < n - 1; i++) {
if (P[i] > maxLen) {
maxLen = P[i];
centerIndex = i;
}
}
int start = (centerIndex - maxLen) / 2;
return s.substring(start, start + maxLen);
}
private String preProcess(String s) {
StringBuilder sb = new StringBuilder();
sb.append('^'); // 开始标记
for (int i = 0; i < s.length(); i++) {
sb.append('#');
sb.append(s.charAt(i));
}
sb.append("#$"); // 结束标记
return sb.toString();
}
5.3 Manacher算法的时间复杂度分析
虽然Manacher算法有两层循环,但内层循环只有在R被扩展时才会执行,而R最多只能被扩展n次(字符串长度)。因此,总体时间复杂度是O(n)。
6. 不同解法的性能对比与选择建议
6.1 时间复杂度对比
| 算法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力解法 | O(n³) | O(1) | 仅用于教学,实际不推荐 |
| 动态规划 | O(n²) | O(n²) | 面试中等长度字符串 |
| 中心扩展 | O(n²) | O(1) | 面试首选,代码简洁 |
| Manacher | O(n) | O(n) | 竞赛或超长字符串处理 |
6.2 面试中的选择建议
在技术面试中:
- 首先说明暴力解法及其缺点
- 提出动态规划或中心扩展法(推荐后者)
- 如果面试官要求更优解,再介绍Manacher算法
实际经验:在大多数面试场景中,能正确实现中心扩展法已经足够。Manacher算法虽然高效,但实现复杂,容易出错,除非特别要求,否则不建议在面试中冒险。
7. 常见错误与调试技巧
7.1 边界条件处理
最长回文子串问题有几个常见的边界条件需要特别注意:
- 空字符串输入
- 单字符字符串(本身就是回文)
- 全相同字符的字符串(如"aaaaa")
- 无回文子串(如"abc",此时应返回任一字符)
7.2 调试技巧
当你的解法出现错误时,可以:
- 打印中间变量(如dp表或P数组)
- 使用小测试用例逐步调试
- 特别注意循环的起始和终止条件
- 检查子串下标的计算是否正确
例如,在中心扩展法中,子串的起始位置计算容易出错:
java复制start = i - (len - 1) / 2; // 正确计算起始位置
end = i + len / 2; // 正确计算结束位置
7.3 LeetCode测试用例建议
在LeetCode上测试时,建议添加以下测试用例:
- 空字符串
- 单字符字符串
- 全相同字符的长字符串(如1000个'a')
- 无任何回文的字符串(如"abcdef")
- 多个相同长度的最长回文(如"abbacca")
8. 算法扩展与应用
8.1 相关问题变种
- 最长回文子序列(不要求连续)
- 回文子串计数(统计所有回文子串数量)
- 分割回文串(将字符串分割为若干回文子串)
- 最短回文串添加(在字符串前添加最少的字符使其成为回文)
8.2 实际应用场景
- DNA序列分析:寻找反向重复序列
- 文本处理:检测回文词或句子
- 数据压缩:利用回文性质进行压缩
- 密码学:某些加密算法利用回文性质
9. Java实现中的性能优化
9.1 字符串操作优化
在Java中,频繁的字符串拼接操作会影响性能。可以:
- 使用StringBuilder代替String拼接
- 预分配足够容量的数组
- 避免在循环中创建新字符串
9.2 内存管理
对于动态规划解法,当处理超长字符串时:
- 考虑使用位压缩减少空间使用
- 按需计算而非存储整个dp表
- 对于特别长的字符串,可以分段处理
9.3 JVM参数调整
在处理超长字符串时,可能需要调整JVM参数:
bash复制java -Xms512m -Xmx1024m YourSolution
这可以增加堆内存,避免OutOfMemoryError。
10. 从解题到掌握:学习建议
- 先理解暴力解法,确保完全掌握问题本质
- 逐步优化,思考如何减少重复计算
- 手写实现每个算法,而不仅仅是理解
- 在白板上练习,模拟面试场景
- 分析时间/空间复杂度,理解trade-off
- 尝试解决相关问题,巩固知识
我在实际面试和工作中发现,很多开发者能够记住算法模板,但遇到变种问题时却无从下手。真正的掌握来自于对算法原理的深刻理解,而不仅仅是记忆实现代码。建议在理解本文介绍的几种解法后,尝试不参考任何代码自己实现,这样才能真正内化为自己的技能。
