vb treeview 展开子节点_详解最长公共子序列问题,秒杀三道动态规划题目
學算法認準?labuladong
后臺回復進群一起力扣?
讀完本文,可以去力扣解決如下題目:
1143.最長公共子序列(Medium)
583. 兩個字符串的刪除操作(Medium)
712.兩個字符串的最小ASCII刪除和(Medium)
好久沒寫動態規劃算法相關的文章了,今天來搞一把。
不知道大家做算法題有什么感覺,我總結出來做算法題的技巧就是,把大的問題細化到一個點,先研究在這個小的點上如何解決問題,然后再通過遞歸/迭代的方式擴展到整個問題。
比如說我們前文 手把手帶你刷二叉樹第三期,解決二叉樹的題目,我們就會把整個問題細化到某一個節點上,想象自己站在某個節點上,需要做什么,然后套二叉樹遞歸框架就行了。
動態規劃系列問題也是一樣,尤其是子序列相關的問題。本文從「最長公共子序列問題」展開,總結三道子序列問題,解這道題仔細講講這種子序列問題的套路,你就能感受到這種思維方式了。
最長公共子序列
計算最長公共子序列(Longest Common Subsequence,簡稱 LCS)是一道經典的動態規劃題目,大家應該都見過:
給你輸入兩個字符串s1和s2,請你找出他們倆的最長公共子序列,返回這個子序列的長度。
力扣第 1143 題就是這道題,函數簽名如下:
int?longestCommonSubsequence(String?s1,?String?s2);比如說輸入s1 = "zabcde", s2 = "acez",它倆的最長公共子序列是lcs = "ace",長度為 3,所以算法返回 3。
如果沒有做過這道題,一個最簡單的暴力算法就是,把s1和s2的所有子序列都窮舉出來,然后看看有沒有公共的,然后在所有公共子序列里面再尋找一個長度最大的。
顯然,這種思路的復雜度非常高,你要窮舉出所有子序列,這個復雜度就是指數級的,肯定不實際。
正確的思路是不要考慮整個字符串,而是細化到s1和s2的每個字符。前文 子序列解題模板 中總結的一個規律:
對于兩個字符串求子序列的問題,都是用兩個指針i和j分別在兩個字符串上移動,大概率是動態規劃思路。
最長公共子序列的問題也可以遵循這個規律,我們可以先寫一個dp函數:
//?定義:計算 s1[i..]?和 s2[j..]?的最長公共子序列長度int?dp(String?s1,?int?i,?String?s2,?int?j)
這個dp函數的定義是:dp(s1, i, s2, j)計算s1[i..]和s2[j..]的最長公共子序列長度。
根據這個定義,那么我們想要的答案就是dp(s1, 0, s2, 0),且 base case 就是i == len(s1)或j == len(s2)時,因為這時候s1[i..]或s2[j..]就相當于空串了,最長公共子序列的長度顯然是 0:
int?longestCommonSubsequence(String?s1,?String?s2)?{????return?dp(s1,?0,?s2,?0);
}
/*?主函數?*/
int?dp(String?s1,?int?i,?String?s2,?int?j)?{
????//?base?case
????if?(i?==?s1.length()?||?j?==?s2.length())?{
????????return?0;
????}
????//?...
接下來,咱不要看s1和s2兩個字符串,而是要具體到每一個字符,思考每個字符該做什么。
我們只看s1[i]和s2[j],如果s1[i] == s2[j],說明這個字符一定在lcs中:
這樣,就找到了一個lcs中的字符,根據dp函數的定義,我們可以完善一下代碼:
//?定義:計算 s1[i..]?和 s2[j..]?的最長公共子序列長度int?dp(String?s1,?int?i,?String?s2,?int?j)?{
????if?(s1.charAt(i)?==?s2.charAt(j))?{
????????//?s1[i]?和?s2[j]?必然在?lcs?中,
????????//?加上?s1[i+1..]?和?s2[j+1..]?中的?lcs?長度,就是答案
????????return?1?+?dp(s1,?i?+?1,?s2,?j?+?1)
????}?else?{
????????//?...
????}
}
剛才說的s1[i] == s2[j]的情況,但如果s1[i] != s2[j],應該怎么辦呢?
s1[i] != s2[j]意味著,s1[i]和s2[j]中至少有一個字符不在lcs中:
如上圖,總共可能有三種情況,我怎么知道具體是那種情況呢?
其實我們也不知道,那就把這三種情況的答案都算出來,取其中結果最大的那個唄,因為題目讓我們算「最長」公共子序列的長度嘛。
這三種情況的答案怎么算?回想一下我們的dp函數定義,不就是專門為了計算它們而設計的嘛!
代碼可以再進一步:
//?定義:計算 s1[i..]?和 s2[j..]?的最長公共子序列長度int?dp(String?s1,?int?i,?String?s2,?int?j)?{
????if?(s1.charAt(i)?==?s2.charAt(j))?{
????????return?1?+?dp(s1,?i?+?1,?s2,?j?+?1)
????}?else?{
????????//?s1[i]?和?s2[j]?中至少有一個字符不在?lcs?中,
????????//?窮舉三種情況的結果,取其中的最大結果
????????return?max(
????????????//?情況一、s1[i]?不在?lcs?中
????????????dp(s1,?i?+?1,?s2,?j),
????????????//?情況二、s2[j]?不在?lcs?中
????????????dp(s1,?i,?s2,?j?+?1),
????????????//?情況三、都不在?lcs?中
????????????dp(s1,?i?+?1,?s2,?j?+?1)
????????);
????}
}
這里就已經非常接近我們的最終答案了,還有一個小的優化,情況三「s1[i]和s2[j]都不在 lcs 中」其實可以直接忽略。
因為我們在求最大值嘛,情況三在計算s1[i+1..]和s2[j+1..]的lcs長度,這個長度肯定是小于等于情況二s1[i..]和s2[j+1..]中的lcs長度的,因為s1[i+1..]比s1[i..]短嘛,那從這里面算出的lcs當然也不可能更長嘛。
同理,情況三的結果肯定也小于等于情況一。說白了,情況三被情況一和情況二包含了,所以我們可以直接忽略掉情況三,完整代碼如下:
//?備忘錄,消除重疊子問題int[][]?memo;
/*?主函數?*/
int?longestCommonSubsequence(String?s1,?String?s2)?{
????int?m?=?s1.length(),?n?=?s2.length();
????//?備忘錄值為?-1?代表未曾計算
????memo?=?new?int[m][n];
????for?(int[]?row?:?memo)?
????????Arrays.fill(row,?-1);
????//?計算?s1[0..]?和?s2[0..]?的?lcs?長度
????return?dp(s1,?0,?s2,?0);
}
//?定義:計算 s1[i..]?和 s2[j..]?的最長公共子序列長度
int?dp(String?s1,?int?i,?String?s2,?int?j)?{
????//?base?case
????if?(i?==?s1.length()?||?j?==?s2.length())?{
????????return?0;
????}
????//?如果之前計算過,則直接返回備忘錄中的答案
????if?(memo[i][j]?!=?-1)?{
????????return?memo[i][j];
????}
????//?根據?s1[i]?和?s2[j]?的情況做選擇
????if?(s1.charAt(i)?==?s2.charAt(j))?{
????????//?s1[i]?和?s2[j]?必然在?lcs?中
????????memo[i][j]?=?1?+?dp(s1,?i?+?1,?s2,?j?+?1);
????}?else?{
????????//?s1[i]?和?s2[j]?至少有一個不在?lcs?中
????????memo[i][j]?=?Math.max(
????????????dp(s1,?i?+?1,?s2,?j),
????????????dp(s1,?i,?s2,?j?+?1)
????????);
????}
????return?memo[i][j];
}
以上思路完全就是按照我們之前的爆文 動態規劃套路框架 來的,應該是很容易理解的。至于為什么要加memo備忘錄,我們之前寫過很多次,為了照顧新來的讀者,這里再簡單重復一下,首先抽象出我們核心dp函數的遞歸框架:
int?dp(int?i,?int?j)?{????dp(i?+?1,?j?+?1);?//?#1
????dp(i,?j?+?1);?????//?#2
????dp(i?+?1,?j);?????//?#3
}
你看,假設我想從dp(i, j)轉移到dp(i+1, j+1),有不止一種方式,可以直接走#1,也可以走#2 -> #3,也可以走#3 -> #2。
這就是重疊子問題,如果我們不用memo備忘錄消除子問題,那么dp(i+1, j+1)就會被多次計算,這是沒有必要的。
至此,最長公共子序列問題就完全解決了,用的是自頂向下帶備忘錄的動態規劃思路,我們當然也可以使用自底向上的迭代的動態規劃思路,和我們的遞歸思路一樣,關鍵是如何定義dp數組,我這里也寫一下自底向上的解法吧:
int?longestCommonSubsequence(String?s1,?String?s2)?{????int?m?=?s1.length(),?n?=?s2.length();
????int[][]?dp?=?new?int[m?+?1][n?+?1];
????//?定義:s1[0..i-1]?和 s2[0..j-1]?的 lcs 長度為 dp[i][j]
????//?目標:s1[0..m-1]?和 s2[0..n-1]?的 lcs 長度,即 dp[m][n]
????//?base?case:?dp[0][..]?=?dp[..][0]?=?0
????for?(int?i?=?1;?i?<=?m;?i++)?{
????????for?(int?j?=?1;?j?<=?n;?j++)?{
????????????//?現在?i?和?j?從?1?開始,所以要減一
????????????if?(s1.charAt(i?-?1)?==?s2.charAt(j?-?1))?{
????????????????//?s1[i-1]?和?s2[j-1]?必然在?lcs?中
????????????????dp[i][j]?=?1?+?dp[i?-?1][j?-?1];
????????????}?else?{
????????????????//?s1[i-1]?和?s2[j-1]?至少有一個不在?lcs?中
????????????????dp[i][j]?=?Math.max(dp[i][j?-?1],?dp[i?-?1][j]);
????????????}
????????}
????}
????return?dp[m][n];
}
自底向上的解法中dp數組定義的方式和我們的遞歸解法有一點差異,而且由于數組索引從 0 開始,有索引偏移,不過思路和我們的遞歸解法完全相同,如果你看懂了遞歸解法,這個解法應該不難理解。
另外,自底向上的解法可以通過我們前文講過的 動態規劃狀態壓縮技巧 來進行優化,把空間復雜度壓縮為 O(N),這里由于篇幅所限,就不展開了。
下面,來看兩道和最長公共子序列相似的兩道題目。
字符串的刪除操作
這是力扣第 583 題「兩個字符串的刪除操作」,看下題目:
函數簽名如下:
int?minDistance(String?s1,?String?s2);題目讓我們計算將兩個字符串變得相同的最少刪除次數,那我們可以思考一下,最后這兩個字符串會被刪成什么樣子?
刪除的結果不就是它倆的最長公共子序列嘛!
那么,要計算刪除的次數,就可以通過最長公共子序列的長度推導出來:
int?minDistance(String?s1,?String?s2)?{????int?m?=?s1.length(),?n?=?s2.length();
????//?復用前文計算?lcs?長度的函數
????int?lcs?=?longestCommonSubsequence(s1,?s2);
????return?m?-?lcs?+?n?-?lcs;
}
這道題就解決了!
最小 ASCII 刪除和
這是力扣第 712 題,看下題目:
這道題,和上一道題非常類似,這回不問我們刪除的字符個數了,問我們刪除的字符的 ASCII 碼加起來是多少。
那就不能直接復用計算最長公共子序列的函數了,但是可以依照之前的思路,稍微修改 base case 和狀態轉移部分即可直接寫出解法代碼:
//?備忘錄int?memo[][];
/*?主函數?*/????
int?minimumDeleteSum(String?s1,?String?s2)?{
????int?m?=?s1.length(),?n?=?s2.length();
????//?備忘錄值為?-1?代表未曾計算
????memo?=?new?int[m][n];
????for?(int[]?row?:?memo)?
????????Arrays.fill(row,?-1);
????return?dp(s1,?0,?s2,?0);
}
//?定義:將 s1[i..]?和 s2[j..]?刪除成相同字符串,
//?最小的 ASCII 碼之和為 dp(s1, i, s2, j)。
int?dp(String?s1,?int?i,?String?s2,?int?j)?{
????int?res?=?0;
????//?base?case
????if?(i?==?s1.length())?{
????????//?如果?s1?到頭了,那么?s2?剩下的都得刪除
????????for?(;?j?????????????res?+=?s2.charAt(j);
????????return?res;
????}
????if?(j?==?s2.length())?{
????????//?如果?s2?到頭了,那么?s1?剩下的都得刪除
????????for?(;?i?????????????res?+=?s1.charAt(i);
????????return?res;
????}
????if?(memo[i][j]?!=?-1)?{
????????return?memo[i][j];
????}
????if?(s1.charAt(i)?==?s2.charAt(j))?{
????????//?s1[i]?和?s2[j]?都是在?lcs?中的,不用刪除
????????memo[i][j]?=?dp(s1,?i?+?1,?s2,?j?+?1);
????}?else?{
????????//?s1[i]?和?s2[j]?至少有一個不在?lcs?中,刪一個
????????memo[i][j]?=?Math.min(
????????????s1.charAt(i)?+?dp(s1,?i?+?1,?s2,?j),
????????????s2.charAt(j)?+?dp(s1,?i,?s2,?j?+?1)
????????);
????}
????return?memo[i][j];
}
base case 有一定區別,計算lcs長度時,如果一個字符串為空,那么lcs長度必然是 0;但是這道題如果一個字符串為空,另一個字符串必然要被全部刪除,所以需要計算另一個字符串所有字符的 ASCII 碼之和。
關于狀態轉移,當s1[i]和s2[j]相同時不需要刪除,不同時需要刪除,所以可以利用dp函數計算兩種情況,得出最優的結果。其他的大同小異,就不具體展開了。
至此,三道子序列問題就解決完了,關鍵在于將問題細化到字符,根據每兩個字符是否相同來判斷他們是否在結果子序列中,從而避免了對所有子序列進行窮舉。
這也算是在兩個字符串中求子序列的常用思路吧,建議好好體會,多多聯系~
往期推薦??
東哥手把手帶你套框架刷通二叉樹|第一期
階乘相關的算法題,東哥又整活兒了
東哥手寫正則通配符算法,結構清晰,包教包會!
關于算法筆試,東哥又整出套路了?
原創 | 東哥教你幾招常用的位運算技巧
_____________
學好算法靠套路,認準 labuladong,知乎、B站賬號同名。
《labuladong的算法小抄》即將出版,公眾號后臺回復關鍵詞「pdf」下載,回復「進群」可加入刷題群。
總結
以上是生活随笔為你收集整理的vb treeview 展开子节点_详解最长公共子序列问题,秒杀三道动态规划题目的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java三大框架SSH简介
- 下一篇: 【转】Xcode 7 真机调试详细步骤