给你一个字符串 s,找到 s 中最长的 回文 子串。
示例 1:
输入: s = "babad"
输出: "bab"
解释: "aba" 同样是符合题意的答案。
示例 2:
输入: s = "cbbd"
输出: "bb"
提示:
二、方法一:动态规划(DP)——详尽剖析
1) 状态定义与转移(核心思想)
- 定义
dp[i][j]表示子串s[i..j](包含 i 和 j)是否是回文。 - 若
s[i] == s[j],且内部子串s[i+1..j-1]是回文(即dp[i+1][j-1] == true),则s[i..j]也是回文。 - 特例:当子串长度为 1(i==j)时,总是回文;长度为 2 时只要两个字符相等即回文。
即:
dp[i][j] = (s[i] == s[j]) && ( (j - i < 2) || dp[i+1][j-1] )
其中 (j - i < 2) 等价于长度 1 或 2 的处理(长度 0 不存在,但意义上可视为 true)。
2) 为什么要按子串长度枚举(填表顺序)
dp[i][j]依赖dp[i+1][j-1],后者对应更短的子串(长度减 2)。- 为保证
dp[i+1][j-1]在使用前已被计算,我们 必须 先计算短子串,再计算长子串。 - 因此常用策略:外层枚举子串长度
L = 1..n,内层枚举起点i,右端点j = i + L - 1。
这样从小到大填,依赖总是可用。
3) 正确性证明(归纳法)
-
基础:
L = 1,dp[i][i] = true显然成立。 -
归纳假设:所有长度 < L 的子串状态都正确。
-
对长度
L的某个子串s[i..j](j = i + L -1):-
若
s[i] != s[j],则dp[i][j] = false正确。 -
若
s[i] == s[j]:- 若
L == 2,显然dp[i][j] = true(两个相同字符)。 - 若
L > 2,则是否回文取决于dp[i+1][j-1],而该子问题长度为L-2 < L,由归纳假设其状态已正确,因此dp[i][j]也正确。
- 若
-
-
因此算法正确。
4) 逐行注释的 Java 实现(务必读注释)
class Solution {
public String longestPalindrome(String s) {
int n = s.length();
if (n < 2) return s; // 空或单字符直接返回
// dp[i][j] 表示 s[i..j] 是否回文
boolean[][] dp = new boolean[n][n];
// 长度为1的子串都是回文
for (int i = 0; i < n; i++) dp[i][i] = true;
int maxLeft = 0, maxRight = 0; // 当前最长回文的左右边界(包含)
int maxLen = 1; // 当前最长回文长度(初始为1)
// 枚举子串长度 L,从2开始到 n
for (int L = 2; L <= n; L++) {
// 起点 i 的最大值是 n - L
for (int i = 0; i + L - 1 < n; i++) {
int j = i + L - 1; // 右端点
if (s.charAt(i) == s.charAt(j)) {
// 如果长度为2(两个字符),或者内部子串是回文
dp[i][j] = (L == 2) || dp[i + 1][j - 1];
} else {
dp[i][j] = false;
}
// 如果 s[i..j] 是回文并且更长,更新答案
if (dp[i][j] && L > maxLen) {
maxLen = L;
maxLeft = i;
maxRight = j;
}
}
}
// substring endIndex 是 exclusive,所以写 maxRight + 1
return s.substring(maxLeft, maxRight + 1);
}
}
5) 详细示例:用 s = "babad" 填表演示
索引:0 1 2 3 4 对应 b a b a d
我们构造上三角矩阵 dp[i][j] (i<=j):
- 初始化
dp[i][i] = true:
i\j 0 1 2 3 4
0 T
1 T
2 T
3 T
4 T
-
L=2(长度2,检查相邻字符):
- (0,1): "ba" -> b!=a -> F
- (1,2): "ab" -> F
- (2,3): "ba" -> F? Actually s[2]='b', s[3]='a' -> F
- (3,4): "ad" -> F
-
L=3:
- (0,2): s[0]==s[2] (b==b) -> check dp[1][1] == T -> dp[0][2] = T ("bab")
- (1,3): s[1]==s[3] (a==a) -> check dp[2][2] == T -> dp[1][3] = T ("aba")
- (2,4): s[2]!=s[4] -> F
-
L=4:
- (0,3): s[0]!=s[3] -> F
- (1,4): s[1]!=s[4] -> F
-
L=5:
- (0,4): s[0]!=s[4] -> F
最终 dp 中回文项: (0,0),(1,1),(2,2),(3,3),(4,4),(0,2),(1,3)
最长长度为 3,对应 "bab" 或 "aba"。
注意:当填表时,你的更新
if (dp[i][j] && L > maxLen)会在发现长度为 3 的回文子串时更新为 3。
6) 时间与空间复杂度
- 时间:外层长度 L 遍历 n 次,内层 i 遍历 ≈ n 次,总体 O(n²)。
- 空间:保存
dp[n][n],空间 O(n²)。
7) 常见实现陷阱 & 注意事项
- 忘了处理
s.length() == 0或 null。 - 填表顺序错误(如果按 i 从大到小或按 j 从小到大随意填,可能依赖项未计算)。
- 更新最长子串的条件写错(比如和你之前出问题的
len > (j-i+1)把自己替自己比较)。 - 对 Unicode(如表情、代理对 surrogate pair)敏感时,Java 的
char是 UTF-16 code unit,不等同于 Unicode code point。若需支持任意 Unicode 字符(例如 emoji),需要按 codepoints 处理(更复杂)。
三、方法二:中心扩展(双指针)——详尽剖析
1) 核心观察
任何回文串都有一个“中心”:
- 奇数长度回文:单字符为中心,例如
"aba"中心是b(indices (1,1))。 - 偶数长度回文:两个字符之间为中心,例如
"abba"中心在b|b(indices (1,2))。
因此对于每个可能的中心位置,都可以向两侧扩展,直到不匹配为止,记录最大长度。
2) 扩展函数的正确性
- 对于某个中心
(L0, R0),如果s[L0] == s[R0],可以尝试扩展到(L0-1, R0+1),只要在界内且字符相等就继续。 - 当扩展结束时,最后一次成功的回文长度是
right - left - 1(因为扩展失败使 left, right 超出上一个成功范围)。
3) 逐行注释的 Java 实现
class Solution {
public String longestPalindrome(String s) {
if (s == null || s.length() < 2) return s;
int start = 0, end = 0; // 当前最长回文子串的左右边界(包含)
for (int i = 0; i < s.length(); i++) {
// 以 i 为中心的奇数长度回文
int len1 = expandAroundCenter(s, i, i);
// 以 i 和 i+1 为中心的偶数长度回文
int len2 = expandAroundCenter(s, i, i + 1);
int len = Math.max(len1, len2);
// 如果找到更长的回文,更新 start 和 end
if (len > end - start + 1) {
// 计算新回文的左右边界:
// 对奇数,len1 -> start = i - (len-1)/2, end = i + (len-1)/2
// 对偶数,len2 -> start = i - (len/2 -1), end = i + len/2
// 统一公式:
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
return s.substring(start, end + 1);
}
// 返回以 left,right 为起点扩展得到的最长回文长度
private int expandAroundCenter(String s, int left, int right) {
// 当左右字符相等且仍在字符串范围内,继续扩展
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
left--;
right++;
}
// loop 退出时,left 和 right 分别越过了最后一个匹配的位置
// 因此真正的回文长度为 right - left - 1
return right - left - 1;
}
}
4) 示例演示:s = "babad"(逐步扩展)
-
i = 0:
- expand(0,0) → match 'b' => expand to (-1,1) 越界 -> len1 = 1
- expand(0,1) → 'b' vs 'a' 不匹配 -> len2 = 0
- max len = 1 -> start=0,end=0
-
i = 1:
- expand(1,1): 'a' match -> expand to (0,2): 'b' == 'b' -> expand to (-1,3) stop -> len1 = 3 ("bab")
- expand(1,2): 'a' vs 'b' 不匹配 -> len2 = 0
- max len = 3 -> start = 1 - (3-1)/2 = 0, end = 1 + 3/2 = 2 -> substring "bab"
-
i = 2:
- expand(2,2) -> yields "aba" len1=3 (can tie with previous)
- ... 最终仍可得 "bab" 或 "aba"
5) 时间与空间复杂度
- 时间:最坏情况下 O(n²)。例如字符串全同字符
"aaaaa",每个中心扩展会遍历近 O(n)。 - 空间:O(1)(除返回字符串外只用常数额外空间)。
6) 正确性证明(直观)
任意回文串 s[i..j]:
- 若长度奇数:中心
c = (i+j)/2(整数),用expandAroundCenter(c,c)必能扩展得到s[i..j]。 - 若偶数:中心在格点之间,
expandAroundCenter(c,c+1)能找到它。
因此遍历所有中心必能找到全局最长回文。
7) 实现细节/陷阱
- 更新
start与end的公式来自于中心位置i与回文长度len,上面给出的公式是常用且正确的写法;易错点是边界 off-by-one。 - 如果字符串长度非常大(例如百万级),该方法仍然 O(n²) 不可行;需要 Manacher。
四、两种方法比较(何时用哪种)
- 空间受限或想写短代码:中心扩展(双指针) ,因为 O(1) 空间,代码短。
- 需要统计所有回文子串数量 或 需要用到子串状态的场景:DP 更自然(可统计所有
dp[i][j] == true)。 - 要获得 O(n) 时间:使用 Manacher 算法(见下节)。
- 易读性:DP 更“可证”,中心扩展更直观、常用于面试。
五、进阶:Manacher 算法(O(n))——概念、实现与说明
1) 为什么需要 Manacher
- DP 和中心扩展都是 O(n²) 最坏情况。Manacher 能在 O(n) 时间内找出最长回文子串,适用于非常长字符串或需要最优复杂度的场景。
2) 核心想法(直观)
- 通过将原串插入特殊字符(比如
#)统一奇偶情况,然后维护一个“以 i 为中心的最大回文半径”数组p[i],并利用已知最右回文区间[center, right]的信息跳过重复比较。 - 用镜像(mirror)概念:若
i在当前最右回文区间内,则p[i]至少为min(p[mirror], right - i + 1),之后再尝试扩展。
3) Java 实现(带注释)
class Solution {
public String longestPalindrome(String s) {
if (s == null || s.length() == 0) return "";
// Transform s into T with separators to handle even-length palindromes
// Example: s = "abba" -> T = "^#a#b#b#a#$" (add guards ^,$ to avoid bounds check)
StringBuilder sb = new StringBuilder();
sb.append('^'); // left guard
for (int i = 0; i < s.length(); i++) {
sb.append('#');
sb.append(s.charAt(i));
}
sb.append("#$"); // right guard
char[] T = sb.toString().toCharArray();
int n = T.length;
int[] p = new int[n]; // p[i] = radius of palindrome centered at i in T
int center = 0, right = 0; // current center and right boundary of the rightmost palindrome
int maxLen = 0, centerIndex = 0;
for (int i = 1; i < n - 1; i++) {
int mirror = 2 * center - i; // mirror index of i around center
if (i < right) {
// i is within the rightmost palindrome, can use previously computed p[mirror]
p[i] = Math.min(right - i, p[mirror]);
} else {
p[i] = 0;
}
// try to expand around i
while (T[i + 1 + p[i]] == T[i - 1 - p[i]]) {
p[i]++;
}
// update center & right if palindrome centered at i expands past right
if (i + p[i] > right) {
center = i;
right = i + p[i];
}
// track maxLen
if (p[i] > maxLen) {
maxLen = p[i];
centerIndex = i;
}
}
// extract start position in original string
int start = (centerIndex - maxLen) / 2; // because of inserted chars
return s.substring(start, start + maxLen);
}
}
4) 复杂度
- 时间:O(n)(每个字符的扩展总次数是受限的)
- 空间:O(n)(用于
T与p数组)
5) 直观正确性
- 通过维护最右回文边界
right,对处于该范围内的 i,可以借助其镜像点的p[mirror]值快速获得一个下界,从而减少不必要的字符比较。总体上每个位置的扩展不超过常数平均次数,得到线性时间。
六、工程级注意点(你在实际编码/面试中可能会犯的坑)
-
空串与 null:先判空
if (s == null || s.length() < 2) return s; -
边界 off-by-one:更新 start/end 的算术容易出错;用注释里给出的公式可以避免。
-
字符编码:Java
char是 UTF-16 单元,对于 surrogate pair(如某些 emoji)会把一个可视字符分成两个char。如需完全支持 Unicode codepoint,请使用int[] codePoints = s.codePoints().toArray(),但那会使算法更复杂(索引、substring 等要小心)。 -
性能:若字符串长度 > 10⁵,优先考虑 Manacher,否则 O(n²) 难以接受。
-
调试技巧:
- 写单元测试:
"a","","aa","aba","abba","abacdfgdcaba","abb", 全同字符字符串"aaaaa"。 - 对比三种方法输出一致性(DP / center / Manacher),用于验证实现正确。
- 写单元测试:
七、总结(直观总结 + 推荐)
- 中心扩展法:实现最简单、常用、空间 O(1)、时间 O(n²);适合面试常规解答。
- 动态规划:思路清晰,能扩展到统计回文个数等变种,但空间 O(n²)。
- Manacher:若需要线性时间且字符串很长,使用 Manacher(实现稍复杂但高效)。
- 小字符集或短字符串都用中心扩展就够;若要严苛性能或批量处理长字符串请用 Manacher。