最长回文子串:从暴力破解到动态规划再到中心扩展

最长回文子串:从暴力破解到动态规划再到中心扩展

在这里插入图片描述题目链接

回文子串是字符串问题中的经典题型,其核心是找到字符串中 “正读和反读完全一致” 的最长子串(如 “babad” 中的 “bab” 或 “aba”)。本文将从最直观的暴力算法入手,逐步分析其缺陷并优化,最终推导到高效的 “中心扩展法”,带大家理解每一步优化的逻辑和价值。

一、问题定义与核心难点

首先明确问题边界:

  • 回文子串:连续的字符序列,正读与反读相同(如 “bb”“aba”,区别于 “abcba” 这种回文串,子串要求连续)。
  • 最长:需在所有回文子串中找到长度最大的,若有多个长度相同的,返回任意一个即可。

核心难点:

  1. 如何高效遍历所有可能的子串,避免遗漏;
  2. 如何快速判断一个子串是否为回文,减少冗余计算;
  3. 如何优化时间复杂度,避免暴力算法的低效问题。

二、初代解法:暴力破解(直观但低效)

暴力破解是最直接的思路:枚举所有可能的子串,判断是否为回文,记录最长的回文子串

1. 思路拆解

  • 步骤 1:枚举所有子串的起始索引i和结束索引j0 ≤ i ≤ j < nn为字符串长度),共n*(n+1)/2个可能的子串;
  • 步骤 2:对每个子串s[i..j],判断是否为回文(双指针从两端向中间比对);
  • 步骤 3:记录长度最大的回文子串,若长度相同则保留任意一个。

2. 代码实现

#include <string>
using namespace std;

class Solution {
public:
    string longestPalindrome(string s) {
        int n = s.size();
        if (n <= 1) return s;
        
        int maxLen = 1;  // 最长回文子串长度(至少为1)
        int start = 0;   // 最长回文子串的起始索引
        
        // 枚举所有子串的起始和结束位置
        for (int i = 0; i < n; ++i) {
            for (int j = i; j < n; ++j) {
                // 判断子串s[i..j]是否为回文
                if (isPalindrome(s, i, j)) {
                    int currLen = j - i + 1;
                    // 更新最长回文子串
                    if (currLen > maxLen) {
                        maxLen = currLen;
                        start = i;
                    }
                }
            }
        }
        
        return s.substr(start, maxLen);
    }

private:
    // 辅助函数:判断s[l..r]是否为回文
    bool isPalindrome(const string& s, int l, int r) {
        while (l < r) {
            if (s[l] != s[r]) {
                return false;
            }
            l++;
            r--;
        }
        return true;
    }
};

3. 性能分析

  • 时间复杂度O(n³)。枚举子串需要O(n²)n为字符串长度),每个子串的回文判断需要O(n)(最坏情况子串长度为n),总复杂度为O(n² * n) = O(n³)
  • 空间复杂度O(1)。仅用几个变量记录状态,无额外空间开销。

4. 缺陷

当字符串长度n=1000时(题目上限),n³=10⁹次操作,远超计算机每秒约10⁸次的处理能力,会直接超时 —— 暴力算法在题目约束下完全不可用,必须优化。

三、第一次优化:减少回文判断的冗余(仍不满足)

暴力算法的核心冗余在于 “回文判断”:对每个子串s[i..j],判断时需要重新比对所有字符,而实际上s[i..j]的回文性与s[i+1..j-1]相关 —— 若s[i] == s[j]s[i+1..j-1]是回文,则s[i..j]也是回文。

基于此,我们可以用动态规划(DP) 预存子串的回文状态,避免重复判断。

1. 动态规划思路

  • 状态定义dp[i][j]表示 “子串s[i..j]是否为回文”(true是,false否)。
  • 状态转移:
    • i == j(子串长度为 1):dp[i][j] = true(单个字符一定是回文);
    • j = i+1(子串长度为 2):dp[i][j] = (s[i] == s[j])(两个字符相同则是回文);
    • j > i+1(子串长度≥3):dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1](两端字符相同且中间子串是回文)。
  • 遍历顺序:需先计算短子串的dp值(因为长串依赖短串),因此按 “子串长度” 从小到大遍历(长度 1→长度 2→…→长度 n)。

2. 代码实现

#include <string>
#include <vector>
using namespace std;

class Solution {
public:
    string longestPalindrome(string s) {
        int n = s.size();
        if (n <= 1) return s;
        
        // dp[i][j]:s[i..j]是否为回文
        vector<vector<bool>> dp(n, vector<bool>(n, false));
        int maxLen = 1;
        int start = 0;
        
        // 1. 初始化长度为1的子串(i==j)
        for (int i = 0; i < n; ++i) {
            dp[i][i] = true;
        }
        
        // 2. 初始化长度为2的子串(j=i+1)
        for (int i = 0; i < n-1; ++i) {
            if (s[i] == s[i+1]) {
                dp[i][i+1] = true;
                maxLen = 2;
                start = i;
            }
        }
        
        // 3. 处理长度≥3的子串(len从3到n)
        for (int len = 3; len <= n; ++len) {
            // 枚举起始索引i,结束索引j = i + len - 1
            for (int i = 0; i <= n - len; ++i) {
                int j = i + len - 1;
                // 状态转移:两端相同且中间子串是回文
                if (s[i] == s[j] && dp[i+1][j-1]) {
                    dp[i][j] = true;
                    // 更新最长回文子串
                    if (len > maxLen) {
                        maxLen = len;
                        start = i;
                    }
                }
            }
        }
        
        return s.substr(start, maxLen);
    }
};

3. 性能分析

  • 时间复杂度O(n²)。枚举子串长度O(n),每个长度枚举起始索引O(n),状态转移O(1)(直接查dp表),总复杂度O(n * n) = O(n²)
  • 空间复杂度O(n²)。需要n×n的二维dp数组存储状态。

4. 进步与缺陷

  • 进步:时间复杂度从O(n³)降至O(n²)n=1000n²=10⁶次操作,完全可通过题目测试。
  • 缺陷:空间复杂度O(n²)n=1000时需要10⁶bool值(约 1MB,实际可接受),但仍有优化空间 —— 能否在O(1)空间内实现O(n²)时间复杂度?

四、最终优化:中心扩展法(时间O(n²),空间O(1)

回文子串的本质是 “对称”—— 以某一 “中心” 向两端扩展,若两端字符相同则继续扩展,直到字符不同或越界。这种 “中心扩展” 的思路,无需额外存储状态,可将空间复杂度降至O(1)

1. 核心洞察

回文子串的中心有两种情况:

  • 奇数长度回文:中心是单个字符(如 “aba” 的中心是 “b”);
  • 偶数长度回文:中心是两个相邻字符(如 “bb” 的中心是 “b” 和 “b” 之间)。

因此,我们只需遍历每个可能的 “中心”,向两端扩展并记录最长回文子串即可。

2. 思路拆解

  • 步骤 1:遍历字符串的每个位置i,将其作为 “奇数长度回文的中心”,向两端扩展(l=i, r=i);
  • 步骤 2:遍历字符串的每个位置i,将ii+1作为 “偶数长度回文的中心”,向两端扩展(l=i, r=i+1);
  • 步骤 3:对每次扩展的回文子串,记录其长度和内容,更新最长回文子串。

3. 代码实现(对应题目中的最终写法)

#include <string>
#include <utility>  // 用于pair
using namespace std;

class Solution {
public:
    string longestPalindrome(string s) {
        int len = s.size();
        if (len == 1) return s;
        
        // pair.first:最长回文子串长度;pair.second:最长回文子串内容
        pair<int, string> maxPair;
        maxPair.first = 0;
        maxPair.second = "";
        
        // 遍历每个可能的中心,分别处理奇数和偶数长度回文
        for (int i = 0; i < len; ++i) {
            // 1. 处理奇数长度回文(中心为i)
            int l = i, r = i;
            // 向两端扩展:l >=0、r < len且字符相同
            while (l > -1 && r < len && s[l] == s[r]) {
                l--;
                r++;
            }
            // 扩展结束后,回文子串的实际范围是[l+1..r-1](因最后一次扩展越界/不匹配)
            l++;
            r--;
            // 更新最长回文子串
            if (r - l + 1 > maxPair.first) {
                maxPair.first = r - l + 1;
                maxPair.second = s.substr(l, r - l + 1);
            }
            
            // 2. 处理偶数长度回文(中心为i和i+1)
            if (i + 1 < len && s[i] == s[i + 1]) {
                l = i;
                r = i + 1;
                // 向两端扩展
                while (l > -1 && r < len && s[l] == s[r]) {
                    l--;
                    r++;
                }
                // 修正范围
                l++;
                r--;
                // 更新最长回文子串
                if (r - l + 1 > maxPair.first) {
                    maxPair.first = r - l + 1;
                    maxPair.second = s.substr(l, r - l + 1);
                }
            }
        }
        
        return maxPair.second;
    }
};

4. 关键细节解释

  • 扩展后的范围修正:当while循环退出时,l可能小于 0、r可能大于等于len,或s[l] != s[r],因此实际回文子串的范围是[l+1..r-1](需将l加 1、r减 1)。
  • 偶数长度的前置判断if (i + 1 < len && s[i] == s[i + 1])避免越界(i+1不超过字符串长度),且只有相邻字符相同时才需要扩展(否则不可能是偶数长度回文)。

5. 性能分析

  • 时间复杂度O(n²)。每个中心最多扩展O(n)次(最坏情况字符串全为相同字符,如 “aaaaa”),共有2n-1个中心(n个奇数中心 + n-1个偶数中心),总复杂度O(n * n) = O(n²)
  • 空间复杂度O(1)。仅用lrmaxPair等变量记录状态,无额外空间开销。

五、三种算法对比与选择建议

算法时间复杂度空间复杂度优点缺点适用场景
暴力破解O(n³)O(1)逻辑直观,易于实现效率极低,超时风险高仅用于理解问题,不实际使用
动态规划O(n²)O(n²)状态清晰,易于调试空间开销较大字符串长度较小(n≤500),追求代码清晰
中心扩展法O(n²)O(1)时空效率平衡,无额外空间需处理两种中心情况所有场景(尤其是 n=1000 的题目上限)

选择建议

  1. 若为学习理解:先掌握暴力破解,再推导动态规划,最后理解中心扩展法,逐步体会优化逻辑;
  2. 若为实际解题:优先选择中心扩展法 —— 时空效率最优,代码简洁且无超时风险;
  3. 若为面试回答:需先说明暴力算法的缺陷,再讲解动态规划的优化思路,最后提出中心扩展法并解释其优势(体现思维的完整性)。

六、示例验证(以输入s="babad"为例)

  1. 奇数中心扩展
    • i=0(中心 “b”):扩展后回文 “b”(长度 1);
    • i=1(中心 “a”):扩展到l=0r=2(“bab”,长度 3),更新最长子串为 “bab”;
    • i=2(中心 “b”):扩展到l=0r=4(“babad” 不匹配,实际 “aba”,长度 3),与当前最长长度相同,可保留 “bab”;
    • i=3(中心 “a”):扩展后回文 “a”(长度 1);
    • i=4(中心 “d”):扩展后回文 “d”(长度 1)。
  2. 偶数中心扩展
    • i=0(中心 “b” 和 “a”):字符不同,不扩展;
    • i=1(中心 “a” 和 “b”):字符不同,不扩展;
    • i=2(中心 “b” 和 “a”):字符不同,不扩展;
    • i=3(中心 “a” 和 “d”):字符不同,不扩展。

最终最长回文子串为 “bab”(或 “aba”),与示例输出一致。

七、总结

解决 “最长回文子串” 的优化路径,本质是减少冗余计算

  1. 暴力算法的冗余在于 “重复判断回文”,动态规划通过预存状态消除了这一冗余;
  2. 动态规划的冗余在于 “额外空间存储状态”,中心扩展法通过 “利用回文对称性” 消除了空间开销。

中心扩展法作为最终优化方案,既保持了O(n²)的时间复杂度,又实现了O(1)的空间复杂度,是平衡效率与简洁性的最优选择,也是面试和解题中的首选思路。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值