動態規劃Ⅰ:斐波那契數列

一、斐波那契數列

1. 爬樓梯問題

爬樓梯:有 N 階樓梯,每次可以上一階或者兩階,求有多少種上樓梯的方法?(嚴格來說這不是動態規劃,因爲並沒有要求最值,但是這個狀態轉移很有參考價值,主要用來體會一下什麼是重疊子問題。)LeetCode 直達

思路 1自上而下求解,設對一個 n 階的樓梯,有 F(n) 種上法。假設現在位於第 n 階樓梯上,想要一步到達這裏,可以在 n-1 階一步上來,也可以在 n-2 階一步上來,也就是上樓梯滿足狀態轉移 F(n) = F(n-1) + F(n-2)

起始狀態:當 n = 1 時,走法 = 1;當 n = 2 時,走法 = 2(邊界條件)
狀態轉移:當 n > 2 時,滿足 F(n) = F(n-1) + F(n-2)

class Solution:
    def climbStairs(self, n: int) -> int:
        if n <= 2: return n
        self.count = self.climbStairs(n-1) + self.climbStairs(n-2)
        return self.count

這樣做實際上是遞歸,存在大量重複計算(重疊子問題),非常沒效率。如果說有 n 個子問題,每個子問題都會衍生出二叉樹的一層,那麼時間複雜度就是 O(2^n)。

思路 2自上而下求解,設置一個哈希表,用於存放已經計算過的子問題的解,後面如果有重複使用,直接返回即可。

class Solution:
    def climbStairs(self, n: int) -> int:
        if n <= 2: return n
        res = [False for i in range(n+1)]
        res[0], res[1], res[2] = 0, 1, 2

        def getNum(n):  # 從哈希表獲取目標值
            if res[n]: return res[n]
            else: res[n] = getNum(n-1) + getNum(n-2)
            return res[n]

        count = getNum(n-1) + getNum(n-2)
        return count

通過哈希表,實際上達到了給遞歸樹 “剪枝” 的效果。對於 n 個子問題,不存在重複計算,時間複雜度是 O(n)。

思路 3自下而上求解,和自上而下求解實際是一樣的,但是自下而上的思路屬於動態規劃。自上而下一般屬於遞歸解法,將大問題逐漸分解,到不能再分時計算一個答案然後不斷再往回推出我們的目標答案,而自下而上屬於迭代解法,脫離了遞歸。

此時的哈希表可以看作是 DP Table,思路2和思路3兩個方法效率一般是相同的,只不過計算方向不同而已。

class Solution:
    def climbStairs(self, n: int) -> int:
        res = [0, 1, 2]
        if n <= 2: return n
        for i in range(3, n):
            m = res[i-1] + res[i-2]
            res.append(m)
        return res[n-1] + res[n-2]

思路 4自下而上求解,只存儲前兩個值。因爲每次計算實際上只需要前兩個值,所以設置兩個變量維護一下就可以啦,空間複雜度 O(1)。

class Solution:
    def climbStairs(self, n: int) -> int:
        if n <= 2: return n
        pre1, pre2 = 1, 2
        for i in range(3, n+1):
            cur = pre1 + pre2
            pre1, pre2 = pre2, cur
        return cur

在牛客網和 LeetCode 上有很多類似爬樓梯的題目,比如直接求斐波那契數列、青蛙跳臺階,都是換湯不換藥。


2. 打家劫舍系列

打家劫舍 Ⅰ:你是一個專業的小偷,計劃偷竊沿街的房屋。每間房內都藏有一定的現金,影響你偷竊的唯一制約因素就是相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。給定一個代表每個房屋存放金額的非負整數數組,計算你 不觸動警報裝置的情況下 ,一夜之內能夠偷竊到的最高金額。LeetCode 直達

萌新如我錯誤的解答:

class Solution:
    def rob(self, nums: List[int]) -> int:
        if not nums: return 0
        if len(nums) < 3: return max(nums)
        n = len(nums)
        dp = [0 for i in range(n)]
        # 判斷初始的3個屋子(從這裏開始就錯了)
        if nums[1] >= nums[0] + nums[2]:
            dp[1], dp[2] = nums[1], nums[1]
        else:
            dp[0], dp[1], dp[2] = nums[0], nums[0], nums[0] + nums[2]
            
        # dp 錯誤原因:狀態數組非最優值
        for i in range(2, n-1):
            # 前一屋拿了,這一屋只能不拿了
            if dp[i-2] != dp[i-1]:
                dp[i] = dp[i-1]
            # 前一屋沒拿,這一屋考慮一下要不要拿
            else:
                if nums[i] < nums[i+1]:
                    dp[i] = dp[i-2]
                else:
                    dp[i] = dp[i-2] + nums[i]
        if dp[n-2] != dp[n-3]:
            dp[n-1] = dp[n-2]
        else:
            dp[n-1] = dp[n-3] + nums[n-1]
        return dp[n-1]

錯誤原因:沒有搞清楚 dp數組的本質,。應該存放的是當前狀態下的最優解,這個最優解是考慮了前面所有位置的狀態得出的。自己的代碼裏,從判斷初始的三個屋子狀態就錯了,

正確的解題思路:明確 “狀態” -> 明確 dp 數組 / 函數的定義 -> 明確 “選擇” -> 尋找狀態之間的關係 -> 明確 base case

  • 明確狀態:最大能拿到的錢數
  • dp 數組定義:每個位置都表示,在當前狀態下能拿到的最大錢數
  • 明確選擇:每個位置都有 “拿” 和 “不拿” 兩種選擇
  • 狀態間關係:在明確了選擇的基礎上,很容易找到狀態間關係,只要在拿和不拿中選一個最大的策略即可,如果拿,那麼當前能拿到的金額就是 dp[i-2] + nums[i],如果不拿,那麼當前能拿到的金額就等於前一位置的狀態 dp[i-1],所以有狀態轉移公式:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
  • 明確 base case:如果只有一間房子,則能拿到的最大金額就是這間房子的錢 nums[0];如果有兩間房子,由於不能連着拿,所以能夠拿到的最大金額就是這兩間房子錢更多的一個 max(nums[0], nums[1])

時間複雜度:O(n),需要遍歷所給數組
空間複雜度:O(n),需要一個 dp 數組存儲每個房屋位置所能獲得的最大金額

class Solution:
    def rob(self, nums: List[int]) -> int:
        if not nums: return 0
        n = len(nums)
        if n <= 2: return max(nums)
        # 初始化邊界
        dp = [0 for i in range(n)]
        dp[0] = nums[0]
        dp[1] = max(nums[0], nums[1])
		# dp
        for i in range(2, n):
            dp[i] = max(dp[i-1], dp[i-2] + nums[i])
        return dp[-1]

注意這個狀態轉移很靈性:dp[i] = max(dp[i-1], dp[i-2] + nums[i]),在每一個位置上取最大值,實際上就進行了“拿”或“不拿”的選擇。

這裏使用了 dp 數組保存每個點的狀態,實際上只需要保存前兩位的狀態即可。優化代碼爲:

class Solution:
    def rob(self, nums: List[int]) -> int:
        if not nums: return 0
        n = len(nums)
        if n <= 2: return max(nums)
        pre1, pre2 = nums[0], max(nums[0], nums[1])  # 邊界
        for i in range(2, n):  # dp
            cur = max(pre1+ nums[i], pre2)
            pre1 = pre2
            pre2 = cur
        return cur

由於省去了 dp 數組的存儲空間,進而將空間複雜度優化到了 O(1)。

也可以這樣寫,更簡潔一些:

class Solution:
    def rob(self, nums: List[int]) -> int:
        if not nums: return 0
        n = len(nums)
        pre1, pre2 = 0, 0
        for i in range(n):
            cur = max(pre1 + nums[i], pre2)
            pre1 = pre2
            pre2 = cur
        return cur

打家劫舍 Ⅱ(環形):你是一個專業的小偷,計劃偷竊沿街的房屋,每間房內都藏有一定的現金。這個地方所有的房屋都圍成一圈,這意味着第一個房屋和最後一個房屋是緊挨着的。同時,相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。給定一個代表每個房屋存放金額的非負整數數組,計算你在不觸動警報裝置的情況下,能夠偷竊到的最高金額。

分析:相比打家劫舍Ⅰ,這裏的房屋是一個環形,也就是第一個房子拿不拿會影響到最後一個房子,所以可以將原問題分爲兩個子問題,即 1)不拿第一個房子,能拿到的最大金額;2)不拿最後一個房子,能拿到的最大金額。最後的輸出就是這兩種情況中較大的值。

解題思路:明確 “狀態” -> 明確 dp 數組 / 函數的定義 -> 明確 “選擇” -> 尋找狀態之間的關係 -> 明確 base case

  • 明確狀態:能拿到的最大金額(變量)
  • dp 數組:數組中每個位置代表當前位置能拿到的最大金額
  • 明確選擇:每個位置,都可以選擇“拿”或“不拿”,如果“拿”,那麼當前位置最大金額就是 dp[i-2] + nums[i];如果“不拿”,那麼當前位置最大金額就是 dp[i-1]
  • 狀態間關係:同 Ⅰ
  • 明確 base case:同 Ⅰ

時間複雜度:O(n),遍歷兩遍數組 nums,分別需要 O(n)
空間複雜度:O(n),dp 數組佔用 O(n) 的空間

class Solution:
    def rob(self, nums):
        if not nums: return 0
        n = len(nums)
        if n <= 2: return max(nums)
        
        # case 1:不拿第一間房子(最後一間可拿可不拿)
        dp = [0 for i in range(n+1)]
        dp[2] = nums[1]
        for i in range(3, n+1):
            dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
        m1 = dp[-1]  # 記錄case1的最大值
        
        # case 2:拿第一間房子(對應地就放棄了最後一間)
        dp = [0 for i in range(n+1)]
        dp[1] = nums[0]
        dp[2] = max(nums[0], nums[1])
        for i in range(3, n):
            dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
        dp[n] = dp[n-1]
        m2 = dp[-1]  # 記錄case2的最大值
        if m1 >= m2:
            return m1
        else:
            return m2

簡化空間複雜度,由於 dp[i] 只與 dp[i-1]dp[i-2] 有關,所以可以用量變量來交替記錄,省掉了 dp 數組的存儲空間,空間複雜度變爲 O(1)。

class Solution:
    def rob(self, nums):
        if not nums: return 0
        n = len(nums)
        if n <= 2: return max(nums)
        # case 1:不拿第一間房子(最後一間可拿可不拿)
        pre1, pre2 = 0, nums[1]
        for i in range(2, n):
            cur = max(pre2, pre1 + nums[i])
            pre1 = pre2
            pre2 = cur
        m1 = cur
        # case 2:拿第一間房子(對應地就放棄了最後一間)
        pre1, pre2 = nums[0], max(nums[0], nums[1])
        for i in range(2, n-1):
            cur = max(pre2, pre1 + nums[i])
            pre1 = pre2
            pre2 = cur
        m2 = max(pre2, cur)
        if m1 >= m2:
            return m1
        else:
            return m2

再看看大佬的代碼,其簡潔度感人… 將兩種情況的搶劫範圍分別設爲 [:-1](搶第一個房子) 和 [1:](不搶第一個房子),求兩個 case 最大值即可。(我太菜啦!!)

class Solution:
    def rob(self, nums: [int]) -> int:
        def my_rob(nums):
            cur, pre = 0, 0
            for num in nums:
                cur, pre = max(pre + num, cur), cur
            return cur
        return max(my_rob(nums[:-1]),my_rob(nums[1:])) if len(nums) != 1 else nums[0]

代碼來源:213-da-jia-jie-she-iidong-tai-gui-hua-jie-gou-hua-


3. 信件錯排

信件錯排:有 n 個信件和信封,它們被打亂,求錯誤裝信方式的數量(即沒有信件正確裝入其對應的信箱)。

解題思路:明確 “狀態” -> 明確 dp 數組 / 函數的定義 -> 明確 “選擇” -> 尋找狀態之間的關係 -> 明確 base case

  • 明確狀態:當前有 i 個信件和信封,錯誤裝信的組合數量
  • dp 數組:長度爲 n 的一維數組,每個位置表示當前狀態下,錯誤裝信組合的數量
  • 明確選擇:當前狀態下,每增加一個信件,都有 n-1 種錯排方法,關鍵在於被錯排的信封對應的信件如何 “選擇”(下面解釋狀態轉移)
  • 尋找狀態間關係:找到 “錯排對兒” 是關鍵
  • 明確 base case:當有 0、1 封信件時錯排數量都爲 0,即不可能錯排;當有 2 封信時,只可能有一種錯排情況,就是裝反了

假設,有信件序列:… i, j, k, …,將信件 i 錯誤地放進了 k 信封,那麼信件 j 和 k 可以放進信封 i 和 j。

  • 如果 j == k,那麼實際是做了 i,j 的互換,將 i 和 j 從信件、信封列表中刪去,剩下的 n-2 個信件信封仍然滿足錯排條件,是原問題的子問題 dp[i-2]
  • 如果 j != k,交換 i 和 k 信封中的信,第 i 個信件就裝在了正確的信封裏,其餘 n-1 個信件信封仍滿足錯排條件,是原問題的子問題 dp[i-1]

綜上,對任意一封信來說,有 n-1 種錯排位置,而根據錯排位置的選擇不同,又能延伸到 dp[i-1] 和 dp[i-2],所以有狀態轉移方程:dp[i] = (i-1)[dp[i-1] + dp[i-2]]

class Solution:
    def demo(self, n):
        # dp = [0, 0, 1]  剛開始寫都會建一個dp數組,但其實不需要花費空間,只用兩個變量滾動保存就可以了
        if n == 0 or n == 1: return 0
        pre1, pre2 = 0, 1
        for i in range(2, n+1):
            cur = (i-1) * (pre1 + pre2)
            pre1 = pre2
            pre2 = cur
        return cur

第一次看錯排的題目,感覺不是很好理解,可以這麼想:假設我們已經知道有 1 個、2 個、3 個、4 個信件和信封時,分別對應的錯排數量爲 0,1,2,9 種。現在要計算 5 個信件和信封的錯排數量 dp[5]:

  • 5 號信件要保證錯排,只能放在 1~4 號信封,有 4 种放法(即 n-1 種)

放 5 號信件有 n-1 = 4 種選擇,即放在 1,2,3,4 中的一個。

  • 如果 5 號信件放在了第 1 個信封,且 1 號信件也 “選擇” 放在第 5 個信封,那麼剩餘的信件和信封編號就是 1,2,3。要保證錯排就是 1 不能對應 1,2 不能對應 2,3 不能對應 3,也就是錯排的子問題:dp[3]
  • 如果 5 號信件放在了第 1 個信封(k = 1~4),而 1 號信件 “不選擇” 放在第 5 個信封,剩餘的信件和信封編號分別爲:1,2,3,4 和 2,3,4,5。他們之間的錯排對應關係爲 1 不能放在 5 中,2、3、4 分別不能放在 2、3、4 中,這其實也相當於錯排子問題:dp[4]

在 5 號信件可以放在 n-1 = 4 個位置的基礎上,對 1 號信件的兩種 “選擇”,可以計算 dp[5] = (n-1)[dp[3] + dp[4]],所以推廣到 n 個信件信封的情況,dp[n] = (n-1)[dp[n-1] + dp[n-2]]


4. 母牛生產

母牛生產:假設農場中成熟的母牛每年都會生 1 頭小母牛,並且永遠不會死。第一年有 1 只小母牛,從第二年開始,母牛開始生小母牛。每隻小母牛 3 年之後成熟又可以生小母牛。給定整數 N,求 N 年後牛的數量。

解題思路:明確 “狀態” -> 明確 dp 數組 / 函數的定義 -> 明確 “選擇” -> 尋找狀態之間的關係 -> 明確 base case

  • 明確狀態:第 i 年,母牛的數量
  • dp 數組:數組中每個位置表示當前第 i 年,母牛的數量
  • 尋找狀態間關係:第 i 年的母牛數量 = 前一年母牛的總數 + 可以生產的母牛總數,而第 i 年可以生產的母牛總數 = 第 i - 3 年的母牛總數,即 dp[i] = dp[i-1] + dp[i-3]
  • 明確 base case:前 4 年只有初始的一頭牛在生產,所以 n 小於等於 4 時,母牛的總數 = n
class Solution:
    def demo(self, n):
        if n <= 4: return n
        dp = []
        for i in range(n+1):
            if i <= 4: dp.append(i)
            else: dp.append(0)
        for i in range(5, n+1):
            dp[i] = dp[i-1] + dp[i-3]
        return dp[n]

這個題就比較簡單了,斐波那契數列的簡單變體。


5. 數字轉字符串

把數字翻譯成字符串:給定一個數字,我們按照如下規則把它翻譯爲字符串:0 翻譯成 “a” ,1 翻譯成 “b”,……,11 翻譯成 “l”,……,25 翻譯成 “z”。一個數字可能有多個翻譯。請編程實現一個函數,用來計算一個數字有多少種不同的翻譯方法。LeetCode 直達

解題思路:明確 “狀態” -> 明確 dp 數組 / 函數的定義 -> 明確 “選擇” -> 尋找狀態之間的關係 -> 明確 base case

  • 明確狀態:唯一的變量是數字字符串的翻譯方法數
  • DP 數組:每個位置元素 dp[i] 都表示當前字符串,有多少種翻譯方法
  • 明確選擇:每向字符串添加一個字符,都有兩種翻譯方法:1)將這個字符作爲單獨的字符來翻譯,2)如果該字符和其前一個字符能夠組合成一個小於 26 的數字,則可以選擇將這兩位連起來翻譯
  • 尋找狀態間關係:明確了選擇後可以看出,這就是青蛙跳臺階問題,只不過加了數值上的限定。假設有字符串 xxxijk,若 jk 組成的數字小於 26,則以 k 結尾的字符串翻譯方法數 = 以 j 結尾的字符串的翻譯方法數 + 以 i 結尾的字符串的翻譯方法數;否則以 k 結尾的字符串翻譯方法數 = 以 j 結尾的字符串的翻譯方法數
  • 確定 base case:當只有一個字符串時,翻譯數 = 1;另外初始化 dp 數組的 0 索引位置也爲 1,方便後面計算

狀態轉移方程:x 是當前字符與其前一個字符組成的數字
dp[i]={dp[i1]+dp[i2]x<26dp[i1]x>=26 dp[i] = \begin{cases} dp[i-1] + dp[i-2] & x < 26 \\ dp[i-1] & x >= 26 \end{cases}

class Solution:
    def translateNum(self, num: int) -> int:
        s = str(num)
        n = len(s)
        dp = [1 for _ in range(n+1)]
        pre = s[0]
        for i in range(2, n+1):
            cur = int(pre + s[i-1])
            if cur >= 10  and cur < 26: dp[i] = dp[i-1] + dp[i-2]
            else: dp[i] = dp[i-1]
            pre = s[i-1]
        return dp[-1]

進一步優化空間,使用滾動數組 p1 和 p2 代替 dp 數組,可以將空間複雜度降到 O(1)。

class Solution:
    def translateNum(self, num: int) -> int:
        s = str(num)
        n = len(s)
        pre, p1, p2 = s[0], 1, 1
        for i in range(2, n+1):
            cur = int(pre + s[i-1])
            if cur >= 10 and cur < 26: p = p1 + p2    
            else: p = p2
            p1, p2 = p2, p
            pre = s[i-1]
        return p2

ps:注意最後要返回 p2 而不是 p,因爲要處理字符串只有一個字符的情況,此時不會進入 for 循環。

這道題就是斐波那契數列類型題中 爬樓梯 的變體,區別僅在於每一次不一定能 “跳” 兩個臺階,需要額外判斷。


參考:動態規劃 LeetCode 題解

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