有讀者私下問我 LeetCode 「打家劫舍」系列問題(英文版叫 House Robber)怎么做,我發現這一系列題目的點贊非常之高,是比較有代表性和技巧性的動態規划題目,今天就來聊聊這道題目。
打家劫舍系列總共有三道,難度設計非常合理,層層遞進。第一道是比較標准的動態規划問題,而第二道融入了環形數組的條件,第三道更絕,把動態規划的自底向上和自頂向下解法和二叉樹結合起來,我認為很有啟發性。如果沒做過的朋友,建議學習一下。
下面,我們從第一道開始分析。
House Robber I

public int rob(int[] nums);
題目很容易理解,而且動態規划的特征很明顯。我們前文「動態規划詳解」做過總結,解決動態規划問題就是找「狀態」和「選擇」,僅此而已。
假想你就是這個專業強盜,從左到右走過這一排房子,在每間房子前都有兩種選擇:搶或者不搶。
如果你搶了這間房子,那么你肯定不能搶相鄰的下一間房子了,只能從下下間房子開始做選擇。
如果你不搶這件房子,那么你可以走到下一間房子前,繼續做選擇。
當你走過了最后一間房子后,你就沒得搶了,能搶到的錢顯然是 0(base case)。
以上的邏輯很簡單吧,其實已經明確了「狀態」和「選擇」:你面前房子的索引就是狀態,搶和不搶就是選擇。

在兩個選擇中,每次都選更大的結果,最后得到的就是最多能搶到的 money:
// 主函數
public int rob(int[] nums) {
return dp(nums, 0);
}
// 返回 nums[start..] 能搶到的最大值
private int dp(int[] nums, int start) {
if (start >= nums.length) {
return 0;
}
int res = Math.max(
// 不搶,去下家
dp(nums, start + 1),
// 搶,去下下家
nums[start] + dp(nums, start + 2)
);
return res;
}
明確了狀態轉移,就可以發現對於同一 start
位置,是存在重疊子問題的,比如下圖:

盜賊有多種選擇可以走到這個位置,如果每次到這都進入遞歸,豈不是浪費時間?所以說存在重疊子問題,可以用備忘錄進行優化:
private int[] memo;
// 主函數
public int rob(int[] nums) {
// 初始化備忘錄
memo = new int[nums.length];
Arrays.fill(memo, -1);
// 強盜從第 0 間房子開始搶劫
return dp(nums, 0);
}
// 返回 dp[start..] 能搶到的最大值
private int dp(int[] nums, int start) {
if (start >= nums.length) {
return 0;
}
// 避免重復計算
if (memo[start] != -1) return memo[start];
int res = Math.max(dp(nums, start + 1),
nums[start] + dp(nums, start + 2));
// 記入備忘錄
memo[start] = res;
return res;
}
這就是自頂向下的動態規划解法,我們也可以略作修改,寫出自底向上的解法:
int rob(int[] nums) {
int n = nums.length;
// dp[i] = x 表示:
// 從第 i 間房子開始搶劫,最多能搶到的錢為 x
// base case: dp[n] = 0
int[] dp = new int[n + 2];
for (int i = n - 1; i >= 0; i--) {
dp[i] = Math.max(dp[i + 1], nums[i] + dp[i + 2]);
}
return dp[0];
}
我們又發現狀態轉移只和 dp[i]
最近的兩個狀態有關,所以可以進一步優化,將空間復雜度降低到 O(1)。
int rob(int[] nums) {
int n = nums.length;
// 記錄 dp[i+1] 和 dp[i+2]
int dp_i_1 = 0, dp_i_2 = 0;
// 記錄 dp[i]
int dp_i = 0;
for (int i = n - 1; i >= 0; i--) {
dp_i = Math.max(dp_i_1, nums[i] + dp_i_2);
dp_i_2 = dp_i_1;
dp_i_1 = dp_i;
}
return dp_i;
}
以上的流程,在我們「動態規划詳解」中詳細解釋過,相信大家都能手到擒來了。我認為很有意思的是這個問題的 follow up,需要基於我們現在的思路做一些巧妙的應變。
House Robber II
這道題目和第一道描述基本一樣,強盜依然不能搶劫相鄰的房子,輸入依然是一個數組,但是告訴你這些房子不是一排,而是圍成了一個圈。
也就是說,現在第一間房子和最后一間房子也相當於是相鄰的,不能同時搶。比如說輸入數組 nums=[2,3,2]
,算法返回的結果應該是 3 而不是 4,因為開頭和結尾不能同時被搶。
這個約束條件看起來應該不難解決,我們前文「單調棧解決 Next Greater Number」說過一種解決環形數組的方案,那么在這個問題上怎么處理呢?
首先,首尾房間不能同時被搶,那么只可能有三種不同情況:要么都不被搶;要么第一間房子被搶最后一間不搶;要么最后一間房子被搶第一間不搶。

那就簡單了啊,這三種情況,那種的結果最大,就是最終答案唄!不過,其實我們不需要比較三種情況,只要比較情況二和情況三就行了,因為這兩種情況對於房子的選擇余地比情況一大呀,房子里的錢數都是非負數,所以選擇余地大,最優決策結果肯定不會小。
所以只需對之前的解法稍作修改即可:
public int rob(int[] nums) {
int n = nums.length;
if (n == 1) return nums[0];
return Math.max(robRange(nums, 0, n - 2),
robRange(nums, 1, n - 1));
}
// 僅計算閉區間 [start,end] 的最優結果
int robRange(int[] nums, int start, int end) {
int n = nums.length;
int dp_i_1 = 0, dp_i_2 = 0;
int dp_i = 0;
for (int i = end; i >= start; i--) {
dp_i = Math.max(dp_i_1, nums[i] + dp_i_2);
dp_i_2 = dp_i_1;
dp_i_1 = dp_i;
}
return dp_i;
}
至此,第二問也解決了。
House Robber III
第三題又想法設法地變花樣了,此強盜發現現在面對的房子不是一排,不是一圈,而是一棵二叉樹!房子在二叉樹的節點上,相連的兩個房子不能同時被搶劫,果然是傳說中的高智商犯罪:

整體的思路完全沒變,還是做搶或者不搶的選擇,去收益較大的選擇。甚至我們可以直接按這個套路寫出代碼:
Map<TreeNode, Integer> memo = new HashMap<>();
public int rob(TreeNode root) {
if (root == null) return 0;
// 利用備忘錄消除重疊子問題
if (memo.containsKey(root))
return memo.get(root);
// 搶,然后去下下家
int do_it = root.val
+ (root.left == null ?
0 : rob(root.left.left) + rob(root.left.right))
+ (root.right == null ?
0 : rob(root.right.left) + rob(root.right.right));
// 不搶,然后去下家
int not_do = rob(root.left) + rob(root.right);
int res = Math.max(do_it, not_do);
memo.put(root, res);
return res;
}
這道題就解決了,時間復雜度 O(N),N
為數的節點數。
但是這道題讓我覺得巧妙的點在於,還有更漂亮的解法。比如下面是我在評論區看到的一個解法:
int rob(TreeNode root) {
int[] res = dp(root);
return Math.max(res[0], res[1]);
}
/* 返回一個大小為 2 的數組 arr
arr[0] 表示不搶 root 的話,得到的最大錢數
arr[1] 表示搶 root 的話,得到的最大錢數 */
int[] dp(TreeNode root) {
if (root == null)
return new int[]{0, 0};
int[] left = dp(root.left);
int[] right = dp(root.right);
// 搶,下家就不能搶了
int rob = root.val + left[0] + right[0];
// 不搶,下家可搶可不搶,取決於收益大小
int not_rob = Math.max(left[0], left[1])
+ Math.max(right[0], right[1]);
return new int[]{not_rob, rob};
}
時間復雜度 O(N),空間復雜度只有遞歸函數堆棧所需的空間,不需要備忘錄的額外空間。
你看他和我們的思路不一樣,修改了遞歸函數的定義,略微修改了思路,使得邏輯自洽,依然得到了正確的答案,而且代碼更漂亮。這就是我們前文「不同定義產生不同解法」所說過的動態規划問題的一個特性。
實際上,這個解法比我們的解法運行時間要快得多,雖然算法分析層面時間復雜度是相同的。原因在於此解法沒有使用額外的備忘錄,減少了數據操作的復雜性,所以實際運行效率會快。
我最近精心制作了一份電子書《labuladong的算法小抄》,分為【動態規划】【數據結構】【算法思維】【高頻面試】四個章節,共 60 多篇原創文章,絕對精品!限時開放下載,在我的公眾號 labuladong 后台回復關鍵詞【pdf】即可免費下載!
歡迎關注我的公眾號 labuladong,技術公眾號的清流,堅持原創,致力於把問題講清楚!