5.最长回文子串

85 阅读10分钟

给你一个字符串 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 = 1dp[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);

            // 如果找到更长的回文,更新 startend
            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 退出时,leftright 分别越过了最后一个匹配的位置
        // 因此真正的回文长度为 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) 实现细节/陷阱

  • 更新 startend 的公式来自于中心位置 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)(用于 Tp 数组)

5) 直观正确性

  • 通过维护最右回文边界 right,对处于该范围内的 i,可以借助其镜像点的 p[mirror] 值快速获得一个下界,从而减少不必要的字符比较。总体上每个位置的扩展不超过常数平均次数,得到线性时间。

六、工程级注意点(你在实际编码/面试中可能会犯的坑)

  1. 空串与 null:先判空 if (s == null || s.length() < 2) return s;

  2. 边界 off-by-one:更新 start/end 的算术容易出错;用注释里给出的公式可以避免。

  3. 字符编码:Java char 是 UTF-16 单元,对于 surrogate pair(如某些 emoji)会把一个可视字符分成两个 char。如需完全支持 Unicode codepoint,请使用 int[] codePoints = s.codePoints().toArray(),但那会使算法更复杂(索引、substring 等要小心)。

  4. 性能:若字符串长度 > 10⁵,优先考虑 Manacher,否则 O(n²) 难以接受。

  5. 调试技巧

    • 写单元测试:"a", "", "aa", "aba", "abba", "abacdfgdcaba", "abb", 全同字符字符串 "aaaaa"
    • 对比三种方法输出一致性(DP / center / Manacher),用于验证实现正确。

七、总结(直观总结 + 推荐)

  • 中心扩展法:实现最简单、常用、空间 O(1)、时间 O(n²);适合面试常规解答。
  • 动态规划:思路清晰,能扩展到统计回文个数等变种,但空间 O(n²)。
  • Manacher:若需要线性时间且字符串很长,使用 Manacher(实现稍复杂但高效)。
  • 小字符集或短字符串都用中心扩展就够;若要严苛性能或批量处理长字符串请用 Manacher。