回文字符串问题


什么是回文字符串?
「正向和反向观察得到的字符串顺序是相同的」 或者 「关于中心对称的字符串」
比如字符串:abcba 和 abccba,都是回文串

常用的判断回文子串相关的两种方法:「中心扩展」和「动态规划1
判断回文子序列的方法一般是「动态规划2

此外还有一种在线性时间内求解最长回文子串的算法:Manacher 算法

中心扩展法

基于回文字符串对称性的特点,我们可以采取从中心向两边扩展的方法,得到回文子串,回文中心分为两种情况,以单个字母为中心 和 以两个字母为中心:
1)以单个字母为回文中心

2)以两个字母为回文中心

假设字符串长度为 l e n len len,则回文中心一共有 2 × l e n − 1 2 × len - 1 2×len1 个,分别是 l e n len len 个单字符和 l e n − 1 len - 1 len1 个双字符。
总结:「中心扩展法」的思想就是遍历以一个字符或两个字符为中心可得到的回文子串。中心拓展法适用于连续子串是否是回文串的判断,不太适用于不连续子序列是否是回文串的判断。
连续子序列一般用第二类动态规划方法,状态量 d p [ i ] [ j ] dp[i][j] dp[i][j]为字符串在 [ i , j ] [i,j] [i,j]区间的回文序列个数(int 类型)。

第一类动态规划法

由于长字符串会依赖短字符串的回文串,所以我们可以采用动态规划来实现。

这里需要二维的 d p [ ] [ ] dp[][] dp[][] 数组,设置状态量: d p [ i ] [ j ] dp[i][j] dp[i][j] 表示字符串 s s s [ i , j ] [i,j] [i,j]区间的子串是否是一个回文串。
当我们判断 [ i . . j ] [i..j] [i..j] 是否为回文子串时,只需要判断 s [ i ] = = s [ j ] s[i] == s[j] s[i]==s[j],同时判断 [ i − 1.. j − 1 ] [i-1..j-1] [i1..j1] 是否为回文子串即可
需要注意有两种特殊情况: [ i , i ] [i, i] [i,i] or [ i , i + 1 ] [i, i + 1] [i,i+1],即:子串长度为 1 或者 2。所以需要加一个条件限定 j − i < 2 j - i < 2 ji<2
状态转移方程如下:

dp[i][j] = (s[i] == s[j]) && (j - i < 2 || dp[i + 1][j - 1])

解释:当 s [ i ] = = s [ j ] & & ( j − i < 2 ∣ ∣ d p [ i + 1 ] [ j − 1 ] ) s[i] == s[j] \&\& (j - i < 2 || dp[i + 1][j - 1]) s[i]==s[j]&&(ji<2∣∣dp[i+1][j1]) 时, d p [ i ] [ j ] = t r u e dp[i][j]=true dp[i][j]=true,否则为 f a l s e false false

Manacher (马拉车)算法

复杂度为 O(n) 的Manacher 算法是在线性时间内求解最长回文子串的算法。也可以用于求解回文串的个数

Manacher 的基本原理
定义一个新概念臂长,表示中心扩展算法向外扩展的长度。如果一个位置的最大回文字符串长度为 2 * length + 1 ,其臂长为 length。

Manacher 算法也会面临奇数长度和偶数长度的问题,它的处理方式是在所有的相邻字符中间插入 # \# #,比如 a b a a abaa abaa 会被处理成 # a # b # a # a # \#a\#b\#a\#a\# #a#b#a#a#,这样可以保证所有找到的回文串都是奇数长度的,以任意一个字符为回文中心,既可以包含原来的奇数长度的情况,也可以包含原来偶数长度的情况。假设原字符串为 S,经过这个处理之后的字符串为 s。

我们用 f(i) 来表示以 s 的第 i 位为回文中心,可以拓展出的最大回文半径,那么 f(i) - 1 就是以 i 为中心的最大回文串长度 。
(后续内容详见超链接,此方法只做了解)

回文子串问题

回文子串问题一般用中心拓展与第一类动态规划方法求解。
可以用这道题的题解当模板,进行理解学习:
647. 回文子串
给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。
回文字符串 是正着读和倒过来读一样的字符串。
子字符串 是字符串中的由连续字符组成的一个序列。
具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。

示例 1:
输入:s = “abc”
输出:3
解释:三个回文子串: “a”, “b”, “c”

示例 2:
输入:s = “aaa”
输出:6
解释:6个回文子串: “a”, “a”, “a”, “aa”, “aa”, “aaa”

法一:中心拓展法
「中心扩展法」的思想就是遍历以一个字符或两个字符为中心可得到的回文子串,分两种情况调用isPalindromic(string s, int i, int j)函数。

//法一:中心扩展法
 	int ans = 0;
    int countSubstrings(string s) {
    
    
        int n = s.size();
        for(int i = 0; i < n; ++i){
    
    
        	// 以单个字母为中心的情况
            isPalindromic(s, i, i);
            // 以两个字母为中心的情况
            isPalindromic(s, i, i + 1);
        }
        return ans;
    }
    void isPalindromic(string s, int i, int j){
    
    
        while(i >= 0 && j < s.size()){
    
    
            if(s[i] != s[j]) return;
                ++ans;
                ++j;
                --i;
        }
    }

中心拓展法的另一种解法:
长度为 n n n 的字符串会生成 2 n − 1 2n-1 2n1 组回文中心 [ l i , r i ] [l_i, r_i] [li,ri],其中 l i = i / 2 l_i = i/2 li=i/2 r i = l i + ( i % 2 ) r_i = l_i + (i \% 2) ri=li+(i%2) 。这样我们只要从 0 0 0 2 n − 2 2n - 2 2n2 遍历 i i i,就可以得到所有可能的回文中心,这样就把奇数长度和偶数长度两种情况统一起来了。

	int countSubstrings(string s) {
    
    
        int n = s.size();
        int num = 0;
        for(int i = 0; i < 2 * n - 1; ++i){
    
    
            int l = i / 2;
            int r = l + ( i % 2);
            while(l >= 0 && r < n && s[l] == s[r]){
    
    
                ++num;
                --l;
                ++r;
            }
        }       
        return num;
    }

法二:动态规划
由于长字符串会依赖短字符串的回文串数量,所以我们可以采用动态规划来实现。
d p [ i ] [ j ] dp[i][j] dp[i][j] 表示字符串 s s s [ i , j ] [i,j] [i,j]区间的子串是否是一个回文串,当 s [ i ] = = s [ j ] s[i]==s[j] s[i]==s[j]时,如果 d p [ i ] [ j ] dp[i][j] dp[i][j]是回文串,则要么 [ i , j ] [i, j] [i,j]区间仅有一个或两个字符,要么 d p [ i + 1 ] [ j − 1 ] dp[i+1][j-1] dp[i+1][j1] 是一个回文串

	//法二:动态规划  
	//dp[i][j] 表示字符串s在[i,j]区间的子串是否是一个回文
	//当 s[i]==s[j] 时,要么[i, j]区间仅有一个或两个字符,要么看 dp[i+1][j-1] 是不是一个回文串    
    int countSubstrings(string s) {
    
    
        int n = s.size();
        int ans = 0;
        //n行n列,其实只用到下三角
        vector<vector<bool>> dp(n, vector<bool>(n));
        for(int j = 0; j < n; ++j){
    
    
            for(int i = 0; i <= j; ++i){
    
    
                if(s[i] == s[j] && (j - i < 2 || dp[i + 1][j - 1])){
    
    
                    dp[i][j] = true;
                    ++ans;
                }
            }
        }
        return ans;
    }

用下面这道题加深对上面模板的套用和理解。
5. 最长回文子串
给你一个字符串 s,找到 s 中最长的回文子串。

示例 1:
输入:s = “babad”
输出:“bab”
解释:“aba” 同样是符合题意的答案。

法一:中心扩展法
还是沿用了上一题的模板,唯一的改动就是记录一下最长子串的起始点和长度。

	//法一:中心扩展法
	int maxi = 0, maxlen = 0;
    string longestPalindrome(string s) {
    
    
        int n = s.size();
        for(int i = 0; i < n; ++i){
    
    
            ispalindromic(s, i, i);
            ispalindromic(s, i, i + 1);
        }
        return s.substr(maxi, maxlen);
    }
    void ispalindromic(string& s, int i, int j){
    
    
        while(i >= 0 && j < s.size()){
    
    
            if(s[i] != s[j]) return;
            if(j - i + 1 > maxlen){
    
    
                maxi = i;
                maxlen = j - i + 1;
            }
            --i;
            ++j;
        }
    }

法二:动态规划
由于长字符串会依赖短字符串的回文串长度,所以我们可以采用动态规划来实现。
沿用了上一题的模板,唯一的改动就是记录一下最长子串的起始点和长度。
d p [ i ] [ j ] dp[i][j] dp[i][j] 表示字符串 s s s [ i , j ] [i,j] [i,j]区间的子串是否是一个回文串,当 s [ i ] = = s [ j ] s[i]==s[j] s[i]==s[j]时,如果 d p [ i ] [ j ] dp[i][j] dp[i][j]是回文串,则要么 [ i , j ] [i, j] [i,j]区间仅有一个或两个字符,要么 d p [ i + 1 ] [ j − 1 ] dp[i+1][j-1] dp[i+1][j1] 是一个回文串

	//法二:动态规划
	string longestPalindrome(string s) {
    
    
        int maxi = 0,  maxlen = 0;
        int n = s.size();
        vector<vector<bool>> dp(n, vector<bool>(n));
        for(int j = 0; j < n; ++j){
    
    
            for(int i = 0; i <= j; ++i){
    
    
                if(s[i] == s[j] && (j - i < 2 || dp[i + 1][j - 1])){
    
    
                    dp[i][j] = true;
                    if(j - i + 1 > maxlen){
    
    
                        maxlen = j - i + 1;
                        maxi = i;
                    }
                }
            }
        }
        return s.substr(maxi, maxlen);
    }

回文子序列问题

回文子序列问题一般用第二类动态规划解法。
这几道题需要另外一种动态规划状态量定义方法。状态量 d p [ i ] [ j ] dp[i][j] dp[i][j]改为字符串在 [ i , j ] [i,j] [i,j]区间的回文序列个数(int 类型)了,而不是之前的字符串 s s s [ i , j ] [i,j] [i,j]区间的子串是否是一个回文串(布尔类型)了。然后按照序列长度 l e n len len进行动态规划。可以理解为另外一套动态规划模板。
516. 最长回文子序列
对于一个子序列而言,如果它是回文子序列,并且长度大于 2,那么将它首尾的两个字符去除之后,它仍然是个回文子序列。因此可以用动态规划的方法计算给定字符串的最长回文子序列。

①状态量的定义
dp [ i ] [ j ] \textit{dp}[i][j] dp[i][j] 表示字符串 s 的下标范围 [ i , j ] [i, j] [i,j] 内的最长回文子序列的长度。假设字符串 s 的长度为 n,则只有当 0 ≤ i ≤ j < n 0 \le i \le j < n 0ij<n 时,才会有 dp [ i ] [ j ] > 0 \textit{dp}[i][j] > 0 dp[i][j]>0,否则 dp [ i ] [ j ] = 0 \textit{dp}[i][j] = 0 dp[i][j]=0(在二维数组中已经默认置为0了,不需要额外操作)。

②边界初始化
由于任何长度为 1 的子序列都是回文子序列,因此动态规划的边界情况是,对任意 0 ≤ i < n 0 \le i < n 0i<n,都有 dp [ i ] [ i ] = 1 \textit{dp}[i][i] = 1 dp[i][i]=1

③状态转移方程推导
i < j i < j i<j 时,计算 dp [ i ] [ j ] \textit{dp}[i][j] dp[i][j] 需要分别考虑 s [ i ] s[i] s[i] s [ j ] s[j] s[j] 相等和不相等的情况:

1)如果 s [ i ] = s [ j ] s[i] = s[j] s[i]=s[j],则首先得到 s 的下标范围 [ i + 1 , j − 1 ] [i+1, j-1] [i+1,j1] 内的最长回文子序列,然后在该子序列的首尾分别添加 s [ i ] s[i] s[i] s [ j ] s[j] s[j],即可得到 s 的下标范围 [ i , j ] [i, j] [i,j] 内的最长回文子序列,因此 dp [ i ] [ j ] = dp [ i + 1 ] [ j − 1 ] + 2 \textit{dp}[i][j] = \textit{dp}[i+1][j-1] + 2 dp[i][j]=dp[i+1][j1]+2

2)如果 s [ i ] ≠ s [ j ] s[i] \ne s[j] s[i]=s[j],则 s [ i ] s[i] s[i] s [ j ] s[j] s[j] 不可能同时作为同一个回文子序列的首尾,因此 dp [ i ] [ j ] = max ⁡ ( dp [ i + 1 ] [ j ] , dp [ i ] [ j − 1 ] ) \textit{dp}[i][j] = \max(\textit{dp}[i+1][j], \textit{dp}[i][j-1]) dp[i][j]=max(dp[i+1][j],dp[i][j1]),注意这里要取两头各自去掉一个字符情况中的最大值,而不是只有一种同时去掉两头的情况。

由于状态转移方程都是从长度 l e n len len 较短的子序列向长度较长的子序列转移,因此需要注意动态规划的循环顺序。

最终得到的 dp [ 0 ] [ n − 1 ] \textit{dp}[0][n-1] dp[0][n1] 即为字符串 s s s 的最长回文子序列的长度。

	int longestPalindromeSubseq(string s) {
    
    
        int n = s.size();
        if(n == 0){
    
    
            return 0;
        }
        vector<vector<int>> dp(n, vector<int>(n));
        //只有当 0≤i≤j<n 时,才会有 dp[i][j]>0,否则 dp[i][j]=0(在二维数组中已经默认置为0了,不需要额外操作)
        // for(int i = 0; i < n - 1; ++i){
    
    
        //     dp[i + 1][i] = 0; 
        // }
        for(int i = 0; i < n; ++i){
    
    
            dp[i][i] = 1;
        }
        for(int len = 2; len <= n; ++len){
    
    
            for(int i = 0; i + len <= n; ++i){
    
    
                int j = i + len - 1;
                if(s[i] == s[j]){
    
    
                    dp[i][j] = dp[i + 1][j - 1] + 2;
                }else{
    
    
                //注意这里要取两头各自去掉一个字符情况中的最大值,
                //而不是只有一种同时去掉两头的情况
                    dp[i][j] = max(dp[i][j - 1], dp[i + 1][j]);
                }
            }
        }
        return dp[0][n - 1];
    }

下面这道题也是相似的解法,不过难度更大一些。

730. 统计不同回文子序列
给定一个字符串 s,返回 s 中不同的非空「回文子序列」个数 。
通过从 s 中删除 0 个或多个字符来获得子序列。子序列不一定连续,仅保证前后顺序一致
如果一个字符序列与它反转后的字符序列一致,那么它是「回文字符序列」。
如果有某个 i i i , 满足 a i ! = b i a_i != b_i ai!=bi ,则两个序列 a 1 , a 2 , . . . a_1, a_2, ... a1,a2,... b 1 , b − 2 , . . . b_1, b-2, ... b1,b2,...不同。

注意:
结果可能很大,你需要对 1 0 9 + 7 10^9 + 7 109+7 取模 。

示例 1:
输入:s = ‘bccb’
输出:6
解释:6 个不同的非空回文子字符序列分别为:‘b’, ‘c’, ‘bb’, ‘cc’, ‘bcb’, ‘bccb’。
注意:‘bcb’ 虽然出现两次但仅计数一次。

示例 2:
输入:s =‘abcdabcdabcdabcdabcdabcdabcdabcddcbadcbadcbadcbadcbadcbadcbadcba’
输出:104860361
解释:共有 3104860382 个不同的非空回文子序列,104860361 对 109 + 7 取模后的值。

【解答】
由于长字符串会依赖短字符串的回文序列数量,所以我们可以采用动态规划来实现。
d p [ i ] [ j ] dp[i][j] dp[i][j] 表示字符串从 i i i j j j 的回文序列个数,我们可以将长字符串看作短字符串左右加上两个字符。
于是我们有 s [ i , j ] = s [ i ] + s [ i + 1 , j − 1 ] + s [ j ] s[i,j] = s[i] + s [i+1,j-1] + s[j] s[i,j]=s[i]+s[i+1,j1]+s[j],如:“bccb” 可以看作 “cc"两边分别加上"b”,此时我们分情况进行讨论
(1)若 s [ i ] = = s [ j ] s[i] == s[j] s[i]==s[j],相当于我们给 s [ i + 1 , j − 1 ] s[i+1,j-1] s[i+1,j1] 左右加上两个相同的字符,然后我们计算回文序列的个数。

s [ i + 1 , j − 1 ] s[i+1,j-1] s[i+1,j1]中没有字符和 s [ i ] s[i] s[i]相等
设有字符串"bcb",则"bcb"的回文子序列是:b、c、bb、bcb
若两边加上相同的字符,相当于给"bcb"的回文子序列左右个加一个相同字符,仍然构成回文子序列
假设我们给"bcb"左右加一个字符"a",则相当于给"bcb"的子序列都左右加一个字符可构成新的回文子序列:

再加上"a"(字符本身就是一个回文子序列)和"aa"(两个相同字符的回文子序列)
所以此时 d p [ i ] [ j ] = 2 d p [ i + 1 ] [ j − 1 ] + 2 dp[i][j] = 2dp[i+1][j-1] + 2 dp[i][j]=2dp[i+1][j1]+2(本身的4个+新生成的4个+2个单独生成的)

s [ i + 1 , j − 1 ] s[i+1,j-1] s[i+1,j1]中有一个字符和s[i]相等
假设有一个字符相等,则之前已经记录了此单字符的回文子序列(只能加上"aa",不能加"a")
所以此时 d p [ i ] [ j ] = 2 d p [ i + 1 ] [ j − 1 ] + 1 dp[i][j] = 2dp[i+1][j-1] + 1 dp[i][j]=2dp[i+1][j1]+1(本身的4个+新生成的4个+1个单独生成的)

s [ i + 1 , j − 1 ] s[i+1,j-1] s[i+1,j1]中有两个及以上字符和 s [ i ] s[i] s[i]相等
若有两个及以上的字符,则我们需要找到其位置,并删掉重复计算的回文子序列,并且两个单独的之前也已经计算。
假设有字符串"dabcbad",我们向两边加入字符"a"
则此时的"a"字符会和中间的"bcb"组成重复的回文子序列,因为之前已经有"a"和"bcb"组成回文子序列

(2)若 s [ i ] ! = s [ j ] s[i] != s[j] s[i]!=s[j],则我们给之前任何一个回文子序列左右加上 s [ i ] s[i] s[i] s [ j ] s[j] s[j]都不能组成回文子序列,只能单独计算

综上所述,状态转移方程为:

按照序列长度 l e n len len进行动态规划,可保证长序列计算时短序列的结果都已经出来,可以直接使用子问题保存的结果。
【注意点】
int 是32位的,第一位是符号位,最大正值是 2 31 2^{31} 231,取值范围是-2147483648(10位)——2147483647,如果超出最大值就会产生进位将符号位置1,变成负数。因此当数量很大时,常常需要对大质数 1 0 9 + 7 10^9 + 7 109+7取模%运算,使结果变小,当然答案也会做相应处理。在C++中, 1 0 9 + 7 10^9 + 7 109+71e9+7表示。仅有加法运算时,最后直接取模缩小就行,当还有减法时,还需要对负数加模运算。

	int countPalindromicSubsequences(string s) {
    
    
        int MOD = 1e9+7; //当结果很大时,需要对大质数10^9 + 7取模%运算,使结果变小,答案也会进行相应处理
        int n = s.size();
        vector<vector<int>> dp(n, vector<int>(n));
        //因为要求子序列无重复,因此按照序列长度进行动态规划,
        //而不是按照j不断向右扩张的[i,j]序列进行动态规划,这样可能导致重复
        //长度为1的[i,j]序列,一个单字符是一个回文子序列
        for(int i = 0; i < n; ++i){
    
    
            dp[i][i] = 1;
        }
        //按照序列长度len进行动态规划,可保证长序列计算时短序列的结果都已经出来,可以直接用
        //从长度为2的子串开始计算
        for(int len = 2; len <= n; ++len){
    
    
        //挨个计算长度为len的子串的回文子序列个数
            for(int i = 0; i + len <= n; ++i){
    
    
                int j = i + len - 1;
                //情况(1) 相等
                if(s[i] == s[j]){
    
    
                    int l = i + 1, r = j - 1;
                    //找到第一个和s[i]相同的字符
                    while(l <= r && s[l] != s[i]) ++l;
                    //找到第一个和s[j]相同的字符
                    while(r >= l && s[r] != s[j]) --r;
                     //情况① 没有重复字符
                    if(l > r) dp[i][j] = 2 * dp[i + 1][j - 1] + 2;
                    //情况② 出现一个重复字符
                    else if(l == r) dp[i][j] = 2 * dp[i + 1][j - 1] + 1;
                    //情况③ 有两个及两个以上
                    else dp[i][j] = 2 * dp[i + 1][j - 1] - dp[l + 1][r - 1];
                }else{
    
    
                //情况(2) 不相等
                    //前面的每个dp值都已经取过模,很大的int也会缩小了,因此减法运算时可能会出现负数
                    //仅有加法运算时,最后直接取模缩小就行,当还有减法时,还需要对负数加模运算
                    dp[i][j] = dp[i + 1][j] + dp[i][j - 1] - dp[i + 1][j - 1];
                }
                //处理超范围结果,signed integer overflow
                //如果超出int最大值就会产生进位将符号位置1,变成负数。
                dp[i][j] = dp[i][j] >= 0 ? dp[i][j] % MOD : dp[i][j] + MOD;
            }
        }
        return dp[0][n - 1];
    }

猜你喜欢

转载自blog.csdn.net/XiaoFengsen/article/details/125234869