#LC5. 最长回文子串

最长回文子串

你好!我是你的OI竞赛教练。很高兴看到你开始挑战经典的字符串题目。LeetCode5这道题虽然在LeetCode上是中等难度,但在OI体系中,它是区间动态规划(Interval DP)中心扩展法的入门必修课,甚至通向更高阶的Manacher算法。

下面我将把这道题改写成标准的NOI竞赛题目格式,并带你一步步拆解思路。


一、 题目描述

题目名称: 最长回文子串 (Longest Palindromic Substring) 输入文件: palindrome.in 输出文件: palindrome.out 时间限制: 1.0 s 空间限制: 256 MB

【题目描述】

给定一个字符串 SS,请你找出 SS 中最长的回文子串。 如果 SS 中存在多个长度相同的最长回文子串,输出其中任意一个即可。

定义:

  • 子串 (Substring):字符串中连续的字符序列。
  • 回文 (Palindrome):正着读和反着读都一样的字符串。例如 "aba" 是回文,而 "abc" 不是。

【输入格式】

输入包含一行,为一个字符串 SSSS 仅由数字 (0-9) 和英文字母 (a-z, A-Z) 组成。

【输出格式】

输出一行,包含一个字符串,表示 SS 的最长回文子串。

【样例 1】

输入:

babad

输出:

bab

说明: "aba" 也是一个有效答案。

【样例 2】

输入:

cbbd

输出:

bb

【数据范围与提示】

对于 100%100\% 的数据,字符串 SS 的长度 1S10001 \le |S| \le 1000


二、 预备知识点总结

在攻克这道题之前,你需要掌握以下知识点:

  1. 字符串基础:了解字符串的下标操作(C++中通常从0开始)。
  2. 回文判定:如何用双指针法判断一个字符串是否是回文。
  3. 枚举思想:如何遍历所有可能的子串。
  4. 动态规划 (DP):特别是区间DP的基础概念(状态定义与转移)。
  5. 中心扩展法:一种利用回文对称性来优化枚举的技巧。

三、 启发式教学:思路推理与草稿纸推演

同学们,拿出你们的草稿纸,我们不要一上来就写代码,先画图理解。

1. 朴素思路(暴力法)—— 为什么会超时?

  • 提问:如果我们要找到最长的回文子串,最笨的办法是什么?
  • 学生思考:枚举所有的子串,然后检查它是不是回文。
  • 草稿纸推演: 假设字符串是 babad
    • 子串有 b, ba, bab, baba, babad, a, ab, ...
    • 子串总数是 O(N2)O(N^2) 级别的。
    • 检查每一个子串是否回文需要 O(N)O(N) 的时间。
    • 总复杂度O(N3)O(N^3)
  • 教练点评:数据范围 N=1000N=100010003=1091000^3 = 10^9,这在1秒的时限内大概率会超时(通常计算机1秒处理 10810^8 次运算)。我们需要优化。

2. 优化视角一:中心扩展法(形象直观)

  • 引导:回文串有什么特点?它是对称的。既然是对称的,如果我们知道了“中心”,是不是就可以向两边扩散去寻找边界?
  • 画图: 在草稿纸上写下 b a b a d
    • 假设中心是 b (下标2),向左看是 a,向右看是 a。一样!继续扩散。
    • 再向左是 b,向右是 d。不一样!停止。
    • 找到了 abc...哦不对,是 aba
  • 难点提示:中心只有字符吗? 看样例 c b b d。最长回文是 bb
    • 如果中心只选字符,选第一个 b,左右不匹配;选第二个 b,左右不匹配。
    • 关键点:回文的中心可能是一个字符(长度为奇数),也可能是两个字符之间的空隙(长度为偶数)。
  • 算法流程
    1. 枚举每一个可能的中心(共有 NN 个字符中心 + N1N-1 个空隙中心 = 2N12N-1 个中心)。
    2. 对于每个中心,向两边扩展,直到字符不相等。
    3. 记录最长的长度和起始位置。
  • 复杂度分析
    • 时间:枚举中心 O(N)O(N) ×\times 扩展 O(N)O(N) = O(N2)O(N^2)。对于 N=1000N=100010610^6 次运算,绰绰有余
    • 空间:O(1)O(1),只需要存几个变量。

3. 优化视角二:动态规划 (DP) —— 经典的区间DP

  • 引导:如果不从中心开始,我们用数学归纳法的思想。如果我们知道 bab 是回文,那么 ababa 是不是回文取决于什么?
  • 推导
    • 取决于两头的字符是否相等 (a == a) 中间的部分 (bab) 是否已经是回文。
  • 状态定义: 令 dp[i][j]dp[i][j] 表示子串 S[i...j]S[i...j] 是否为回文串(True/False)。
  • 状态转移方程dp[i][j]=(S[i]==S[j])dp[i+1][j1]dp[i][j] = (S[i] == S[j]) \land dp[i+1][j-1] 边界条件
    • 长度为1:dp[i][i]=Truedp[i][i] = \text{True}
    • 长度为2:dp[i][i+1]=(S[i]==S[i+1])dp[i][i+1] = (S[i] == S[i+1])
  • 填表顺序: 同学们注意!在草稿纸上画一个二维表格。如果我们要算 dp[0][4]dp[0][4],我们需要先知道 dp[1][3]dp[1][3]。 所以,不能简单地双层 for (i=0..n) for (j=0..n)必须按子串长度(len)从小到大枚举! 先算长度为1的,再算长度为2的...
  • 复杂度分析
    • 时间:O(N2)O(N^2)(填满半个表格)。
    • 空间:O(N2)O(N^2)(需要一个二维数组)。

4. 教练建议

对于 N1000N \le 1000中心扩展法在空间上优于DP,且代码更容易写对(不需要考虑DP的遍历顺序)。DP是很好的思维训练,但在这题上略显笨重。

进阶思考(课后作业):如果 N107N \le 10^7,怎么做?去查阅 Manacher算法 (马拉车算法),它可以做到 O(N)O(N)


四、 读题关键词 (Reading Comprehension)

做题时圈出这些词,你就赢了一半:

  1. “连续子串” (Substring):不是子序列,必须连续。
  2. “最长”:需要维护一个 max_len 变量,并在发现更长时更新 start_index
  3. “任意一个”:不需要纠结输出第一个还是最后一个,遇到一样长的,不更新或者更新都可以。

五、 参考代码 (NOI风格 C++14)

这里提供中心扩展法的代码,这是考场上性价比最高的写法。

/**
 * Title: Longest Palindromic Substring
 * Language: C++14 (NOI Standard)
 * Time Complexity: O(N^2)
 * Space Complexity: O(1) (excluding input/output storage)
 */

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>

using namespace std;

// 辅助函数:从中心向两边扩展,返回该中心能构成的最长回文串长度
// left 和 right 是扩展的起始位置
int expandAroundCenter(const string& s, int left, int right) {
    int L = left, R = right;
    // 当没有越界,且两端字符相等时,继续扩展
    while (L >= 0 && R < s.length() && s[L] == s[R]) {
        L--;
        R++;
    }
    // 注意:跳出循环时,s[L] != s[R] 或者越界
    // 回文串的真实范围是 (L + 1) 到 (R - 1)
    // 长度 = (R - 1) - (L + 1) + 1 = R - L - 1
    return R - L - 1;
}

int main() {
    // 优化 I/O 效率,这是OI选手的基本素养
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string s;
    if (!(cin >> s)) return 0; // 处理空输入等异常情况

    if (s.empty()) {
        cout << "" << endl;
        return 0;
    }

    int start = 0, maxLen = 0;

    for (int i = 0; i < s.length(); ++i) {
        // 情况1:回文中心是一个字符(如 "aba" 中心是 'b')
        int len1 = expandAroundCenter(s, i, i);
        
        // 情况2:回文中心是两个字符之间(如 "abba" 中心是 "bb" 之间)
        int len2 = expandAroundCenter(s, i, i + 1);
        
        // 取两种情况的较大值
        int len = max(len1, len2);
        
        // 如果找到了更长的回文串,更新起始位置和最大长度
        if (len > maxLen) {
            maxLen = len;
            // 推导起始位置:
            // 若 len 是奇数,中心是 i,start = i - len/2
            // 若 len 是偶数,中心是 i 和 i+1 之间,start = i - (len-1)/2
            // 整数除法特性:(len - 1) / 2 等价于统一公式
            start = i - (len - 1) / 2;
        }
    }

    // 输出最长回文子串
    cout << s.substr(start, maxLen) << endl;

    return 0;
}

六、 课后自我检查

  1. 代码中 R - L - 1 是怎么推导出来的?(建议带入 abaabba 算一下边界)
  2. 如果不使用 startmaxLen 记录,而是每次都截取 substr 比较,会增加多少时间复杂度?(字符串拷贝是 O(N)O(N) 的,会导致总复杂度退化为 O(N3)O(N^3)千万小心)。

祝你切题愉快!