淺談最長公共子序列引發的經典動態規劃問題

白澤來了發表於2022-04-07

前言

關注公眾號【程式設計師白澤】,帶你走近一個不一樣的程式猿/學生黨,公眾號平時會同步更新部落格文章,回覆【簡歷】即可獲得我使用的簡歷模板。

希望上海疫情儘早過去,其實有一段穩定的時間是比較適合沉澱一下技術的,多少還是自己有些散漫,近期應該會恢復更新《手撕MySQL》系列文章。這篇文章通過一道經典例題:最長公共子序列,給大家講講動態規劃,並且給出一道LeetCode周賽動態規劃題作為練手並講解,相信看完文章之後,你會對動態規劃有更深的理解。

關於後面的dp練手題,是某次周賽的第四題,藉助這題,我會在後面分析部分講解如何從讀題開始,沉浸式一步一步解決一個演算法題。這個過程適用於所有的題目,比較重要,當然我們先從經典的最長公共子序列入手。

最長公共子序列

題目連結:LeetCode 1143

題目

給定兩個字串 text1 和 text2,返回這兩個字串的最長公共子序列的長度。如果不存在公共子序列,返回0。

一個字串的子序列是指這樣一個新的字串:它是由原字串在不改變字元的相對順序的情況下刪除某些字元(也可以不刪除任何字元)後組成的新字串,如aceabcde的子序列。

兩個字串的 公共子序列 是這兩個字串所共同擁有的子序列。

分析

設dp[i] [j]為text1的前i個字元組成的串str1和text2的前j個字元組成的串str2的最長公共子序列,初始化時:dp[0] [j]與dp[i] [0]都為0,因為str1為空或者str2為空都將無法構成子序列

根據上面的表述,text1[i-1]是str1的最後一個字元,而不是text1[i],因為下標從0開始;同理test2[j-1]表示str2的最後一個字元

那麼就可以開始討論狀態轉移方程: 如果 text[i-1] == text2[j-1],表示str1的最後一個字元和str2的最後一個字元相等,那麼dp[i] [j] = dp[i-1] [j-1] + 1,可以理解成兩個字串都去掉相等的末尾字元,然後在前面剩餘的字元中再求最長公共子序列,最後結果+1,因為這個過程是可以追溯的,因此滿足動態規劃的要求

如果 text[i-1] != text2[j-1],則dp[i] [j] = max(dp[i-1] [j], dp[i] [j-1]) ,因為dp[i-1] [j]和dp[i] [j-1]都是已經求出來的字問題的解,所以可以追溯,既然str1和str2的末尾不一樣,那麼就讓str1去掉末尾和str2求解或者str2去掉末尾和str1求解,兩者取最大值即可

程式碼

用的是go語言,但語言不是障礙~

func longestCommonSubsequence(text1 string, text2 string) int {
    dp := make([][]int, len(text1)+1)
    for i := range dp {
        dp[i] = make([]int, len(text2)+1)
    }
    for i := 1; i <= len(text1); i++ {
        for j := 1; j <= len(text2); j++ {
            if text1[i-1] == text2[j-1] {
                dp[i][j] = dp[i-1][j-1] + 1
            } else {
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]) //為節約篇幅max函式就不寫明瞭,這裡需要自己實現
            }
        }
    }
    return dp[len(text1)][len(text2)]
}

用地毯覆蓋後的最少白色磚塊

題目連結:LeetCode 2209

題目

給你一個下標從 0 開始的 二進位制 字串 floor ,它表示地板上磚塊的顏色。

  • floor[i] = '0' 表示地板上第 i 塊磚塊的顏色是 黑色 。
  • floor[i] = '1' 表示地板上第 i 塊磚塊的顏色是 白色 。

同時給你 numCarpets 和 carpetLen 。你有 numCarpets 條 黑色 的地毯,每一條 黑色 的地毯長度都為 carpetLen 塊磚塊。請你使用這些地毯去覆蓋磚塊,使得未被覆蓋的剩餘 白色 磚塊的數目 最小 。地毯相互之間可以覆蓋。

請你返回沒被覆蓋的白色磚塊的 最少 數目。

分析

其實在拿到題的一開始,如果看不太明白題意,建議先對照輸入輸出樣例去梳理,比如對於如下輸入輸出

輸入:floor = "10110101", numCarpets = 2, carpetLen = 2
輸出:2,表示最少有2塊白色沒有被覆蓋到(1表示白色)

image-20220407162920898

結合樣例你大概懂了題意,好像是要用黑色的地毯儘可能去覆蓋白色的連續區域,使得最後剩餘的白色最少。大概明白做什麼之後,去看輸入輸出資料的取值範圍,因為這涉及到你設計的演算法的時間複雜度(主要是時間),如下:

1 <= carpetLen <= floor.length <= 1000
floor[i] 要麼是 '0' ,要麼是 '1' 。
1 <= numCarpets <= 1000

錯誤思路

floor長度1000,看樣子可以寫一個O(N^2)的演算法,你放心了。然後想:既然是儘可能去覆蓋白色連續區域,且每次就是拿一個長度為L的地毯去覆蓋,那麼我只要每次找一個長度為L的擁有最多白色的塊的區間去給他覆蓋不就行了,然後把白色改成黑色,外迴圈是地毯數量,核心是貪心!我又行了!

下面給出一組測試資料:

輸入:floor = "101111", numCarpets = 2, carpetLen = 3
輸出:0

如果是貪心,那麼首先會找到連續的3個1的部分,然後將其修改為100001,然後再找到包含一個1的長度為3的區間,將其修改為000001或者100000,無法到達最優效果:第一次覆蓋前3塊,第二次覆蓋後3塊。

正確思路

對於給出的資料,思考是否能使用dp求解,對於動態規劃來說,首先要確定規模,一維的dp本題無法勝任,因為地毯數量有多塊。

如果是二維dp,那麼i和j分別表示什麼,一般來說:我習慣於將j設定為被具體操作的“物件”空間(就像是0-1揹包我會將揹包空間設定為j,而物品的種類設定為i,因為所有i種物品都會放置在j大小的空間中,揹包空間此時是被操作"物件"),本題所有的地毯覆蓋到一個floor上,因此j的緯度是地磚數量(那麼i的維度就是地毯的數量)

最終dp[i] [j]表示使用i塊地毯覆蓋前j塊磚,所剩餘的白色的地磚的最少數量

下面給出狀態轉移方程:

如果第i塊地毯選擇覆蓋下標為j的地磚,則dp[i] [j] = dp[i-1] [j-carpetLen] ,表示只考慮前i-1塊地毯去覆蓋前j-carpetLen位置的最少白色塊數量(因為覆蓋了第i塊的位置全都變黑)

如果第i塊地毯選擇不覆蓋下標為j的地磚,則dp[i] [j] = dp[i] [j-1]+(floor[j] - '0') ,相當於只考慮用i塊地毯去覆蓋j-1的地磚,且可能第j塊磚是白色的,因此要加上

程式碼

func minimumWhiteTiles(floor string, numCarpets int, carpetLen int) int {
    dp := make([][]int, numCarpets+1)
    for i := range dp {
        dp[i] = make([]int, len(floor))
    }
    num := 0
    for i := 0; i < len(floor); i++ {
        num += int(floor[i] - '0')
        dp[0][i] = num
    }
    for i := 1; i <= numCarpets; i++ {
        // 注意j的起始位置,前carpetLen長度就是一塊地毯的空間,此時的dp[i][j]一定是0
        for j := carpetLen; j < len(floor); j++ {
            // 對於j位置,需要在兩種情況中選擇白色數量最少的一種保留
            dp[i][j] = min(dp[i][j-1]+int(floor[j] - '0'), dp[i-1][j-carpetLen])
        }
    }
    return dp[numCarpets][len(floor)-1]
}

結束

建議在看過文章之後自己去做一下1143和2209兩道題,相信你對動態規劃的掌握一定會更上一層。演算法水平在面試筆試當中還是十分重要的,經典動態規劃題更是很多題目的模板出處,值得學習。

關注公眾號【程式設計師白澤】,帶你走近一個不一樣的程式猿/學生黨,公眾號平時會同步更新部落格文章,回覆【簡歷】即可獲得我使用的簡歷模板。

相關文章