求出,具體原理後面會解釋。這個性質就是教科書中所說的「最優子結構」。如果定義不出這樣的子問題,那麼這道題實際上沒法用動態規劃解。打家劫舍問題由於比較簡單,定義子問題實際上是很直觀的。一些比較難的動態規劃題目可能需要一些定義子問題的技巧。
步驟二:寫出子問題的遞推關係這一步是求解動態規劃問題最關鍵的一步。然而,這一步也是最無法在代碼中體現出來的一步。在做題的時候,最好把這一步的思路用注釋的形式寫下來。做動態規劃題目不要求快,而要確保無誤。否則,寫代碼五分鐘,找 bug 半小時,豈不美哉?
我們來分析一下這道題的遞推關係:
假設一共有 個房子,每個房子的金額分別是 ,子問題 表示偷前 個房子(即 )中能偷到的最大金額。那麼,偷 個房子有兩種偷法:
子問題的遞推關係 個房子中最後一個房子是 。如果不偷這個房子,那麼問題就變成在前 個房子中偷到最大的金額,也就是子問題 。如果偷這個房子,那麼前一個房子 顯然不能偷,其他房子不受影響。那麼問題就變成在前 個房子中偷到的最大的金額。兩種情況中,選擇金額較大的一種結果。
另外,我們在寫遞推關係的時候,還要注意遞推關係的 base case。這樣才能構成完整的遞推關係,後面寫代碼也不容易在邊界條件上出錯。在這道題中,是 和 時的子問題:
步驟三:確定 DP 數組的計算順序在確定了子問題的遞推關係之後,下一步就是依次計算出這些子問題了。在很多教程中都會寫,動態規劃有兩種計算順序,一種是自頂向下的、使用備忘錄的遞歸方法,一種是自底向上的、使用 DP 數組的循環方法。不過在普通的動態規劃題目中,99% 的情況我們用循環方法就很好解決 ,所以我們最好在一開始就堅持自底向上方法,使用 DP 數組,這樣才有助於領悟動態規劃的真正精髓。
DP 數組也可以叫「子問題數組」,因為 DP 數組中的每一個元素都對應一個子問題 。如下圖所示,dp[k] 對應子問題 ,即偷前 間房子的最大金額。
DP 數組與子問題的對應關係那麼,只要搞清楚了子問題的計算順序,就可以確定 DP 數組的計算順序。對於打家劫舍問題,我們分析子問題的依賴關係,發現每個 依賴 和 。也就是說,dp[k] 依賴 dp[k-1] 和 dp[k-2],如下圖所示。
DP 數組的依賴順序那麼,既然 DP 數組中的依賴關係都是向右指的,DP 數組的計算順序就是從左向右。這樣我們可以保證,計算一個子問題的時候,它所依賴的那些子問題已經計算出來了。
確定了 DP 數組的計算順序之後,我們就可以寫出題解代碼了:
public int rob(int[] nums) { if (nums.length == 0) { return 0; } // 子問題: // f(k) = 偷 [0..k) 房間中的最大金額 // f(0) = 0 // f(1) = nums[0] // f(k) = max{ rob(k-1), nums[k-1] + rob(k-2) } int N = nums.length; int[] dp = new int[N+1]; dp[0] = 0; dp[1] = nums[0]; for (int k = 2; k <= N; k++) { // 套用子問題的遞推關係 dp[k] = Math.max(dp[k-1], nums[k-1] + dp[k-2]); } return dp[N]; }注意在上面的代碼中,有一大段關於子問題定義、遞推關係的公式。在面試的時候,我們一般需要先把子問題的定義、遞推關係給面試官解釋清楚,同時順手把這些內容用注釋的形式寫下來。這樣一來讓自己寫代碼不容易出錯,二來也能讓面試官更好地理解自己的思路。
步驟四:空間優化空間優化是動態規劃問題的進階內容了。對於初學者來說,可以不掌握這部分內容,寫出來的代碼無非是空間複雜度高一些。不過,打家劫舍問題同樣是空間優化的一個很好的例子,所以你不妨了解一下。
讓我們回顧一下在編程入門階段學習的斐波那契數列的求解方法。斐波那契數列的遞推關係是 。這是不是和打家劫舍問題一模一樣?計算斐波那契數列的時候,我們只需要用到三個變量,並不需要什麼 DP 數組:
// 計算斐波那契數列 // f(1) = f(2) = 1, f(n) = f(n-1) + f(n-2) int fib(int n) { if (n <= 2) { return 1; } int a = 1; int b = 1; for (int i = 2; i < n; i++) { int c = a + b; a = b; b = c; } return b; }請記住這段代碼,這段我們很早就會的代碼裡,其實蘊含了打家劫舍類動態規劃問題的空間優化技巧。
空間優化的基本原理是,很多時候我們並不需要始終持有全部的 DP 數組 。對於打家劫舍問題,我們發現,最後一步計算 的時候,實際上只用到了 和 的結果。 之前的子問題,實際上早就已經用不到了。
那麼,我們可以只用兩個變量保存兩個子問題的結果,就可以依次計算出所有的子問題。這樣一來,空間複雜度也從 降到了 。下面的動圖比較了空間優化前和優化後的對比關係:
空間優化前後對比(動圖)優化後的代碼如下所示:
public int rob(int[] nums) { int prev = 0; int curr = 0; // 每次循環,計算「偷到當前房子為止的最大金額」 for (int i : nums) { // 循環開始時,curr 表示 dp[k-1],prev 表示 dp[k-2] // dp[k] = max{ dp[k-1], dp[k-2] + i } int temp = Math.max(curr, prev + i); prev = curr; curr = temp; // 循環結束時,curr 表示 dp[k],prev 表示 dp[k-1] } return curr; }可以看到,我們只用到了三個變量:prev、curr 和 temp。在每一輪循環中,prev 都表示 dp[k-2],curr 都表示 dp[k-1],而 temp 負責計算出 dp[k] 的值,再往前循環複製。這三個變量的操作方式,和斐波那契數列中的三個變量一模一樣。
如果你是 Python 選手的話,還可以利用多重賦值的語法,去掉 temp 變量,在循環裡只用一行代碼:
def rob(self, nums: List[int]) -> int: prev = 0 curr = 0 # 每次循環,計算「偷到當前房子為止的最大金額」 for i in nums: # 循環開始時,curr 表示 dp[k-1],prev 表示 dp[k-2] # dp[k] = max{ dp[k-1], dp[k-2] + i } prev, curr = curr, max(curr, prev + i) # 循環結束時,curr 表示 dp[k],prev 表示 dp[k-1] return curr 總結 本文用打家劫舍問題展示了動態規劃問題的基本解題四步驟。動態規劃問題種類繁多,有些題目非常有難度,但這些問題千變萬化也離不開這四個基本解題步驟,只不過是把四步驟的某些步驟變得更難了。例如:
在「定義子問題」的步驟,有的題目需要定義二維、三維的子問題。在「子問題遞推關係」的步驟,有的題目需要分情況討論,有複雜的 max、min、sum 表達式。在「DP 數組計算順序」的步驟,有的題目需要反向計算,甚至斜向計算。在「空間優化」的步驟,有的題目需要使用臨時變量,使用特殊的計算順序。那麼你可能要問,題目千變萬化,怎麼才能全部學會呢?答案是由易到難、循序漸進,吃透例題,觸類旁通。
看到這裡,恭喜你走好了動態規劃入門的第一步。接下來,我會循序漸進地講解其他動態規劃題目,讓你能逐步掌握動態規劃問題的技巧,敬請期待。
往期文章我是 nettee,致力於分享面試算法的解題套路,讓你真正掌握解題技巧,做到舉一反三。我的《LeetCode 例題精講》系列文章正在寫作中,關注我的公眾號,獲取最新文章。