子序列問題是常見的算法問題,而且並不好解決。
首先,子序列問題本身就相對子串、子數組更困難一些,因爲前者是不連續的序列,而後兩者是連續的,就算窮舉你都不一定會,更別說求解相關的算法問題了。
而且,子序列問題很可能涉及到兩個字符串,比如前文「最長公共子序列」,如果沒有一定的處理經驗,真的不容易想出來。所以本文就來扒一扒子序列問題的套路,其實就有兩種模板,相關問題只要往這兩種思路上想,十拿九穩。
一般來說,這類問題都是讓你求一個最長子序列,因爲最短子序列就是一個字符嘛,沒啥可問的。一旦涉及到子序列和最值,那幾乎可以肯定,考察的是動態規劃技巧,時間複雜度一般都是 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]
。
第一種情況可以參考這兩篇舊文:「編輯距離」「公共子序列」
下面就借最長迴文子序列這個問題,詳解一下第二種情況下如何使用動態規劃。
二、最長迴文子序列
之前解決了「最長迴文子串」的問題,這次提升難度,求最長迴文子序列的長度:
我們說這個問題對 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]
中,最長迴文子序列的長度)呢?
可以!這取決於 s[i]
和 s[j]
的字符:
如果它倆相等,那麼它倆加上 s[i+1..j-1]
中的最長迴文子序列就是 s[i..j]
的最長迴文子序列:
如果它倆不相等,說明它倆不可能同時出現在 s[i..j]
的最長迴文子序列中,那麼把它倆分別加入 s[i+1..j-1]
中,看看哪個子串產生的迴文子序列更長即可:
以上兩種情況寫成代碼就是這樣:
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 數組之後是這樣:
爲了保證每次計算 dp[i][j]
,左下右方向的位置已經被計算出來,只能斜着遍歷或者反着遍歷:
我選擇反着遍歷,代碼如下:
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];
}
至此,最長迴文子序列的問題就解決了。
_____________