詳解【打家劫舍】系列動態規劃問題

讀完本文,你可以去力扣拿下如下題目:

198.打家劫舍

213.打家劫舍II

337.打家劫舍III

-----------

有讀者私下問我 LeetCode 「打家劫舍」系列問題(英文版叫 House Robber)怎麼做,我發現這一系列題目的點贊非常之高,是比較有代表性和技巧性的動態規劃題目,今天就來聊聊這道題目。

打家劫舍系列總共有三道,難度設計非常合理,層層遞進。第一道是比較標準的動態規劃問題,而第二道融入了環形數組的條件,第三道更絕,把動態規劃的自底向上和自頂向下解法和二叉樹結合起來,我認爲很有啓發性。如果沒做過的朋友,建議學習一下。

下面,我們從第一道開始分析。

House Robber I

6147bbdf310af02edb4e2e39ee005905.jpg

public int rob(int[] nums);

題目很容易理解,而且動態規劃的特徵很明顯。我們前文「動態規劃詳解」做過總結,解決動態規劃問題就是找「狀態」和「選擇」,僅此而已

假想你就是這個專業強盜,從左到右走過這一排房子,在每間房子前都有兩種選擇:搶或者不搶。

如果你搶了這間房子,那麼你肯定不能搶相鄰的下一間房子了,只能從下下間房子開始做選擇。

如果你不搶這件房子,那麼你可以走到下一間房子前,繼續做選擇。

當你走過了最後一間房子後,你就沒得搶了,能搶到的錢顯然是 0(base case)。

以上的邏輯很簡單吧,其實已經明確了「狀態」和「選擇」:你面前房子的索引就是狀態,搶和不搶就是選擇

44518b0a94fb3984312ce557966ca429.jpeg

在兩個選擇中,每次都選更大的結果,最後得到的就是最多能搶到的 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 位置,是存在重疊子問題的,比如下圖:

267f2bb39fbad1ca422faedbe34c3c56.jpeg

盜賊有多種選擇可以走到這個位置,如果每次到這都進入遞歸,豈不是浪費時間?所以說存在重疊子問題,可以用備忘錄進行優化:

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,需要基於我們現在的思路做一些巧妙的應變。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

House Robber II

這道題目和第一道描述基本一樣,強盜依然不能搶劫相鄰的房子,輸入依然是一個數組,但是告訴你這些房子不是一排,而是圍成了一個圈

也就是說,現在第一間房子和最後一間房子也相當於是相鄰的,不能同時搶。比如說輸入數組 nums=[2,3,2],算法返回的結果應該是 3 而不是 4,因爲開頭和結尾不能同時被搶。

這個約束條件看起來應該不難解決,我們前文「單調棧解決 Next Greater Number」說過一種解決環形數組的方案,那麼在這個問題上怎麼處理呢?

首先,首尾房間不能同時被搶,那麼只可能有三種不同情況:要麼都不被搶;要麼第一間房子被搶最後一間不搶;要麼最後一間房子被搶第一間不搶。

be9fa34adb48b9b85e67f79719eb5c54.jpeg

那就簡單了啊,這三種情況,那種的結果最大,就是最終答案唄!不過,其實我們不需要比較三種情況,只要比較情況二和情況三就行了,因爲這兩種情況對於房子的選擇餘地比情況一大呀,房子裏的錢數都是非負數,所以選擇餘地大,最優決策結果肯定不會小

所以只需對之前的解法稍作修改即可:

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

第三題又想法設法地變花樣了,此強盜發現現在面對的房子不是一排,不是一圈,而是一棵二叉樹!房子在二叉樹的節點上,相連的兩個房子不能同時被搶劫,果然是傳說中的高智商犯罪:

9a1b4661c90d296a60d61921628717c2.jpg

整體的思路完全沒變,還是做搶或者不搶的選擇,去收益較大的選擇。甚至我們可以直接按這個套路寫出代碼:

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 爲數的節點數。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

但是這道題讓我覺得巧妙的點在於,還有更漂亮的解法。比如下面是我在評論區看到的一個解法:

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),空間複雜度只有遞歸函數堆棧所需的空間,不需要備忘錄的額外空間。

你看他和我們的思路不一樣,修改了遞歸函數的定義,略微修改了思路,使得邏輯自洽,依然得到了正確的答案,而且代碼更漂亮。這就是我們前文「不同定義產生不同解法」所說過的動態規劃問題的一個特性。

實際上,這個解法比我們的解法運行時間要快得多,雖然算法分析層面時間複雜度是相同的。原因在於此解法沒有使用額外的備忘錄,減少了數據操作的複雜性,所以實際運行效率會快。


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