三种方法详解最长回文子串问题

文章目录

回文子串是字符串处理中的经典问题,本文将通过动态规划、中心扩展和马拉车算法三种方法,详细解析如何高效求解最长回文子串,并对比各方法的优劣。

题目描述

方法一:动态规划

我们定义一个二维布尔数组 dp,其中:

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

状态转移方程:

  1. 基础情况
    • 单个字符总是回文串:dp[i][i] = true
    • 两个相同字符是回文串:若 s[i] == s[j]j - i == 1,则 dp[i][j] = true
  2. 扩展情况
    • 如果 s[i] == s[j] 且子串 s[i+1..j-1] 是回文串(即 dp[i+1][j-1] = true),那么 s[i..j] 也是回文串。
    • 如果 s[i] != s[j],则 s[i...j] 不是回文串。

状态转移公式:

cpp 复制代码
dp[i][j] = (s[i] == s[j]) && 
           (j - i <= 1 || dp[i+1][j-1])

算法步骤

  1. 初始化
    • 若字符串长度小于 2,直接返回字符串本身。
    • 初始化最长回文子串为第一个字符(ret = s.substr(0,1))。
    • 创建二维数组 dp,大小为 n x n,初始值为 false
  2. 填充 DP 表
    • 外层循环遍历子串的结束位置 j(从 1 到 n-1)。
    • 内层循环遍历子串的起始位置 i(从 0 到 j)。
    • 根据状态转移方程计算 dp[i][j]
  3. 更新最长回文子串
    • dp[i][j] = true 且当前子串长度 (j - i + 1) 大于记录的最长子串长度,则更新最长回文子串。
  4. 返回结果
    • 循环结束后返回记录的最长回文子串 ret

代码实现:

cpp 复制代码
class Solution {
public:
    string longestPalindrome(string s) {
        int n = s.size();
        if (n < 2) return s;

        string ret = s.substr(0, 1); // 初始化最长回文子串为第一个字符
        vector<vector<bool>> dp(n, vector<bool>(n, false));
        
        for (int j = 1; j < n; ++j) { // j 是结束位置
            for (int i = 0; i <= j; ++i) { // i 是起始位置
                if (s[i] == s[j]) {
                    // 子串长度 ≤ 2 或内部子串是回文串
                    if (j - i <= 1 || dp[i + 1][j - 1]) {
                        dp[i][j] = true;
                        // 更新最长回文子串
                        if (j - i + 1 > ret.size()) {
                            ret = s.substr(i, j - i + 1);
                        }
                    }
                }
            }
        }
        return ret;
    }
};

复杂度分析

  • 时间复杂度O(n²),其中 n 是字符串长度。需要两层循环遍历所有子串。
  • 空间复杂度O(n²),用于存储动态规划表 dp

使用滚动数组优化空间

原始动态规划解法使用 O(n²) 空间存储二维数组 dp,但观察状态转移方程 dp[i][j] = dp[i+1][j-1] 可知:计算当前行 i 的状态时,仅依赖于下一行 i+1 的状态 。因此,可通过 滚动数组 将空间复杂度优化至 O(n)

优化原理

  • 使用一维数组 dp[j] 表示子串 s[i..j] 是否为回文。
  • 遍历顺序改为 从下到上(i 从 n-1 到 0)从左到右(j 从 i 到 n-1) ,确保计算 dp[j] 时,dp[j-1] 已更新为当前行的状态,而 dp[j] 仍保留上一行(i+1)的状态。
  • 使用额外变量 prev 记录 dp[i+1][j-1] 的值(即上一轮的 dp[j-1])。

代码实现

cpp 复制代码
class Solution {
public:
    string longestPalindrome(string s) {
        int n = s.size();
        if (n < 2) return s;
        
        string ret = s.substr(0, 1);  // 初始最长回文为第一个字符
        vector<bool> dp(n, false);    // 滚动数组,dp[j] 表示 s[i..j] 是否为回文
        
        // 从下到上遍历行(i 从 n-1 到 0)
        for (int i = n - 1; i >= 0; --i) {
            bool prev = false;  // 记录 dp[i+1][j-1] 的值(即上一轮的 dp[j-1])
            
            // 从左到右遍历列(j 从 i 到 n-1)
            for (int j = i; j < n; ++j) {
                bool curr = dp[j];  // 暂存当前 dp[j](即 dp[i+1][j])
                
                if (i == j) {
                    dp[j] = true;  // 单字符是回文
                } else if (j == i + 1) {
                    dp[j] = (s[i] == s[j]);  // 双字符需相等
                } else {
                    dp[j] = (s[i] == s[j]) && prev;  // 依赖 dp[i+1][j-1](即 prev)
                }
                
                prev = curr;  // 更新 prev 为下一轮的 dp[i+1][j-1]
                
                // 更新最长回文串
                if (dp[j] && (j - i + 1) > ret.size()) {
                    ret = s.substr(i, j - i + 1);
                }
            }
        }
        
        return ret;
    }
};

方法二:中心扩展法

在解决最长回文子串问题时,中心扩展法通常比动态规划更简单直观。

核心思想

利用回文串的对称特性,从每个可能的中心点(单个字符或两个字符之间)向两侧扩展,寻找最长回文子串。

算法步骤

  1. 初始化
    • 设置起始位置 start = 0 和最大长度 maxLen = 1
  2. 遍历中心点
    • 对每个字符 s[i] 作为奇数长度中心
    • 对每对相邻字符 s[i], s[i+1] 作为偶数长度中心
  3. 扩展检查
    • 从中心向左右扩展,直到字符不匹配或到达边界
  4. 更新结果
    • 当发现更长回文子串时更新 startmaxLen

代码实现

cpp 复制代码
class Solution {
public:
    string longestPalindrome(string s) {
        if (s.empty()) return "";

        int start = 0; // 最长回文串的起始位置
        int maxLen = 1; // 最长回文串的长度

        // 中心扩展函数:从中心扩展,返回当前中心的最长回文长度
        auto expandAroundCenter = [&](int left, int right) {
            while (left >= 0 && right < s.size() && s[left] == s[right]) {
                --left;
                ++right;
            }
            return right - left - 1; // 返回长度,因为这里的 left 和 right 越界,所以是 -1
            };

        // 遍历每个可能的中心位置
        for (int i = 0; i < s.size(); ++i) {
            // 1. 以单个字符为中心的回文串
            int len1 = expandAroundCenter(i, i);
            // 2. 以两个字符为中心的回文串
            int len2 = expandAroundCenter(i, i + 1);

            // 取最大值
            int Len = max(len1, len2);

            // 更新起始位置和长度
            if (Len > maxLen) {
                maxLen = Len;
                start = i - (Len - 1) / 2;
            }
        }
        return s.substr(start, maxLen);
    }
};

复杂度分析

  • 时间复杂度O (n²),每个中心最多扩展 O (n) 次,共 O (n) 个中心。
  • 空间复杂度O (1),仅需常数级空间。

方法三:马拉车算法

马拉车算法通过预处理字符串和利用回文串的对称性,将时间复杂度优化至线性,适用于大规模字符串。

算法思路

  1. 预处理 :在字符串每个字符间插入特殊符号(如 '#'),将奇偶长度的回文串统一为奇数长度(如 "abc" → "^#a#b#c#$"^$ 为边界符)。
  2. 核心数组p[i] 表示以 i 为中心的最长回文半径(包含 i 本身)。
  3. 对称优化 :利用已知回文串的右边界 right 和中心 center,通过对称点 mirror 快速初始化 p[i],减少重复扩展。

代码实现

cpp 复制代码
class Solution {
public:
    string longestPalindrome(string s) {
        if (s.empty()) return "";

        // 预处理:插入特殊字符统一奇偶长度
        string t = "#";
        for (char c : s) {
            t += c;
            t += '#';
        }

        int n = t.size();
        vector<int> p(n, 0); // 回文半径数组
        int center = 0, right = 0; // 当前最右回文的中心和右边界
        int maxLen = 0, maxCenter = 0; // 最长回文的半径和中心

        for (int i = 0; i < n; ++i) {
            // 利用对称性初始化 p[i]
            if (i < right) {
                int mirror = 2 * center - i; // i 关于 center 的对称点
                p[i] = min(right - i, p[mirror]);
            }

            // 中心扩展
            while (i - p[i] - 1 >= 0 && i + p[i] + 1 < n && 
                   t[i - p[i] - 1] == t[i + p[i] + 1]) {
                p[i]++;
            }

            // 更新最右回文边界
            if (i + p[i] > right) {
                center = i;
                right = i + p[i];
            }

            // 更新最长回文记录
            if (p[i] > maxLen) {
                maxLen = p[i];
                maxCenter = i;
            }
        }

        // 转换回原字符串的起始位置
        int start = (maxCenter - maxLen) / 2;
        return s.substr(start, maxLen);
    }
};

复杂度分析

  • 时间复杂度O (n),每个字符最多被访问一次。
  • 空间复杂度O (n),用于存储预处理后的字符串和 p 数组。

三种方法对比

方法 时间复杂度 空间复杂度 优点 缺点
动态规划 O(n²) O(n²) 思路直观,易于理解 空间开销大
中心扩展 O(n²) O(1) 空间高效,实现简单 时间复杂度仍为 O (n²)
马拉车算法 O(n) O(n) 线性时间,适合大规模数据 预处理复杂,理解难度高
相关推荐
董董灿是个攻城狮1 小时前
5分钟搞懂什么是窗口注意力?
算法
Dann Hiroaki1 小时前
笔记分享: 哈尔滨工业大学CS31002编译原理——02. 语法分析
笔记·算法
xiaolang_8616_wjl2 小时前
c++文字游戏_闯关打怪2.0(开源)
开发语言·c++·开源
夜月yeyue2 小时前
设计模式分析
linux·c++·stm32·单片机·嵌入式硬件
qqxhb3 小时前
零基础数据结构与算法——第四章:基础算法-排序(上)
java·数据结构·算法·冒泡·插入·选择
无小道3 小时前
c++-引用(包括完美转发,移动构造,万能引用)
c语言·开发语言·汇编·c++
FirstFrost --sy4 小时前
数据结构之二叉树
c语言·数据结构·c++·算法·链表·深度优先·广度优先
森焱森4 小时前
垂起固定翼无人机介绍
c语言·单片机·算法·架构·无人机
Tanecious.5 小时前
C++--map和set的使用
开发语言·c++
搂鱼1145145 小时前
(倍增)洛谷 P1613 跑路/P4155 国旗计划
算法