1. 程式人生 > >[LeetCode] Count Different Palindromic Subsequences 計數不同的迴文子序列的個數

[LeetCode] Count Different Palindromic Subsequences 計數不同的迴文子序列的個數

Given a string S, find the number of different non-empty palindromic subsequences in S, and return that number modulo 10^9 + 7.

A subsequence of a string S is obtained by deleting 0 or more characters from S.

A sequence is palindromic if it is equal to the sequence reversed.

Two sequences A_1, A_2, ...

 and B_1, B_2, ... are different if there is some i for which A_i != B_i.

Example 1:

Input: 
S = 'bccb'
Output: 6
Explanation: 
The 6 different non-empty palindromic subsequences are 'b', 'c', 'bb', 'cc', 'bcb', 'bccb'.
Note that 'bcb' is counted only once, even though it occurs twice.

Example 2:

Input: 
S = 'abcdabcdabcdabcdabcdabcdabcdabcddcbadcbadcbadcbadcbadcbadcbadcba'
Output: 104860361
Explanation: 
There are 3104860382 different non-empty palindromic subsequences, which is 104860361 modulo 10^9 + 7.

Note:

  • The length of S will be in the range [1, 1000].
  • Each character S[i] will be in the set {'a', 'b', 'c', 'd'}
    .

這道題給了給了我們一個字串,讓我們求出所有的非空迴文子序列的個數,雖然這題限制了字元只有四種,但是我們還是按一般的情況來解吧,可以有26個字母。然後說最終結果要對一個很大的數字取餘,這就暗示了結果會是一個很大的值,那麼對於這種問題一般都是用DP或者是帶記憶陣列memo的遞迴來解,二者的本質其實是一樣的。我們先來看帶記憶陣列memo的遞迴解法,這種解法的思路是一層一層剝洋蔥,比如"bccb",按照字母來剝,先剝字母b,確定最外層"b _ _ b",這會產生兩個迴文子序列"b"和"bb",然後遞迴進中間的部分,把中間的迴文子序列個數算出來加到結果res中,然後開始剝字母c,找到最外層"cc",此時會產生兩個迴文子序列"c"和"cc",然後由於中間沒有字串了,所以遞迴返回0,按照這種方法就可以算出所有的迴文子序列了。

我們建立一個二維陣列chars,外層長度為26,裡面放一個空陣列。這是為了統計每個字母在原字串中出現的位置,然後定義一個二維記憶陣列memo,其中memo[i][j]表示第i個字元到第j個字元之間的子字串中的迴文子序列的個數,初始化均為0。然後我們遍歷字串S,將每個字元的位置加入其對應的陣列中,比如對於"bccb",那麼有:

b -> {0, 3}

c -> {1, 2}

然後在[0, n]的範圍內呼叫遞迴函式,在遞迴函式中,首先判斷如果start大於等於end,返回0。如果當前位置在memo的值大於0,說明當前情況已經計算過了,直接返回memo陣列中的值。否則進行所有字母的遍歷,如果某個字母對應的陣列中沒有值,說明該字母不曾在字串中出現,跳過。然後我們在字母陣列中查詢第一個不小於start的位置,查詢第一個小於end的位置,當前迴圈中,start為0,end為4,當前處理字母b,我們的new_start指向0,new_end指向3,如果當前new_start指向了end(),或者其指向的位置大於end,說明當前範圍內沒有字母b,直接跳過,否則結果res自增1,因為此時new_start存在,至少有個單個的字母b,也可以當作迴文子序列,然後看new_start和new_end如果不相同,說明兩者各指向了不同的b,此時res應自增1,因為又增加了一個新的迴文子序列"bb",下面就是對中間部分呼叫遞迴函數了,把返回值加到結果res中。此時字母b就處理完了,現在處理字母c,此時的start還是0,end還是4,new_start指向1,new_end指向2,跟上面的分析相同,new_start在範圍內,結果自增1,因為加上了"c",然後new_start和new_end不同,結果res再自增1,因為加上了"cc",其中間沒有字元了,呼叫遞迴的結果是0,for迴圈結束,我們將memo[start][end]的值對超大數取餘,將該值返回即可,參見程式碼如下:

解法一:

class Solution {
public:
    int countPalindromicSubsequences(string S) {
        int n = S.size();
        vector<vector<int>> chars(26, vector<int>());
        vector<vector<int>> memo(n + 1, vector<int>(n + 1, 0));
        for (int i = 0; i < n; ++i) {
            chars[S[i] - 'a'].push_back(i);
        }
        return helper(S, chars, 0, n, memo);
    }
    int helper(string S, vector<vector<int>>& chars, int start, int end, vector<vector<int>>& memo) {
        if (start >= end) return 0;
        if (memo[start][end] > 0) return memo[start][end];
        long res = 0;
        for (int i = 0; i < 26; ++i) {
            if (chars[i].empty()) continue;
            auto new_start = lower_bound(chars[i].begin(), chars[i].end(), start);
            auto new_end = lower_bound(chars[i].begin(), chars[i].end(), end) - 1;
            if (new_start == chars[i].end() || *new_start >= end) continue;
            ++res;
            if (new_start != new_end) ++res;
            res += helper(S, chars, *new_start + 1, *new_end, memo);
        }
        memo[start][end] = res % int(1e9 + 7);
        return memo[start][end];
    }
};

我們再來看一種迭代的寫法,使用一個二維的dp陣列,其中dp[i][j]表示子字串[i, j]中的不同迴文子序列的個數,我們初始化dp[i][i]為1,因為任意一個單個字元就是一個迴文子序列,其餘均為0。這裡的更新順序不是正向,也不是逆向,而是斜著更新,對於"bccb"的例子,其最終dp陣列如下,我們可以看到其更新順序分別是紅-綠-藍-橙。

  b c c b
b 1
2 3 6 c 0 1 2 3 c 0 0 1 2 b 0 0 0 1

這樣更新的好處是,更新當前位置時,其左,下,和左下位置的dp值均已存在,而當前位置的dp值需要用到這三個位置的dp值。我們觀察上面的dp陣列,可以發現當S[i]不等於S[j]的時候,dp[i][j] = dp[i][j - 1] + dp[i + 1][j] - dp[i + 1][j - 1],即當前的dp值等於左邊值加下邊值減去左下值,因為算左邊值的時候包括了左下的所有情況,而算下邊值的時候也包括了左下值的所有情況,那麼左下值就多算了一遍,所以要減去。而當S[i]等於S[j]的時候,情況就比較複雜了,需要分情況討論,因為我們不知道中間還有幾個和S[i]相等的值。舉個簡單的例子,比如"aba"和"aaa",當i = 0, j = 2的時候,兩個字串均有S[i] == S[j],此時二者都新增兩個子序列"a"和"aa",但是"aba"中間的"b"就可以加到結果res中,而"aaa"中的"a"就不能加了,因為和外層的單獨"a"重複了。我們的目標就要找到中間重複的"a"。所以我們讓left = i + 1, right = j - 1,然後對left進行while迴圈,如果left <= right, 且S[left] != S[i]的時候,left向右移動一個;同理,對right進行while迴圈,如果left <= right, 且S[right] != S[i]的時候,left向左移動一個。這樣最終left和right值就有三種情況:

1. 當left > righ時,說明中間沒有和S[i]相同的字母了,就是"aba"這種情況,那麼就有dp[i][j] = dp[i + 1][j - 1] * 2 + 2,其中dp[i + 1][j - 1]是中間部分的迴文子序列個數,為啥要乘2呢,因為中間的所有子序列可以單獨存在,也可以再外面包裹上字母a,所以是成對出現的,要乘2。加2的原因是外層的"a"和"aa"也要統計上。

2. 當left = right時,說明中間只有一個和S[i]相同的字母,就是"aaa"這種情況,那麼有dp[i][j] = dp[i + 1][j - 1] * 2 + 1,其中乘2的部分跟上面的原因相同,加1的原因是單個字母"a"的情況已經在中間部分算過了,外層就只能再加上個"aa"了。

3. 當left < right時,說明中間至少有兩個和S[i]相同的字母,就是"aabaa"這種情況,那麼有dp[i][j] = dp[i + 1][j - 1] * 2 - dp[left + 1][right - 1],其中乘2的部分跟上面的原因相同,要減去left和right中間部分的子序列個數的原因是其被計算了兩遍,要將多餘的減掉。

參見程式碼如下:

解法二:

class Solution {
public:
    int countPalindromicSubsequences(string S) {
        int n = S.size(), M = 1e9 + 7;
        vector<vector<int>> dp(n, vector<int>(n, 0));
        for (int i = 0; i < n; ++i) dp[i][i] = 1;
        for (int len = 1; len < n; ++len) {
            for (int i = 0; i < n - len; ++i) {
                int j = i + len;
                if (S[i] == S[j]) {
                    int left = i + 1, right = j - 1;
                    while (left <= right && S[left] != S[i]) ++left;
                    while (left <= right && S[right] != S[i]) --right;
                    if (left > right) {
                        dp[i][j] = dp[i + 1][j - 1] * 2 + 2;
                    } else if (left == right) {
                        dp[i][j] = dp[i + 1][j - 1] * 2 + 1;
                    } else {
                        dp[i][j] = dp[i + 1][j - 1] * 2 - dp[left + 1][right - 1];
                    }
                } else {
                    dp[i][j] = dp[i][j - 1] + dp[i + 1][j] - dp[i + 1][j - 1];
                }
                dp[i][j] = (dp[i][j] < 0) ? dp[i][j] + M : dp[i][j] % M;
            }
        }
        return dp[0][n - 1];
    }
};

討論:這道題確實是一道很難的題,和它類似的題目還有幾道,雖然那些題有的還有非DP解法,但是DP解法始終是核心的,也是我們最應該掌握的方法。首先我們要分清子串和子序列的題,個人感覺子序列要更難一些。在之前那道Longest Palindromic Subsequence中要我們求最長的迴文子序列,我們需要逆向遍歷dp陣列,當s[i]和s[j]相同時,長度為中間部分的dp值加2,否則就是左邊值和下邊值中的較大值,因為是子序列,不匹配就可以忽略當前字元。而對於迴文子串的問題,比如Longest Palindromic SubstringPalindromic Substrings,一個是求最長的迴文子串,一個是求所有的迴文子串個數,他們的dp定義是看子串[i, j]是否是迴文串,求最長迴文子串就是維護一個最大值,不停用當前迴文子串的長度更新這個最大值,同時更新最大值的左右邊界。而求所有迴文子串的個數就是如果當前dp[i][j]判斷是迴文串,計數器就自增1。而判斷當前dp[i][j]是否是迴文串的核心就是s[i]==s[j],且i,j中間沒有字元了,或者中間的dp值為true。

類似題目:

參考資料: