最長迴文子序列:子序列問題通用思路

子序列問題是常見的算法問題,而且並不好解決。

首先,子序列問題本身就相對子串、子數組更困難一些,因爲前者是不連續的序列,而後兩者是連續的,就算窮舉你都不一定會,更別說求解相關的算法問題了。

而且,子序列問題很可能涉及到兩個字符串,比如前文「最長公共子序列」,如果沒有一定的處理經驗,真的不容易想出來。所以本文就來扒一扒子序列問題的套路,其實就有兩種模板,相關問題只要往這兩種思路上想,十拿九穩。

一般來說,這類問題都是讓你求一個最長子序列,因爲最短子序列就是一個字符嘛,沒啥可問的。一旦涉及到子序列和最值,那幾乎可以肯定,考察的是動態規劃技巧,時間複雜度一般都是 O(n^2)

原因很簡單,你想想一個字符串,它的子序列有多少種可能?起碼是指數級的吧,這種情況下,不用動態規劃技巧,還想怎麼着?

既然要用動態規劃,那就要定義 dp 數組,找狀態轉移關係。我們說的兩種思路模板,就是 dp 數組的定義思路。不同的問題可能需要不同的 dp 數組定義來解決。

一、兩種思路

1、第一種思路模板是一個一維的 dp 數組

int n = array.length;
int[] dp = new int[n];

for (int i = 1; i < n; i++) {
    for (int j = 0; j < i; j++) {
        dp[i] = 最值(dp[i], dp[j] + ...)
    }
}

舉個我們寫過的例子「最長遞增子序列」,在這個思路中 dp 數組的定義是:

在子數組 array[0..i] 中,我們要求的子序列(最長遞增子序列)的長度是 dp[i]

爲啥最長遞增子序列需要這種思路呢?前文說得很清楚了,因爲這樣符合歸納法,可以找到狀態轉移的關係,這裏就不具體展開了。

2、第二種思路模板是一個二維的 dp 數組

int n = arr.length;
int[][] dp = new dp[n][n];

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        if (arr[i] == arr[j]) 
            dp[i][j] = dp[i][j] + ...
        else
            dp[i][j] = 最值(...)
    }
}

這種思路運用相對更多一些,尤其是涉及兩個字符串/數組的子序列,比如前文講的「最長公共子序列」。本思路中 dp 數組含義又分爲「只涉及一個字符串」和「涉及兩個字符串」兩種情況。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

2.1 涉及兩個字符串/數組時(比如最長公共子序列),dp 數組的含義如下:

在子數組 arr1[0..i] 和子數組 arr2[0..j] 中,我們要求的子序列(最長公共子序列)長度爲 dp[i][j]

2.2 只涉及一個字符串/數組時(比如本文要講的最長迴文子序列),dp 數組的含義如下:

在子數組 array[i..j] 中,我們要求的子序列(最長迴文子序列)的長度爲 dp[i][j]

第一種情況可以參考這兩篇舊文:「編輯距離」「公共子序列」

下面就借最長迴文子序列這個問題,詳解一下第二種情況下如何使用動態規劃。

二、最長迴文子序列

之前解決了「最長迴文子串」的問題,這次提升難度,求最長迴文子序列的長度:

a66d5839da8e8dfaf54a94ff21f85d7d.jpeg

我們說這個問題對 dp 數組的定義是:在子串 s[i..j] 中,最長迴文子序列的長度爲 dp[i][j]。一定要記住這個定義才能理解算法。

爲啥這個問題要這樣定義二維的 dp 數組呢?我們前文多次提到,找狀態轉移需要歸納思維,說白了就是如何從已知的結果推出未知的部分,這樣定義容易歸納,容易發現狀態轉移關係。

具體來說,如果我們想求 dp[i][j],假設你知道了子問題 dp[i+1][j-1] 的結果(s[i+1..j-1] 中最長迴文子序列的長度),你是否能想辦法算出 dp[i][j] 的值(s[i..j] 中,最長迴文子序列的長度)呢?

12578076b32a30c9c86304b531f2a862.jpeg

可以!這取決於 s[i] 和 s[j] 的字符:

如果它倆相等,那麼它倆加上 s[i+1..j-1] 中的最長迴文子序列就是 s[i..j] 的最長迴文子序列:

ad775690b677610ccd3d3bf02206625e.jpeg

如果它倆不相等,說明它倆不可能同時出現在 s[i..j] 的最長迴文子序列中,那麼把它倆分別加入 s[i+1..j-1] 中,看看哪個子串產生的迴文子序列更長即可:

73281af2b3586323f3f550c744edabbe.jpeg

以上兩種情況寫成代碼就是這樣:

if (s[i] == s[j])
    // 它倆一定在最長迴文子序列中
    dp[i][j] = dp[i + 1][j - 1] + 2;
else
    // s[i+1..j] 和 s[i..j-1] 誰的迴文子序列更長?
    dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);

至此,狀態轉移方程就寫出來了,根據 dp 數組的定義,我們要求的就是 dp[0][n - 1],也就是整個 s 的最長迴文子序列的長度。

三、代碼實現

首先明確一下 base case,如果只有一個字符,顯然最長迴文子序列長度是 1,也就是 dp[i][j] = 1 (i == j)

因爲 i 肯定小於等於 j,所以對於那些 i > j 的位置,根本不存在什麼子序列,應該初始化爲 0。

另外,看看剛纔寫的狀態轉移方程,想求 dp[i][j] 需要知道 dp[i+1][j-1]dp[i+1][j]dp[i][j-1] 這三個位置;再看看我們確定的 base case,填入 dp 數組之後是這樣:

62adb8ebca4f068df95ef20f8af5348d.jpeg

爲了保證每次計算 dp[i][j],左下右方向的位置已經被計算出來,只能斜着遍歷或者反着遍歷

7bf900498b026dec1ab7b3fa1851bc59.jpeg

我選擇反着遍歷,代碼如下:

int longestPalindromeSubseq(string s) {
    int n = s.size();
    // dp 數組全部初始化爲 0
    vector<vector<int>> dp(n, vector<int>(n, 0));
    // base case
    for (int i = 0; i < n; i++)
        dp[i][i] = 1;
    // 反着遍歷保證正確的狀態轉移
    for (int i = n - 1; i >= 0; i--) {
        for (int j = i + 1; j < n; j++) {
            // 狀態轉移方程
            if (s[i] == s[j])
                dp[i][j] = dp[i + 1][j - 1] + 2;
            else
                dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
        }
    }
    // 整個 s 的最長迴文子串長度
    return dp[0][n - 1];
}

至此,最長迴文子序列的問題就解決了。

_____________


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章