【LeetCode Hot100 刷题日记 (93/100)】5. 最长回文子串 —— 字符串、动态规划、中心扩展、Manacher 算法🧠

3 阅读7分钟

📌 题目链接:5. 最长回文子串 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:字符串、动态规划、中心扩展、Manacher 算法

⏱️ 目标时间复杂度:O(n²)(常规解法),O(n)(Manacher)

💾 空间复杂度:O(n²)(DP),O(1)(中心扩展),O(n)(Manacher)


🧩 题目分析

给定一个字符串 s,要求找出其中最长的回文子串。回文串是指正读和反读都相同的字符串,如 "aba""bb"

  • 输入范围1 <= s.length <= 1000
  • 字符集:仅包含数字和英文字母
  • 输出要求:返回任意一个最长回文子串即可(存在多个时)

💡 面试高频点:本题是考察字符串处理 + 回文性质 + 多种算法思维的经典题,常出现在大厂一面/二面。面试官通常希望你至少掌握两种解法,并能分析其优劣。


🧠 核心算法及代码讲解

本题有三种主流解法,按面试推荐程度排序:

1️⃣ 动态规划(Dynamic Programming)✅【最稳妥】

✅ 核心思想:

利用回文串的子结构性质

s[i...j] 是回文,且 s[i] == s[j],则 s[i+1...j-1] 也必须是回文。

定义状态:

  • dp[i][j] = true 表示子串 s[i..j] 是回文串

状态转移方程:

dp[i][j] = (s[i] == s[j]) && (j - i < 3 || dp[i+1][j-1])

📌 为什么 j - i < 3 就直接为 true?

  • j - i == 0 → 单字符,必回文
  • j - i == 1 → 两字符,相等即回文
  • j - i == 2 → 三字符,首尾相等即回文(中间一个无所谓)
    所以 j - i < 3 等价于长度 ≤ 3,无需依赖更小子问题。

✅ 代码实现(带详细行注释):

// dp[i][j] 表示 s[i..j] 是否为回文串
vector<vector<int>> dp(n, vector<int>(n));

// 初始化:所有单字符都是回文
for (int i = 0; i < n; i++) {
    dp[i][i] = true;
}

// 枚举子串长度 L 从 2 到 n(从小到大保证子问题已计算)
for (int L = 2; L <= n; L++) {
    for (int i = 0; i < n; i++) {
        int j = i + L - 1; // 右边界
        if (j >= n) break; // 越界剪枝

        if (s[i] != s[j]) {
            dp[i][j] = false;
        } else {
            // 长度 ≤ 3 或内部是回文
            if (j - i < 3) {
                dp[i][j] = true;
            } else {
                dp[i][j] = dp[i + 1][j - 1];
            }
        }

        // 更新最长回文子串
        if (dp[i][j] && L > maxLen) {
            maxLen = L;
            begin = i;
        }
    }
}

⚠️ 注意循环顺序:必须先枚举长度,再枚举起点。若先枚举 i, j,可能 dp[i+1][j-1] 还未计算!


2️⃣ 中心扩展法(Expand Around Center)✅【空间最优】

✅ 核心思想:

回文串一定围绕某个“中心”对称。

  • 奇数长度:中心是一个字符(如 "aba",中心是 'b'
  • 偶数长度:中心在两个字符之间(如 "abba",中心在 'b''b' 之间)

因此,我们枚举所有可能的中心(共 2n - 1 个),对每个中心向两边扩展,直到不匹配为止。

✅ 优势:

  • 空间复杂度 O(1)
  • 代码简洁,逻辑清晰
  • 面试时最容易手撕

✅ 代码实现:

// 辅助函数:从 left, right 向外扩展,返回最长回文区间 [l, r]
pair<int, int> expandAroundCenter(const string& s, int left, int right) {
    while (left >= 0 && right < s.size() && s[left] == s[right]) {
        --left;
        ++right;
    }
    return {left + 1, right - 1}; // 回退一步
}

// 主函数:枚举所有中心
for (int i = 0; i < n; ++i) {
    auto [l1, r1] = expandAroundCenter(s, i, i);     // 奇数中心
    auto [l2, r2] = expandAroundCenter(s, i, i + 1); // 偶数中心
    // 更新全局最长
}

💡 面试技巧:可强调“中心数量是 2n-1 而非 n”,体现细节把控。


3️⃣ Manacher 算法(马拉车)🚀【线性时间,高阶技巧】

✅ 核心思想:

  • 统一奇偶:通过插入 # 将所有回文转为奇数长度(如 "abba""#a#b#b#a#"
  • 利用对称性:维护当前最右回文边界 right 和其中心 j,利用镜像位置加速计算

✅ 关键变量:

  • arm_len[i]:位置 i 的“臂长”(即回文半径)
  • right:当前所有回文能到达的最右边界
  • j:对应 right 的中心

✅ 状态转移:

if (right >= i) {
    int i_sym = 2 * j - i; // i 关于 j 的对称点
    int min_arm = min(arm_len[i_sym], right - i);
    cur_arm_len = expand(s, i - min_arm, i + min_arm);
} else {
    cur_arm_len = expand(s, i, i);
}

⚠️ 面试建议:除非应聘算法岗或明确要求 O(n),否则不必强求手写 Manacher。但需知道其存在及核心思想。


🧭 解题思路(分步骤)

🔹 方法一:动态规划(推荐掌握)

  1. 特判:若字符串长度 < 2,直接返回
  2. 初始化 DP 表:所有 dp[i][i] = true
  3. 按子串长度递增枚举(L = 2 → n)
    • 计算右边界 j = i + L - 1
    • 若越界则 break
    • s[i] != s[j]dp[i][j] = false
    • 否则:
      • 若长度 ≤ 3 → true
      • 否则 → dp[i][j] = dp[i+1][j-1]
    • 若当前是回文且更长 → 更新答案
  4. 返回结果

🔹 方法二:中心扩展(推荐掌握)

  1. 初始化start = 0, end = 0
  2. 遍历每个可能的中心(0 到 n-1):
    • 扩展奇数中心 (i, i)
    • 扩展偶数中心 (i, i+1)
    • 比较两者长度,更新全局最长
  3. 返回子串

📊 算法分析

方法时间复杂度空间复杂度面试推荐度适用场景
动态规划O(n²)O(n²)⭐⭐⭐⭐要求输出所有回文子串、或需记录结构
中心扩展O(n²)O(1)⭐⭐⭐⭐⭐最常用,空间敏感场景
ManacherO(n)O(n)⭐⭐算法岗、追求极致性能

💬 面试官可能追问

  • “能否优化 DP 的空间?” → 答:可以滚动数组,但因依赖 i+1,需倒序遍历 i,且仍需 O(n) 空间。
  • “中心扩展最坏情况是不是 O(n³)?” → 答:不是!每个中心最多扩展 O(n) 次,共 O(n) 个中心,总 O(n²)。

💻 代码

C++

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
public:
    string longestPalindrome(string s) {
        int n = s.size();
        if (n < 2) {
            return s;
        }

        int maxLen = 1;
        int begin = 0;
        // dp[i][j] 表示 s[i..j] 是否是回文串
        vector<vector<int>> dp(n, vector<int>(n));
        // 初始化:所有长度为 1 的子串都是回文串
        for (int i = 0; i < n; i++) {
            dp[i][i] = true;
        }
        // 递推开始
        // 先枚举子串长度
        for (int L = 2; L <= n; L++) {
            // 枚举左边界,左边界的上限设置可以宽松一些
            for (int i = 0; i < n; i++) {
                // 由 L 和 i 可以确定右边界,即 j - i + 1 = L 得
                int j = L + i - 1;
                // 如果右边界越界,就可以退出当前循环
                if (j >= n) {
                    break;
                }

                if (s[i] != s[j]) {
                    dp[i][j] = false;
                } else {
                    if (j - i < 3) {
                        dp[i][j] = true;
                    } else {
                        dp[i][j] = dp[i + 1][j - 1];
                    }
                }

                // 只要 dp[i][j] == true 成立,就表示子串 s[i..j] 是回文,此时记录回文长度和起始位置
                if (dp[i][j] && j - i + 1 > maxLen) {
                    maxLen = j - i + 1;
                    begin = i;
                }
            }
        }
        return s.substr(begin, maxLen);
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    Solution sol;
    // 测试用例
    cout << sol.longestPalindrome("babad") << "\n"; // "bab" 或 "aba"
    cout << sol.longestPalindrome("cbbd") << "\n";  // "bb"
    cout << sol.longestPalindrome("a") << "\n";     // "a"
    cout << sol.longestPalindrome("ac") << "\n";    // "a" 或 "c"

    return 0;
}

JavaScript

/**
 * @param {string} s
 * @return {string}
 */
var longestPalindrome = function(s) {
    const n = s.length;
    if (n < 2) return s;

    let maxLen = 1;
    let begin = 0;
    const dp = Array.from({ length: n }, () => Array(n).fill(false));

    // 初始化单字符
    for (let i = 0; i < n; i++) {
        dp[i][i] = true;
    }

    // 枚举长度 L 从 2 到 n
    for (let L = 2; L <= n; L++) {
        for (let i = 0; i < n; i++) {
            let j = i + L - 1;
            if (j >= n) break;

            if (s[i] !== s[j]) {
                dp[i][j] = false;
            } else {
                if (j - i < 3) {
                    dp[i][j] = true;
                } else {
                    dp[i][j] = dp[i + 1][j - 1];
                }
            }

            if (dp[i][j] && L > maxLen) {
                maxLen = L;
                begin = i;
            }
        }
    }

    return s.substring(begin, begin + maxLen);
};

// 测试
console.log(longestPalindrome("babad")); // "bab"
console.log(longestPalindrome("cbbd"));  // "bb"
console.log(longestPalindrome("a"));     // "a"
console.log(longestPalindrome("ac"));    // "a"

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!