讀者:西法,記憶化遞歸究竟怎麼改爲動態規劃啊?


title:
tags: [算法, 動態規劃]
date: 2021-05-18
categories:git

  • [動態規劃]

我在動態規劃專題反覆強調了先學習遞歸,再學習記憶化,最後再學動態規劃github

其中緣由已經講得很透了,相信你們已經明白了。若是不明白,強烈建議先看看那篇文章。算法

儘管不少看了我文章的小夥伴知道了先去學記憶化遞歸,可是仍是有一些粉絲問我:「記憶化遞歸轉化爲動態規劃總是出錯,不得要領怎麼辦?有沒有什麼要領呀?」api

今天我就來回答一下粉絲的這個問題。數組

實際上個人動態規劃那篇文章已經講了將記憶化遞歸轉化爲動態規劃的大概的思路,只是可能不是特別細,今天咱們就嘗試細化一波ide

咱們仍然先以經典的爬樓梯爲例,給你們講一點基礎知識。接下來,我會帶你們解決一個更加複雜的題目。函數

<!-- more -->學習

爬樓梯

題目描述

一我的爬樓梯,每次只能爬 1 個或 2 個臺階,假設有 n 個臺階,那麼這我的有多少種不一樣的爬樓梯方法?測試

思路

因爲第 n 級臺階必定是從 n - 1 級臺階或者 n - 2 級臺階來的,所以到第 n 級臺階的數目就是 到第 n - 1 級臺階的數目加上到第 n - 1 級臺階的數目優化

記憶化遞歸代碼:

const memo = {};
function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  if (n in memo) return memo[n];
  ans = climbStairs(n - 1) + climbStairs(n - 2);
  memo[n] = ans;
  return ans;
}

climbStairs(10);

首先爲了方便看出關係,咱們先將 memo 的名字改一下,將 memo 換成 dp:

const dp = {};
function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  if (n in dp) return dp[n];
  ans = climbStairs(n - 1) + climbStairs(n - 2);
  dp[n] = ans;
  return ans;
}

climbStairs(10);

其餘地方一點沒動,就是名字改了下。

那麼這個記憶化遞歸代碼如何改形成動態規劃呢?這裏我總結了三個步驟,根據這三個步驟就能夠將不少記憶化遞歸輕鬆地轉化爲動態規劃。

1. 根據記憶化遞歸的入參創建 dp 數組

在動態規劃專題中,西法還提過動態規劃的核心就是狀態。動態規劃問題時間複雜度打底就是狀態數,空間複雜度若是不考慮滾動數組優化打底也是狀態數,而狀態數是什麼?不就是各個狀態的取值範圍的笛卡爾積麼?而狀態正好對應的就是記憶化遞歸的入參

對應這道題,顯然狀態是當前位於第幾級臺階。那麼狀態數就有 n 個。所以開闢一個長度爲 n 的一維數組就行了。

我用 from 表示改造前的記憶化遞歸代碼, to 表示改造後的動態規劃代碼。(下同,再也不贅述)

from:

dp = {};
function climbStairs(n) {}

to:

function climbStairs(n) {
  const dp = new Array(n);
}

2. 用記憶化遞歸的葉子節點返回值填充 dp 數組初始值

若是你模擬上面 dp 函數的執行過程會發現: if n == 1 return 1if n == 2 return 2,對應遞歸樹的葉子節點,這兩行代碼深刻到葉子節點纔會執行。接下來再根據子 dp 函數的返回值合併結果,是一個典型的後序遍歷

藍色表示葉子節點

若是改形成迭代,如何作呢?一個樸素的想法就是從葉子節點開始模擬遞歸棧返回的過程,沒錯動態規劃本質就是如此。從葉子節點開始,到根節點結束,這也是爲何記憶化遞歸一般被稱爲自頂向下,而動態規劃被稱爲自底向上的緣由。這裏的底和頂能夠看作是遞歸樹的葉子和根。

知道了記憶化遞歸和動態規劃的本質區別。 接下來,咱們填充初始化,填充的邏輯就是記憶化遞歸的葉子節點 return 部分。

from:

const dp = {};
function climbStairs(n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
}

to:

function climbStairs(n) {
  const dp = new Array(n);
  dp[0] = 1;
  dp[1] = 2;
}
dp 長度爲 n,索引範圍是 [0,n-1],所以 dp[n-1] 對應記憶化遞歸的 dp(n)。所以 dp[0] = 1 等價於上面的 if n == 1: return 1。 若是你想讓兩者徹底對應也是能夠的,數組長度開闢爲 n + 1,而且數組索引 0 不用便可。

3. 枚舉笛卡爾積,並複製主邏輯

  1. if (xxx in dp) return dp[xxx] 這種代碼刪掉
  2. 將遞歸函數 f(xxx, yyy, ...) 改爲 dpxxx[....] ,對應這道題就是 climbStairs(n) 改爲 dp[n]
  3. 將遞歸改爲迭代。好比這道題每次 climbStairs(n) 遞歸調用了 climbStairs(n-1) 和 climbStairs(n-2),一共調用 n 次,咱們要作的就是迭代模擬。好比這裏調用了 n 次,咱們就用一層循環來模擬執行 n 次。若是有兩個參數就兩層循環,三個參數就三層循環,以此類推。

from:

const dp = {};
function climbStairs(n) {
  // ...
  if (n in dp) return dp[n];
  ans = climbStairs(n - 1) + climbStairs(n - 2);
  dp[n] = ans;
  return ans;
}

to:

function climbStairs(n) {
  // ...
  // 這個循環其實就是咱上面提到的狀態的笛卡爾積。因爲這道題就一個狀態,枚舉一層就行了。若是狀態有兩個,那麼笛卡爾積就能夠用兩層循環搞定。至於誰在外層循環誰在內層循環,請看個人動態規劃專題。
  for (let i = 2; i < n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[dp.length - 1];
}

將上面幾個步驟的成果合併起來就能夠將原有的記憶化遞歸改造爲動態規劃了。

完整代碼:

function climbStairs(n) {
  if (n == 1) return 1;
  const dp = new Array(n);
  dp[0] = 1;
  dp[1] = 2;

  for (let i = 2; i < n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[dp.length - 1];
}

有的人可能以爲這道題太簡單了。實際上確實有點簡單了。 並且我也認可有的記憶化遞歸比較難以改寫,什麼狀況記憶化遞歸比較好寫,改爲動態規劃就比較麻煩我也在動態規劃專題給你們講過了,不清楚的同窗翻翻。

據我所知,若是動態規劃能夠過,大多數記憶化遞歸均可以過。有一些極端狀況記憶化遞歸過不了:那就是力扣測試用例偏多,而且數據量大的測試用例比較多。這是因爲力扣的超時判斷是多個測試用例的用時總和,而不是單獨計算時間。

接下來,我再舉一個稍微難一點的例子(這個例子就必須使用動態規劃才能過,記憶化遞歸會超時)。帶你們熟悉我上面給你們的套路。

1824. 最少側跳次數

題目描述

給你一個長度爲  n  的  3 跑道道路  ,它總共包含  n + 1  個   點  ,編號爲  0  到  n 。一隻青蛙從  0  號點第二條跑道   出發  ,它想要跳到點  n  處。然而道路上可能有一些障礙。

給你一個長度爲 n + 1  的數組  obstacles ,其中  obstacles[i] (取值範圍從 0 到 3)表示在點 i  處的  obstacles[i]  跑道上有一個障礙。若是  obstacles[i] == 0 ,那麼點  i  處沒有障礙。任何一個點的三條跑道中   最多有一個   障礙。

比方說,若是  obstacles[2] == 1 ,那麼說明在點 2 處跑道 1 有障礙。
這隻青蛙從點 i  跳到點 i + 1  且跑道不變的前提是點 i + 1  的同一跑道上沒有障礙。爲了躲避障礙,這隻青蛙也能夠在   同一個   點處   側跳   到 另一條   跑道(這兩條跑道能夠不相鄰),但前提是跳過去的跑道該點處沒有障礙。

比方說,這隻青蛙能夠從點 3 處的跑道 3 跳到點 3 處的跑道 1 。
這隻青蛙從點 0 處跑道 2  出發,並想到達點 n  處的 任一跑道 ,請你返回 最少側跳次數  。

注意:點 0  處和點 n  處的任一跑道都不會有障礙。

示例 1:

輸入:obstacles = [0,1,2,3,0]
輸出:2
解釋:最優方案如上圖箭頭所示。總共有 2 次側跳(紅色箭頭)。
注意,這隻青蛙只有當側跳時才能夠跳過障礙(如上圖點 2 處所示)。
示例 2:

輸入:obstacles = [0,1,1,3,3,0]
輸出:0
解釋:跑道 2 沒有任何障礙,因此不須要任何側跳。
示例 3:

輸入:obstacles = [0,2,1,0,3,0]
輸出:2
解釋:最優方案如上圖所示。總共有 2 次側跳。

提示:

obstacles.length == n + 1
1 <= n <= 5 \* 105
0 <= obstacles[i] <= 3
obstacles[0] == obstacles[n] == 0

思路

這個青蛙在反覆橫跳??

稍微解釋一下這個題目。

  • 若是當前跑道後面一個位置沒有障礙物,這種狀況左右橫跳必定不會比直接平跳更優,咱們應該貪心地直接平跳(不是橫跳)過去。這是由於最壞狀況咱們能夠先平跳過去再橫跳,這和先橫跳再平跳是同樣的。
  • 若是當前跑道後面一個位置有障礙物,咱們須要橫跳到一個沒有障礙物的通道,同時橫跳計數器 + 1。

最後選取全部到達終點的橫跳次數最少的便可,對應遞歸樹中就是到達葉子節點時計數器最小的。

使用 dp(pos, line) 表示當前在通道 line, 從 pos 跳到終點須要的最少的橫跳數。不難寫出以下記憶化遞歸代碼。

因爲本篇文章主要講的是記憶化遞歸改造動態規劃,所以這道題的細節就很少介紹了,你們看代碼就好。

咱們來看下代碼:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            if (pos, line) in dp: return dp[(pos, line)]
            if pos == len(obstacles) - 1:
                return 0
            # 貪心地平跳
            if obstacles[pos + 1] != line:
                ans = f(pos + 1, line)
                dp[(pos, line)] = ans
                return ans
            ans = float("inf")
            for nxt in [1, 2, 3]:
                if nxt != line and obstacles[pos] != nxt:
                    ans = min(ans, 1 +f(pos, nxt))
            dp[(pos, line)] = ans
            return ans

        return f(0, 2)

這道題記憶化遞歸會超時,須要使用動態規劃才行。 那麼如何將 ta 改形成動態規劃呢?

仍是用上面的口訣。

1. 根據記憶化遞歸的入參創建 dp 數組

上面遞歸函數的是 dp(pos, line),狀態就是形參,所以須要創建一個 m * n 的二維數組,其中 m 和 n 分別是 pos 和 line 的取值範圍集合的大小。而 line 取值範圍其實就是 [1,3],爲了方便索引對應,此次西法決定浪費一個空間。因爲這道題是求最小,所以初始化爲無窮大沒毛病。

from:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            # ...

        return f(0, 2)

to:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        # ...
        return min(dp[-1])

2. 用記憶化遞歸的葉子節點返回值填充 dp 數組初始值

很少說了,直接上代碼。

from:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            if pos == len(obstacles) - 1:
                return 0
            # ...

        return f(0, 2)

to:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        # ...
        return min(dp[-1])

3. 枚舉笛卡爾積,並複製主邏輯

這道題如何枚舉狀態?固然是枚舉狀態的笛卡爾積了。簡單,幾個狀態就幾層循環唄。

上代碼。

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        for pos in range(1, len(obstacles)):
            for line in range(1, 4):
                # ...
        return min(dp[-1])

接下來就是把記憶化遞歸的主邏輯複製一下粘貼過來就行。

from:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            # ...
            # 貪心地平跳
            if obstacles[pos + 1] != line:
                ans = f(pos + 1, line)
                dp[(pos, line)] = ans
                return ans
            ans = float("inf")
            for nxt in [1, 2, 3]:
                if nxt != line and obstacles[pos] != nxt:
                    ans = min(ans, 1 +f(pos, nxt))
            dp[(pos, line)] = ans
            return ans

        return f(0, 2)

to:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        for pos in range(1, len(obstacles)):
            for line in range(1, 4):
                if obstacles[pos - 1] != line: # 因爲自底向上,所以是和 pos - 1 創建聯繫,而不是 pos + 1
                    dp[pos][line] = min(dp[pos][line], dp[pos - 1][line])
                else:
                    for nxt in range(1, 4):
                        if nxt != line and obstacles[pos] != nxt:
                            dp[pos][line] = min(dp[pos][line], 1 + dp[pos][nxt])

        return min(dp[-1])

能夠看出我基本就是把主邏輯複製過來,稍微改改。 改的基本就是由於:

  • 以前是遞歸函數,所以 return 須要去掉,好比改爲 continue 啥的,不能讓函數直接返回,而是繼續枚舉下一個狀態。
  • 以前是 dp[(pos, line)] = ans 如今則改爲填充咱上面初始好的二維 dp 數組。

你覺得這就結束了麼?

那你就錯了。之因此選這道題是有緣由的。這道題直接提交會報錯,是答案錯誤(WA)。

這裏我要告訴你們的是:因爲咱們使用迭代模擬遞歸過程,使用多層循環枚舉狀態的笛卡爾積,而主邏輯部分則是狀態轉移方程,而轉移方程的書寫和枚舉的順序息息相關。

從代碼不難看出:對這道題來講咱們採用的是從小到大枚舉,而 dppos 也僅僅依賴 dppos-1 和 dppos。

而問題的關鍵是 nxt,好比處理到了 dp2,d2 依賴了 dp2 的值,而實際上 dp2 是沒有處理到的。

所以上面動態規劃的的這一行代碼有問題:

dp[pos][line] = min(dp[pos][line], 1 + dp[pos][nxt])

由於遍歷到 dppos 的時候,有可能 dppos 還沒計算好(沒有枚舉到),這就是產生了 bug。

那爲何記憶化遞歸就沒問題呢?

其實很簡單。遞歸函數裏面的子問題都是沒有計算好的,到葉子節點後再開始計算,計算好後往上返回,而返回的過程其實和迭代是相似的。

好比這道題的 f(0,2) 的遞歸樹大概是這樣的,其中虛線標識可能沒法到達。

遞歸樹

當從 f(0, 2) 遞歸到 f(0, 1) 或者 f(0, 3) 的的時候,都是沒計算好的,所以都無所謂,代碼會繼續往葉子節點方向擴展,到達葉子節點返回後,全部的子節點確定都已經計算好了,接下來的過程和普通的迭代就很像了

好比 f(0,2) 遞歸到 f(0,3) ,f(0,3) 會繼續向下遞歸知道葉子節點,而後向上返回,當再次回到 f(0,2) 的時候,f(0,3) 必定是已經計算好的。

形象點來講就是:f(0,2) 是一個 leader,告訴他的下屬 f(0,3),我想要 xxxx,怎麼實現我無論,你有的話直接給我(記憶化),沒有的話想辦法獲取(遞歸)。無論怎麼樣,反正你給我弄出來送到我手上。

而若是使用迭代的動態規劃,你有的話直接給我(記憶化)很容易作到。關鍵是沒有的話想辦法獲取(遞歸)不容易作到啊,至少須要一個相似的循環去完成吧?

那如何解決這個問題呢?

很簡單,每次只依賴已經計算好的狀態就行了。

對於這道題來講,雖然 dppos 可能沒計算好了,那麼 dppos-1 必定是計算好的,由於 dppos-1 已經在上一次主循環計算好了。

可是直接改爲 dppos-1 邏輯還對麼?這就要具體問題具體分析了,對於這道題來講,這麼寫是能夠的。

這是由於這裏的邏輯是若是當前賽道的前面一個位置有障礙物,那麼咱們不能從當前賽道的前一個位置過來,而只能選擇從其餘兩個賽道橫跳過來。

我畫了一個簡圖。其中 X 表示障礙物,O 表示當前的位置,數字表示時間上的前後循序,先跳 1 再跳 2 。。。

-XO
---
---

在這裏,而如下兩種狀況實際上是等價的:

狀況 1(也就是上面 dppos 的狀況):

-X2
--1
---

狀況 2(也就是上面 dppos-1 的狀況):

-X3
-12
---

能夠看出兩者是同樣的。沒懂?多看看,多想一想。

綜上,咱們將 dppos 改爲 dppos-1 不會有問題。你們遇到其餘問題也採起相似思路分析一波便可。

完整代碼:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        for pos in range(1, len(obstacles)):
            for line in range(1, 4):
                if obstacles[pos - 1] != line: # 因爲自底向上,所以是和 pos - 1 創建聯繫,而不是 pos + 1
                    dp[pos][line] = min(dp[pos][line], dp[pos - 1][line])
                else:
                    for nxt in range(1, 4):
                        if nxt != line and obstacles[pos] != nxt:
                            dp[pos][line] = min(dp[pos][line], 1 + dp[pos-1][nxt])

        return min(dp[-1])

趁熱打鐵再來一個

再來一個例子,1866. 恰有 K 根木棍能夠看到的排列數目

思路

直接上記憶化遞歸代碼:

class Solution:
    def rearrangeSticks(self, n: int, k: int) -> int:
        @lru_cache(None)
        def dp(i, j):
            if i == 0 and j != 0: return 0
            if i == 0 and j == 0: return 1
            return (dp(i - 1, j - 1) + dp(i - 1, j) * (i - 1)) % (10**9 + 7)
        return dp(n, k) % (10**9 + 7)

咱無論這個題是啥,代碼怎麼來的。假設這個代碼咱已經寫出來了。那麼如何改形成動態規劃呢?繼續套用三部曲。

1. 根據記憶化遞歸的入參創建 dp 數組

因爲 i 的取值 [0-n] 一共 n + 1 個, j 的取值是 [0-k] 一共 k + 1 個。所以初始化一個二維數組便可。

dp = [[0] * (k+1) for _ in range(n+1)]

2. 用記憶化遞歸的葉子節點返回值填充 dp 數組初始值

因爲 i == 0 and j == 0 是 1,所以直接寫 dp0 = 1 就行了。

dp = [[0] * (k+1) for _ in range(n+1)]
dp[0][0] = 1

3. 枚舉笛卡爾積,並複製主邏輯

就是兩層循環枚舉 i 和 j 的全部組合就行了。

dp = [[0] * (k+1) for _ in range(n+1)]
dp[0][0] = 1

for i in range(1, n + 1):
    for j in range(1, min(k, i) + 1):
        # ...
return dp[-1][-1]

最後把主邏輯複製過來完工了。

好比: return xxx 改爲 dp形參一 = xxx 等小細節。

最終的一個代碼就是:

class Solution:
    def rearrangeSticks(self, n: int, k: int) -> int:
        dp = [[0] * (k+1) for _ in range(n+1)]
        dp[0][0] = 1

        for i in range(1, n + 1):
            for j in range(1, min(k, i) + 1):
                dp[i][j] = dp[i-1][j-1]
                if i - 1 >= j:
                    dp[i][j] += dp[i-1][j] * (i - 1)
                dp[i][j] %= 10**9 + 7
        return dp[-1][-1]

總結

有的記憶化遞歸比較難以改寫,什麼狀況記憶化遞歸比較好寫,改爲動態規劃就比較麻煩我也在動態規劃專題給你們講過了,不清楚的同窗翻翻。

我之因此推薦你們從記憶化遞納入手,正是由於不少狀況下記憶化寫起來簡單,並且容錯高(想一想上面的青蛙跳的例子)。這是由於記憶化遞歸老是後序遍歷,會在到達葉子節點只會往上計算。而往上計算的過程和迭代的動態規劃是相似的。或者你也能夠認爲迭代的動態規劃是在模擬記憶化遞歸的歸的過程

咱們要作的就是把一些容易改造的方法學會,接下來面對難的儘可能用記憶化遞歸。據我所知,若是動態規劃能夠過,大多數記憶化遞歸均可以過。有一個極端狀況記憶化遞歸過不了:那就是力扣測試用例偏多,而且數據量大的測試用例比較多。這是因爲力扣的超時判斷是多個測試用例的用時總和,而不是單獨計算時間。

將記憶化遞歸改形成動態規劃能夠參考個人這三個步驟:

  1. 根據記憶化遞歸的入參創建 dp 數組
  2. 用記憶化遞歸的葉子節點返回值填充 dp 數組初始值
  3. 枚舉笛卡爾積,並複製主邏輯

另外有一點須要注意的是:狀態轉移方程的肯定和枚舉的方向息息相關,雖然不一樣題目細節差別很大。 可是咱們只要緊緊把握一個原則就好了,那就是:永遠不要用沒有計算好的狀態,而是僅適用已經計算好的狀態

相關文章
相關標籤/搜索