回文串系列动态规划附马拉车算法原理及实现

回文子串

题目描述

回文子串

给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。

回文字符串 是正着读和倒过来读一样的字符串。

子字符串 是字符串中的由连续字符组成的一个序列。

示例 1:

输入:s = "abc"

输出:3

解释:三个回文子串: "a", "b", "c"

示例 2:

输入:s = "aaa"

输出:6

解释:6个回文子串: "a", "a", "a", "aa", "aa", "aaa"

提示:

  • 1 <= s.length <= 1000
  • s 由小写英文字母组成

动态规划

动态规划解法并非这题的最优解法,但是能帮助我们提供一个解决其他回文串问题的思路。

首先我们以dp[i][j]表示以i位置为起点,j位置为结尾的子串是否为回文子串。

那么我们来看三种情况:

  1. i==j,是回文子串
  2. i==j-1,即只有两个元素,那么当s[i]==s[j]时,是回文子串
  3. 其他,那么当s[i]==s[j]时,问题就能转化为[i+1,j-1]部分是不是回文子串,若是则[i,j]也是。

因此我们需要用右下角开启填表,具体实现:

cpp 复制代码
class Solution {
public:
    int countSubstrings(string s) {
        int ret=0,n=s.size();
        vector<vector<bool>>dp(n,vector<bool>(n));
        for(int i=n-1;i>=0;--i){
            for(int j=i;j<n;++j){
                if(j-i<=1)dp[i][j]=s[i]==s[j];
                else if(s[i]==s[j])dp[i][j]=dp[i+1][j-1];
                ret+=dp[i][j];
            }
            cout<<endl;
        }
        return ret;
    }
};

时间复杂度O(n2),空间复杂度O(n2)。

中心拓展算法

中心拓展算法也很好理解,我们选定i为中心,开始比较s[i-len]和s[i+len]是否相等。如果相等就让len++。最终就能求出以i为中心的最大回文子串长度。

具体实现:

cpp 复制代码
class Solution {
public:
    int countSubstrings(string s) {
        int ret=0,n=s.size();
        for(int i=0;i<n;++i){
            int left=i,right=i;
            while(left>=0&&right<n&&s[left]==s[right]){
                --left;
                ++right;
                ++ret;
            }
            left=i;
            right=i+1;
            while(left>=0&&right<n&&s[left]==s[right]){
                --left;
                ++right;
                ++ret;
            }
        }
        return ret;
    }
};

时间复杂度O(n2),空间复杂度O(n)。

Manacher

马拉车算法是解决这个问题的最优算法。原理是利用回文子串的对称性:

首先这是一个以z为中心的最长回文子串:

那么他红色部分一定相等,这是以s为中心的最长回文子串:

那么根据对称性,s在z对称位置应当长这样:

所以我们确定了z右边的s为中心的最长回文子串长度。

在考虑另一种情况:

这是以k为中心的最长回文子串,这个子串不完全在z为中心的回文子串当中。这时候我们对称性知道:

这三个字符一定是相等的,因此我们就确定了以z右边的k为中心的回文子串长度是多少,那么下次到了k,我们就能从不能确定的位置开始比较:

由此,我们的算法需要记录当前回文子串能覆盖的最右边界max_right,和对应的中心center。当我们遍历到cur的时候,计算出cur关于center的对称位置:

并将min(cnt[mirror], max_right - i)赋值给cnt[cur]->这里赋较小值是为了刚刚提到的不要超过有边界。

假如赋值后是这样:

我们就从不确定是否回文的地方开始比较,最后拓展完毕:

此时cur的回文子串有边界超出了max_right,就要更新max_right和center:

此外,为了不分类讨论奇数情况和偶数情况的回文串,我们在回文串中插入一个不在回文串范围内的字符,比如'#':

这时候不管是奇数回文串还是偶数回文串,都变成了奇数回文串,且新串的拓展次数就是原串长度:

具体代码实现:

cpp 复制代码
class Solution {
public:
    int countSubstrings(string s) {
        // 1. 预处理字符串:统一奇偶回文
        string tmp = "#";
        for (char e : s) {
            tmp += e;
            tmp += "#";
        }
        int n = tmp.size();
        int ret = 0;
        vector<int> cnt(n, 0); // cnt[i]:以i为中心的最长回文半径(步数)
        
        // Manacher 核心变量:当前最右回文边界 + 对应中心
        int max_right = 0;
        int center = 0;

        for (int i = 0; i < n; ++i) {
            // 2. 利用对称性初始化cnt[i](核心优化)
            if (i < max_right) {
                int mirror = 2 * center - i; // 镜像位置
                // 复用镜像位置的半径,但不超过max_right - i(避免超出当前覆盖范围)
                cnt[i] = min(cnt[mirror], max_right - i);
            }

            // 3. 暴力扩展(仅扩展必要的部分)
            int left = i - cnt[i] - 1;
            int right = i + cnt[i] + 1;
            while (left >= 0 && right < n && tmp[left] == tmp[right]) {
                cnt[i]++;
                left--;
                right++;
            }

            // 4. 更新max_right和center
            if (i + cnt[i] > max_right) {
                max_right = i + cnt[i];
                center = i;
            }

            // 5. 统计结果:预处理后,半径cnt[i]对应原字符串的回文子串数为 (cnt[i]+1)/2
            ret += (cnt[i] + 1) / 2;
        }

        return ret;
    }
};

时间复杂度O(n),空间复杂度O(n)。

最长回文子串

题目描述

给你一个字符串 s,找到 s 中最长的 回文 子串。

示例 1:

输入:s = "babad"

输出:"bab"

解释:"aba" 同样是符合题意的答案。

示例 2:

输入:s = "cbbd"

输出:"bb"

提示:

  • 1 <= s.length <= 1000
  • s 仅由数字和英文字母组成

动态规划

这题和上一题没什么区别,我们还是用dp[i][j]表示[i,j]是否为回文子串,具体实现:

cpp 复制代码
class Solution {
public:
    string longestPalindrome(string s) {
        int n=s.size(),ret=0,begin=0;
        vector<vector<bool>>dp(n,vector<bool>(n));
        for(int i=n-1;i>=0;--i){
            for(int j=i;j<n;++j){
                if(j-i<=1)dp[i][j]=s[i]==s[j];
                else if(s[i]==s[j])dp[i][j]=dp[i+1][j-1];
                if(dp[i][j]&&j-i+1>ret){
                    ret=j-i+1;
                    begin=i;
                }
            }
        }
        return s.substr(begin,ret);
    }
};

时间复杂度和空间复杂度都是O(n2)。

注意到我们每次都只关心下一行的dp表,所以我们可以实行空间优化:

cpp 复制代码
class Solution {
public:
    string longestPalindrome(string s) {
        int n=s.size(),ret=0,begin=0;
        vector<bool>dp(n);
        bool cur,prev;
        for(int i=n-1;i>=0;--i){
            for(int j=i;j<n;++j){
                cur=dp[j];
                if(j-i<=1)dp[j]=s[i]==s[j];
                else if(s[i]==s[j])dp[j]=prev;
                else dp[j]=false;
                if(dp[j]&&j-i+1>ret){
                    ret=j-i+1;
                    begin=i;
                }
                prev=cur;
            }
        }
        return s.substr(begin,ret);
    }
};

时间复杂度O(n2),空间复杂度O(n)。

中心拓展

具体实现:

cpp 复制代码
class Solution {
public:
    string longestPalindrome(string s) {
        int maxlen = 0, ret = 0;
        for (int i = 0; i < s.size(); i++) {
            int left = i, right = i;
            while (left >= 0 && right < s.size() && s[left] == s[right]) {
                --left;
                ++right;
            }
            if (right - left - 1 > maxlen) {
                maxlen = right - left - 1;
                ret = left + 1;
            }
            if (i + 1 < s.size() && s[i + 1] == s[i]) {
                left = i;
                right = i + 1;
                while (left >= 0 && right < s.size() && s[left] == s[right]) {
                    --left;
                    ++right;
                }
                if (right - left - 1 > maxlen) {
                    maxlen = right - left - 1;
                    ret = left + 1;
                }
            }
        }
        return s.substr(ret, maxlen);
    }
};

时间复杂度O(n2),空间复杂度O(1)。

Manacher

具体实现:

cpp 复制代码
class Solution {
public:
    string longestPalindrome(string s) {
        string tmp="#";
        for(auto e:s){
            tmp+=e;
            tmp+='#';
        }
        int n=tmp.size(),center=0,max_right=0,ret=0,begin=0;
        vector<int>len(n);
        for(int i=1;i<n;++i){
            if(i<max_right){
                int mirror=2*center-i;
                len[i]=min(len[mirror],max_right-i);
            }
            int left=i-len[i]-1,right=i+len[i]+1;
            while(left>=0&&right<n&&tmp[left]==tmp[right]){
                --left;
                ++right;
                ++len[i];
            }
            if(len[i]>ret){
                ret=len[i];
                begin=(i-ret)/2;
            }
            if(i+len[i]>max_right){
                center=i;
                max_right=i+len[i];
            }
        }
        return s.substr(begin,ret);
    }
};

时间复杂度和空间复杂度都是O(n)。

分割回文串 IV

题目描述

分割回文串 IV

给你一个字符串 s ,如果可以将它分割成三个 非空 回文子字符串,那么返回 true ,否则返回 false 。

当一个字符串正着读和反着读是一模一样的,就称其为 回文字符串 。

示例 1:

输入:s = "abcbdd"

输出:true

解释:"abcbdd" = "a" + "bcb" + "dd",三个子字符串都是回文的。

示例 2:

输入:s = "bcbddxy"

输出:false

解释:s 没办法被分割成 3 个回文子字符串。

提示:

  • 3 <= s.length <= 2000
    s- 只包含小写英文字母。

算法原理和实现

这题就能用到我们刚开始动态规划[i,j]是否为回文串。

那么我们只需要遍历[0,i][i+1,j][j+1,n-1]是否为回文串即可。

具体实现:

cpp 复制代码
class Solution {
public:
    bool checkPartitioning(string s) {
        int n = s.size();
        vector<vector<bool>> dp(n, vector<bool>(n));
        for (int i = n - 1; i >= 0; --i) {
            for (int j = i; j < n; ++j) {
                if (j - i <= 1)
                    dp[i][j] = s[i] == s[j];
                else if (s[i] == s[j])
                    dp[i][j] = dp[i + 1][j - 1];
            }
        }
        for (int i = 0; i < n - 2; ++i) {
            if (dp[0][i]) {
                for (int j = i + 1; j < n - 1; ++j) {
                    if (dp[i + 1][j] && dp[j + 1][n - 1]) {
                        return true;
                    }
                }
            }
        }

        return false;
    }
};

时间复杂度和空间复杂度都是O(n2)。

分割回文串 II

题目描述

分割回文串 II

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是回文串。

返回符合要求的 最少分割次数 。

示例 1:

输入:s = "aab"

输出:1

解释:只需一次分割就可将 s 分割成 ["aa","b"] 这样两个回文子串。

示例 2:

输入:s = "a"

输出:0

示例 3:

输入:s = "ab"

输出:1

提示:

  • 1 <= s.length <= 2000
  • s 仅由小写英文字母组成

算法原理和实现

这题类似之前写过的单词拆分的考量。

我们定义状态dp[i]为,[0,i]被分割成回文子串的最小次数。

那么当[0,i]是回文串时,dp[i]=0。

如果不是我们就需要从i开始往前遍历找到[j,i]是回文子串,则dp[i]=min(dp[i],dp[j-1]+1)

具体实现:

cpp 复制代码
class Solution {
public:
    int minCut(string s) {
        int n=s.size();
        vector<vector<bool>>isPal(n,vector<bool>(n));
        vector<int>dp(n,n);
        for(int i=n-1;i>=0;--i){
            for(int j=i;j<n;++j){
                if(j-i<=1)isPal[i][j]=s[i]==s[j];
                else if(s[i]==s[j])isPal[i][j]=isPal[i+1][j-1];
            }
        }
        for(int i=0;i<n;++i){
            if(isPal[0][i])dp[i]=0;
            else{
                for(int j=i;j>0;j--){
                    if(isPal[j][i]){
                        dp[i]=min(dp[i],dp[j-1]+1);
                    }
                }
            }
        }
        return dp[n-1];
    }
};

时间复杂度和空间复杂度都是O(n2)。

最长回文子序列

题目描述

最长回文子序列

给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。

子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。

示例 1:

输入:s = "bbbab"

输出:4

解释:一个可能的最长回文子序列为 "bbbb" 。

示例 2:

输入:s = "cbbd"

输出:2

解释:一个可能的最长回文子序列为 "bb" 。

提示:

  • 1 <= s.length <= 1000
  • s 仅由小写英文字母组成

算法原理和实现

如果你阅读过我的子序列动态规划系列,你就会发现。我们这次一维动态规划是肯定行不通的。因为我们新增一个字母根本就不知道能不能和前面组成回文串。

所以更好的想法是二维:

dp[i][j]表示区间[i,j]内的最长回文子序列的长度。

那么状态转移方程为:
d p [ i ] [ j ] = 1 w h e r e i = = j dp[i][j]=1\space where\space i==j dp[i][j]=1 where i==j
d p [ i ] [ j ] = 1 + s [ i ] = = s [ j ] w h e r e i = = j − 1 dp[i][j]=1+s[i]==s[j]\space where\space i==j-1 dp[i][j]=1+s[i]==s[j] where i==j−1
d p [ i ] [ j ] = 2 + d p [ i + 1 ] [ j − 1 ] w h e r e s [ i ] = = s [ j ] dp[i][j]=2+dp[i+1][j-1]\space where\space s[i]==s[j] dp[i][j]=2+dp[i+1][j−1] where s[i]==s[j]
d p [ i ] [ j ] = m a x ( d p [ i + 1 ] [ j ] , d p [ i ] [ j − 1 ] ) o t h e r dp[i][j]=max(dp[i+1][j],dp[i][j-1])\space other dp[i][j]=max(dp[i+1][j],dp[i][j−1]) other

具体实现:

cpp 复制代码
class Solution {
public:
    int longestPalindromeSubseq(string s) {
        int n=s.size(),ret=0;
        vector<vector<int>>dp(n,vector<int>(n));
        for(int i=n-1;i>=0;--i){
            for(int j=i;j<n;++j){
                if(i==j)dp[i][j]=1;
                else if(i==j-1)dp[i][j]=1+(s[i]==s[j]);
                else if(s[i]==s[j])dp[i][j]=2+dp[i+1][j-1];
                else dp[i][j]=max(dp[i+1][j],dp[i][j-1]);
                ret=max(dp[i][j],ret);
            }
        }
        return ret;
    }
};

时间复杂度和空间复杂度都是O(n2)

根据上一题的经验,我们这个dp表每次更新都只需要下一行的元素,所以我们可以进行空间优化:

cpp 复制代码
class Solution {
public:
    int longestPalindromeSubseq(string s) {
        int n=s.size(),ret=0,prev,cur;
        vector<int>dp(n);
        for(int i=n-1;i>=0;--i){
            for(int j=i;j<n;++j){
                cur=dp[j];
                if(i==j)dp[j]=1;
                else if(i==j-1)dp[j]=1+(s[i]==s[j]);
                else if(s[i]==s[j])dp[j]=2+prev;
                else dp[j]=max(cur,dp[j-1]);
                prev=cur;
                ret=max(dp[j],ret);
            }
        }
        return ret;
    }
};

现在时间复杂度是O(n2),空间复杂度是O(n)

让字符串成为回文串的最少插入次数

题目描述

让字符串成为回文串的最少插入次数

给你一个字符串 s ,每一次操作你都可以在字符串的任意位置插入任意字符。

请你返回让 s 成为回文串的 最少操作次数 。

「回文串」是正读和反读都相同的字符串。

示例 1:

输入:s = "zzazz"

输出:0

解释:字符串 "zzazz" 已经是回文串了,所以不需要做任何插入操作。

示例 2:

输入:s = "mbadm"

输出:2

解释:字符串可变为 "mbdadbm" 或者 "mdbabdm" 。

示例 3:

输入:s = "leetcode"

输出:5

解释:插入 5 个字符后字符串变为 "leetcodocteel" 。

提示:

  • 1 <= s.length <= 500
  • s 中所有字符都是小写字母。

算法原理和实现

我们用dp[i][j]表示[i,j]子串变成回文字符串的最小插入次数。

首先考虑i==j时,dp[i][j]=0。

其次考虑i==j-1时,即只有两个字符,显然当两个字符相等时dp[i][j]=0,不相等时dp[i][j]=1。

最后我们考虑剩余情况。

当s[i]==s[j]时:

末端两个字符已经配对了,那说明我们肯定不需要管这两个字符。所以dp[i][j]=dp[i+1][j-1]。

当s[i]!=s[j]时:

我们这两个元素闭不可能作为回文串的两端,所以至少要插一个字符到两端和他们相匹配,这又分为两种。

第一种是我们在右边插入一个x:

这时我们插入次数就加一了,剩下只需要管i+1,j这部分的最小插入次数,即回到s[i]==s[j+1]的情况。

第二种自然是往左边插入z:

我们只需管[i,j-1]的情况。

所以dp[i][j]=min(dp[i+1][j],dp[i][j-1])+1

具体实现:

cpp 复制代码
class Solution {
public:
    int minInsertions(string s) {
        int n=s.size();
        vector<vector<int>>dp(n,vector<int>(n));
        for(int i=n-1;i>=0;--i){
            for(int j=i;j<n;++j){
                if(i==j)dp[i][j]=0;
                else if(i==j-1)dp[i][j]=s[i]!=s[j];
                else if(s[i]==s[j])dp[i][j]=dp[i+1][j-1];
                else dp[i][j]=min(dp[i+1][j],dp[i][j-1])+1;
            }
        }
        return dp[0][n-1];
    }
};

时间复杂度和空间复杂度都是O(n2)。

不难发现我们每次更新都只需要下一行的状态,所以我们可以进行空间优化:

cpp 复制代码
class Solution {
public:
    int minInsertions(string s) {
        int n=s.size(),cur,prev;
        vector<int>dp(n);
        for(int i=n-1;i>=0;--i){
            for(int j=i;j<n;++j){
                cur=dp[j];
                if(i==j)dp[j]=0;
                else if(i==j-1)dp[j]=s[i]!=s[j];
                else if(s[i]==s[j])dp[j]=prev;
                else dp[j]=min(cur,dp[j-1])+1;
                prev=cur;
            }
            cout<<endl;
        }
        return dp[n-1];
    }
};

时间复杂度O(n2),空间复杂度O(n)。

相关推荐
YGGP2 小时前
【Golang】LeetCode 56. 合并区间
算法·leetcode·职场和发展
你怎么知道我是队长2 小时前
C语言---排序算法12---计数排序法
c语言·算法·排序算法
fu的博客2 小时前
【数据结构2】带头结点·单向链表实现
数据结构·算法·链表
近津薪荼2 小时前
优选算法——前缀和(6):和可被 K 整除的子数组
c++·算法
lifallen2 小时前
线性基 (Linear Basis)
数据结构·算法
twilight_4692 小时前
人工智能数学基础——第二章 高等数学基础
人工智能·算法·机器学习
_OP_CHEN2 小时前
【算法提高篇】(二)线段树之区间修改:懒标记的核心奥义与实战实现
算法·蓝桥杯·线段树·c/c++·区间查询·acm/icpc·懒标记
啊阿狸不会拉杆2 小时前
《机器学习导论》第 18 章-增强学习
人工智能·python·学习·算法·机器学习·智能体·增强学习
田里的水稻2 小时前
FA_规划和控制(PC)-D*规划
人工智能·算法·数学建模·机器人·自动驾驶