(动态规划)最长回文子串

  1. 最长回文子串
    给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

示例 1:

输入: “babad” 输出: “bab” 注意: “aba” 也是一个有效答案。

示例 2:

输入: “cbbd” 输出: “bb”

1、暴力法

暴力法将遍历子串集合,判断每个子串是否回文。
复杂度分析

  • 时间复杂度:O(N3),对于长度N的字符串,其子串总数是N(N-1)/2,而验证每个子串是否回文需要O(N),所以时间复杂度O(N2·N)
  • 空间复杂度:O(1)

2、动态规划

2.1、动态规划+从左向右遍历2次

设状态dp[j][i]表示索引j到索引i的子串是否是回文串。则转移方程为:
在这里插入图片描述
dp[j][i]为true时表示索引j到索引i形成的子串为回文子串,且子串起点索引为j,长度为i - j + 1
算法时间复杂度为O(N ^ 2)

#include <iostream>
#include <cstring>
using namespace std;

string longestPalindrome(string s)
{
    const int n = s.size();
    bool dp[n][n];
    memset(dp, 0, sizeof(dp));

    int maxlen = 1;     //保存最长回文子串长度
    int start = 0;      //保存最长回文子串起点
    for(int i = 0; i < n; ++i)
    {
    	// i是右指针,j是左指针,必须要j<=i
        for(int j = 0; j <= i; ++j)
        {
            if(i - j < 2)
            {
                dp[j][i] = (s[i] == s[j]);
            }
            else
            {
                dp[j][i] = (s[i] == s[j] && dp[j + 1][i - 1]);
            }
			// 更新最大回文串的长度,并记录起点位置
            if(dp[j][i] && maxlen < i - j + 1)
            {
                maxlen = i - j + 1;
                start = j;
            }
        }
    }

    return s.substr(start, maxlen);
}

int main()
{
    string s;
    cout << "Input source string: ";
    cin >> s;
    cout << "The longest palindrome: " << longestPalindrome(s);
    return 0;
}

2.2、动态规划+从中心向两边拓展

利用之前迭代获得的信息,帮助后续迭代减少不必要的重复计算。考虑 “ababa” 这个示例。如果我们已经知道 “bab” 是回文,那么很明显,“ababa” 一定是回文,因为它的左首字母和右尾字母是相同的。

第一步:思考状态dp[i]

结合题意,要求最长回文子串,那么
dp[i]表示一个回文子串的长度,则该回文子串可以被表示成s[i, i + dp[i])

例如:dp[0] = 1,表示s[0, 0 + 1)是一个回文字串,长度为1

第二步:思考状态转移方程

从中心向两边伸展,遇到了新的2个字符,如果这2个字符相等,则
dp[i] = dp[i - 1] + 2
Q:i是从左向右吗?

第三步:思考起始条件(中心拓展的起始状态)

起始子串的长度可以有两种情况,例如:

  • length=1时,“a”–>“aba”
  • length=2时,“bb”–>“abba”

那么,需要对长度为N的字符串,分别以起始长度为1,2的情况遍历2次,总共遍历 N+(N-1) 次。

复杂度分析

  • 时间复杂度:遍历时间O(N+(N-1)),延伸子串并判断是否回文串时间最坏情况下是O(N/2),那么总的时间复杂度为:O((N+(N-1))·(N/2)),即O(N2)
  • 空间复杂度:由于每次迭代,当前状态只与前一刻的状态有关,因此dp状态向量可以重复使用,向量长度是N,因此空间复杂度是O(N)

程序思路导读:

  • 需要对长度为N的字符串,分别以起始长度为1,2的情况遍历2次

  • start指针用来遍历,代表子串的首字母在整串中的位置

  • i j 是start的偏移,分别代表了子串左右伸展的长度

      i j 向左右伸展,那么要时刻注意数组越界~~
    
  • 每次遍历时,移动start,然后对i j指向的字符做判断,相等则对dp[i-1]+2

      这里是对dp[i-1]操作而不是dp[i+1]操作,是个注意点~~
      因为 i 是向左移动的!
    
  • 由于dp[x]的值是x为起点的回文子串的长度,因此s[x:x+dp[x]]是一个回文串!

class Solution {
public:
    string longestPalindrome(string s) {
		// 特殊判例
        if (s.size() == 0) return "";
        // i代表子串的左指针,j代表子串的右指针,start代表子串首字符在S中的位置
        int start, i, j;
        int L = 0, max_length = 1;
        // dp表示该子串的最大回文长度
        int *dp = new int[s.size()]{};

        // 情况1:起始子串长度=1
        //cout << "情况1" << endl;
        for (start = 0; start < s.size(); ++start){
            // i代表子串的左指针,j代表子串的右指针,start代表子串首字符在S中的位置
            i = 0, j = 0;
            // 起始子串长度=1,即s[0, 0 + 1)长度为1
            dp[start] = 1;
            // 迭代,以start为中心,向两头延伸子串
            // “ababa” ,如果我们已经知道 “bab” 是回文,那么“ababa” 一定是回文,因为它的左首字母和右尾字母是相同的。
            while(dp[start + i] > 0){
                --i;
                ++j;
                //printf("start = %d, i = %d, j = %d\n", start, i, j);
                // 如果当前索引会超出边界,则迭代结束
                if (start + i < 0 || (j - i + 1) > s.size()){
                    //printf("break\n");
                    break;
                }
                // 若两头的字符匹配,则更新结果,否则结束迭代
                if (s[start + i] == s[start + j]){
                    //printf("i == j, %d == %d \n", i, j);
                    // 状态转移,以左边界为索引[start + i],更新以该左边界为起点的回文子串的长度
                    dp[start + i] = dp[start + i + 1] + 2;
					
                    //printf("max_length = %d, length = %d\n", max_length, dp[start + i]);
                    // 如果当前子串的长度更长,则更新结果
                    if (max_length < dp[start + i]){
                        max_length = dp[start + i];
                        L = start + i;
                    }
                } else {
                    break;
                }

            }
        }
        // 情况2:起始子串长度=2
        // i代表子串的左指针,j代表子串的右指针,start代表子串在S中的位置
        // dp表示该子串的最大回文长度
        //cout << "情况2" << endl;
        for (start = 0; start < s.size() - 1; ++start){
            i = 0;
            j = 1;//区别1:j=1
            dp[start] = 0;
            if (s[start + i] == s[start + j]){	//区别2:j=1要判断2个初始字符
                // 这是初始状态
                dp[start + i] = 2;
                // 输出结果的更新
                if (max_length < dp[start + i]){
                    // 如果当前子串的长度更长,则更新结果
                    max_length = dp[start + i];
                    L = start + i;
                }
            }
            // 迭代,向两头延伸子串
            while(dp[start + i] > 0){
                --i;
                ++j;
                //printf("start = %d, i = %d, j = %d\n", start, i, j);
                // 如果当前索引会超出边界,则迭代结束
                if (start + i < 0 || (j - i + 1) > s.size()){
                    //printf("break\n");
                    break;
                }
                // 若两头的字符匹配,则更新结果,否则结束迭代
                if (s[start + i] == s[start + j]){
                    //printf("i == j, %d == %d \n", i, j);
                    // 状态转移,注意,每次迭代时,对i-1,因此,i+1表示上一个状态
                    dp[start + i] = dp[start + i + 1] + 2;
                    if (max_length < dp[start + i]){
                        // 如果当前子串的长度更长,则更新结果
                        max_length = dp[start + i];
                        L = start + i;

                    }
                } else {
                    break;
                }

            }
        }
        delete[] dp;
        // 字符串切片s.substr(a, length)返回的是s[a, a + length),左闭右开
        
        return s.substr(L, max_length);
    }
};

3、Manacher’s Algorithm 马拉车算法

马拉车算法 Manacher‘s Algorithm 是用来查找一个字符串的最长回文子串的线性方法,由一个叫 Manacher 的人在 1975 年发明的,这个方法的最大贡献是在于将时间复杂度提升到了线性。

第一步:预处理,得到奇数串

首先我们解决下奇数和偶数的问题,在每个字符间插入 “#”,并且为了使得扩展的过程中,到边界后自动结束,在两端分别插入 “^” 和 “$”,两个不可能在字符串中出现的字符,这样中心扩展的时候,判断两端字符是否相等的时候,如果到了边界就一定会不相等,从而出了循环。经过以上插入字符的处理,字符串的长度永远都是奇数了。
在这里插入图片描述

  • 求最长回文子串的长度
    首先我们用一个数组 P 保存字符从中心扩展的最大次数,而它刚好也是去掉 “#” 的原字符串的总长度。
    从中心扩展的最大次数 = 最长回文子串长度
    例如下图中下标是 6 的地方,可以看到 P[ 6 ] 等于 5,所以它是从左边扩展 5 个字符,相应的右边也是扩展 5 个字符,也就是 “#c#b#c#b#c#”。而去掉 # 恢复到原来的字符串,变成 “cbcbc”,它的长度刚好也就是 5。
    在这里插入图片描述

得到了子串的回文长度,怎么知道该子串的首字母的位置??

  • 求原字符串下标
    用 P 的下标 i 减去 P [ i ],再除以 2,就是原字符串的首字母下标了。

例如我们找到 P[ i ] 的最大值为 5,也就是回文串的最大长度是 5,对应的下标是 6,所以原字符串的开头下标是(6 - 5 )/ 2 = 0。所以我们只需要返回原字符串的第 0 到第(5 - 1)位就可以了。

第二步:求数组P

P[i]的值代表了使用中心拓展法向外拓展的次数,也对应了回文字串的长度。

用 C 表示回文串的中心,用 R 表示回文串的右边界。所以 R = C + P[ i ]。C 和 R 所对应的回文串是当前循环中 R 最靠右的回文串。

使用 i 从做向右扫描求P,让我们考虑求 P [ i ] 的时候,如下图。

用 i_mirror 表示当前需要求的第 i 个字符以 C 为镜像对应的下标。
在这里插入图片描述
我们现在要求 P [ i ],利用前一个子串C 的对称性,之前已经知道了,回文串 C 的长度为5,显然是覆盖了当前字符b。i 关于 C 的对称点是 i_mirror,P [ i_mirror ] = 3,所以 P [ i ] 也等于 3

但是有三种情况将会造成直接赋值为 P [ i_mirror ] 是不正确的,下边一一讨论。

  • 超出了 R
    在这里插入图片描述
    当我们要求 P [ i ] 的时候,P [ mirror ] = 7,而此时 P [ i ] 并不等于 7,为什么呢,因为我们从 i 开始往后数 7 个,等于 22,已经超过了最右的 R,此时不能利用对称性了。但我们一定可以扩展到 R 的(必然),所以 P [ i ] 至少等于 R - i = 20 - 15 = 5,会不会更大呢,我们只需要比较 T [ R+1 ] 和 T [ R+1 ]关于 i 的对称点就行了,就像中心扩展法一样一个个扩展。

  • P [ i_mirror ] 遇到了原字符串的左边界

    此时P [ i_mirror ] = 1,但是 P [ i ] 赋值成 1 是不正确的,出现这种情况的原因是 P [ i_mirror ] 在扩展的时候首先是 “#” == “#”,之后遇到了 “^” 和另一个字符比较,也就是到了边界,才终止循环的。而 P [ i ] 并没有遇到边界,所以我们可以继续通过中心扩展法一步一步向两边扩展就行了。

  • i 等于R
    此时我们先把 P [ i ] 赋值为 0,然后通过中心扩展法一步一步扩展就行了。

第三步:考虑 C 和 R 何时更新?C和R同时更新

就这样一步一步的求出每个 P [ i ],当求出的 P [ i ] 的右边界大于当前的 R 时,我们就需要更新 C 和 R 为当前的回文串了。因为我们必须保证 i 在 R 里面,所以一旦有更右边的 R 就要更新 R。

在这里插入图片描述
此时的 P [ i ] 求出来将会是 3,P [ i ] 对应的右边界将是 10 + 3 = 13,所以大于当前的 R,我们需要把 C 更新成 i 的值,也就是 10,R 更新成 13。继续循环。

复杂度分析

  • 时间复杂度:for 循环里边套了一层 while 循环,难道不是 O(N2)?不!O(N)。不严谨的想一下,因为 while 循环中,访问 R 右边的数字用来扩展,也就是那些还未求出的节点,然后不断向两头扩展,而期间访问的节点下次就不会再进入 while 了,下次可以利用对称性直接得到解,所以每个节点访问都是常数次,所以是 O( n )。

  • 空间复杂度:O(n)。

class Solution {
    public:
        string preProcess(string s) {
        int n = s.length();
        if (n == 0) {
            return "^$";
        }
        string ret = "^";
        for (int i = 0; i < n; i++)
            ret = ret + '#' + s[i];
        ret += "#$";
        return ret;
    }

    // 马拉车算法
    string longestPalindrome(string s) {
        // 调用预处理函数
        string T = preProcess(s);
        int n = T.length();
        // 数组 P 保存从中心扩展的最大次数
        int* P = new int[n];
        // C是当前子串的中心,R是子串的右边界,什么时候更新C和R呢?
        int C = 0, R = 0;
        // 填充P时,略过P的头尾
        for (int i = 1; i < n - 1; ++i) {
            int i_mirror = 2 * C - i;
            if (R > i) {
                P[i] = min(R - i, P[i_mirror]);// 防止超出 R
            } else {
                P[i] = 0;// 等于 R 的情况
            }

            // 碰到三种特殊情况时,P[i]的值会出错,这里需要利用中心扩展法检验,错了则会修复
            while (T[i + 1 + P[i]] == T[i - 1 - P[i]]) {
                P[i]++;
            }

            // 判断是否需要更新 R
            if (i + P[i] > R) {
                C = i;
                R = i + P[i];
            }

        }

        // 找出 P 的最大值
        int maxLen = 0;
        int centerIndex = 0;
        for (int i = 1; i < n - 1; i++) {
            if (P[i] > maxLen) {
                maxLen = P[i];
                centerIndex = i;
            }
        }
        int start = (centerIndex - maxLen) / 2; //最开始讲的求原字符串下标
        return s.substr(start, maxLen);
    }
};
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
最长回文子串是指在一个字符串中最长的回文子序列。回文是指正着读和倒着读都一样的字符串。动态规划是解决最长回文子串问题的一种常用方法。动态规划的思想是将问题分解成子问题,通过求解子问题的最优解来得到原问题的最优解。在最长回文子串问题中,我们可以使用一个二维数组dp[i][j]来表示从i到j的子串是否为回文子串。如果dp[i][j]为true,则表示从i到j的子串是回文子串,否则不是。我们可以通过以下步骤来求解最长回文子串: 1. 初始化dp数组,将所有dp[i][i]都设置为true,表示单个字符是回文子串。 2. 遍历字符串s,从长度为2的子串开始,依次判断每个子串是否为回文子串。如果是,则将dp[i][j]设置为true。 3. 在遍历的过程中,记录最长回文子串的长度和起始位置。 4. 最后,通过起始位置和长度来截取最长回文子串。 下面是一个示例代码,可以帮助你更好地理解动态规划求解最长回文子串的过程: class Solution { public: string longestPalindrome(string s) { int len=s.size(); if(len<2) return s; bool dp[len][len];//布尔型,dp[i][j]表示从i到j是否构成回文 int max_count=1;//最大字串的长度 int start=0;//最长字串的起始位置 for(int j=0;j<len;j++) { for(int i=0;i<j;i++) { if(s[i]!=s[j]) dp[i][j]=false; else if((j-i)<3)//(j-1)-(i+1)+1<2表示dp[i][j]的最大字串长度为1 dp[i][j]=true; else { dp[i][j]=dp[i+1][j-1]; } if((j-i+1)>max_count&&dp[i][j]) { max_count=j-i+1; start=i; } } } return s.substr(start,max_count);//截取字符串 } };

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值