有讀者私下問我 leetcode 「打家劫舍」系列問題(英文版叫 house robber)怎麼做,我發現這一系列題目的點讚非常之高,是比較有代表性和技巧性的動態規劃題目,今天就來聊聊這道題目。
打家劫舍系列總共有三道,難度設計非常合理,層層遞進。第一道是比較標準的動態規劃問題,而第二道融入了環形陣列的條件,第三道更絕,把動態規劃的自底向上和自頂向下解法和二叉樹結合起來,我認為很有啟發性。如果沒做過的朋友,建議學習一下。
下面,我們從第一道開始分析。
public int rob(int nums);
題目很容易理解,而且動態規劃的特徵很明顯。我們前文「動態規劃詳解」做過總結,解決動態規劃問題就是找「狀態」和「選擇」,僅此而已。
假想你就是這個專業強盜,從左到右走過這一排房子,在每間房子前都有兩種選擇:搶或者不搶。
如果你搶了這間房子,那麼你肯定不能搶相鄰的下一間房子了,只能從下下間房子開始做選擇。
如果你不搶這件房子,那麼你可以走到下一間房子前,繼續做選擇。
當你走過了最後一間房子後,你就沒得搶了,能搶到的錢顯然是 0(base case)。
以上的邏輯很簡單吧,其實已經明確了「狀態」和「選擇」:你面前房子的索引就是狀態,搶和不搶就是選擇。
在兩個選擇中,每次都選更大的結果,最後得到的就是最多能搶到的 money:
// 主函式
public int rob(int nums)
// 返回 nums[start..] 能搶到的最大值
private int dp(int nums, int start)
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)
// 返回 dp[start..] 能搶到的最大值
private int dp(int nums, int start)
// 避免重複計算
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)
return dp[0];
}
我們又發現狀態轉移只和dp[i]
最近的兩個狀態有關,所以可以進一步優化,將空間複雜度降低到 o(1)。
int rob(int nums)
return dp_i;
}
以上的流程,在我們「動態規劃詳解」中詳細解釋過,相信大家都能手到擒來了。我認為很有意思的是這個問題的 follow up,需要基於我們現在的思路做一些巧妙的應變。
這道題目和第一道描述基本一樣,強盜依然不能搶劫相鄰的房子,輸入依然是乙個陣列,但是告訴你這些房子不是一排,而是圍成了乙個圈。
也就是說,現在第一間房子和最後一間房子也相當於是相鄰的,不能同時搶。比如說輸入陣列nums=[2,3,2]
,演算法返回的結果應該是 3 而不是 4,因為開頭和結尾不能同時被搶。
這個約束條件看起來應該不難解決,我們前文「單調棧解決 next greater number」說過一種解決環形陣列的方案,那麼在這個問題上怎麼處理呢?
首先,首尾房間不能同時被搶,那麼只可能有三種不同情況:要麼都不被搶;要麼第一間房子被搶最後一間不搶;要麼最後一間房子被搶第一間不搶。
那就簡單了啊,這三種情況,那種的結果最大,就是最終答案唄!不過,其實我們不需要比較三種情況,只要比較情況二和情況三就行了,因為這兩種情況對於房子的選擇餘地比情況一大呀,房子裡的錢數都是非負數,所以選擇餘地大,最優決策結果肯定不會小。
所以只需對之前的解法稍作修改即可:
public int rob(int nums)
// 僅計算閉區間 [start,end] 的最優結果
int robrange(int nums, int start, int end)
return dp_i;
}
至此,第二問也解決了。
第三題又想法設法地變花樣了,此強盜發現現在面對的房子不是一排,不是一圈,而是一棵二叉樹!房子在二叉樹的節點上,相連的兩個房子不能同時被搶劫,果然是傳說中的高智商犯罪:
整體的思路完全沒變,還是做搶或者不搶的選擇,去收益較大的選擇。甚至我們可以直接按這個套路寫出**:
mapmemo = new hashmap<>();
public int rob(treenode root)
這道題就解決了,時間複雜度 o(n),n
為數的節點數。
int rob(treenode root)
/* 返回乙個大小為 2 的陣列 arr
arr[0] 表示不搶 root 的話,得到的最大錢數
arr[1] 表示搶 root 的話,得到的最大錢數 */
int dp(treenode root) ;
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;
}
時間複雜度 o(n),空間複雜度只有遞迴函式堆疊所需的空間,不需要備忘錄的額外空間。
你看他和我們的思路不一樣,修改了遞迴函式的定義,略微修改了思路,使得邏輯自洽,依然得到了正確的答案,而且**更漂亮。這就是我們前文「不同定義產生不同解法」所說過的動態規劃問題的乙個特性。
實際上,這個解法比我們的解法執行時間要快得多,雖然演算法分析層面時間複雜度是相同的。原因在於此解法沒有使用額外的備忘錄,減少了資料操作的複雜性,所以實際執行效率會快。
團滅 LeetCode 打家劫舍 問題
leetcode 打家劫舍 系列問題共有三道 198.打家劫舍 213.打家劫舍ii 337.打家劫舍iii int rob vector nums 建模 給定陣列 nums中都是正整數,nums中相鄰的數不能同時取,制定一種取數策略,使得取到的nums中的數和最大,返回取到的數的最大和。思路 題目...
團滅LeetCode關於回文串的演算法題
本文主要針對leetcode上關於回文字串的演算法題進行總結。總體而言,leetcode上關於回文字串的演算法題可以分為以下幾類 1 在已知的字串串找到最長的回文字串 2 修改現有的字串得到回文字串 3 分割現有的字串得到回文字串 涉及題目 5.最長回文子串 131.分割回文串 132.分割回文串 ...
LeetCode 打家劫舍
你是乙個專業的小偷,計畫偷竊沿街的房屋。每間房內都藏有一定的現金,影響你偷竊的唯一制約因素就是相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。給定乙個代表每個房屋存放金額的非負整數陣列,計算你在不觸動警報裝置的情況下,能夠偷竊到的最高金額。示例 1 輸入 1...