15.動態規劃:打家劫舍
打家劫舍I(LeetCode 198題 難度:中等)
題目很容易理解,而且動態規劃的特徵很明顯。,解決動態規劃問題就是找「狀態」和「選擇」,僅此而已。
假想你就是這個專業強盜,從左到右走過這一排房子,在每間房子前都有兩種選擇:搶或者不搶。
如果你搶了這間房子,那麼你肯定不能搶相鄰的下一間房子了,只能從下下間房子開始做選擇。
如果你不搶這間房子,那麼你可以走到下一間房子前,繼續做選擇。
當你走過了最後一間房子後,你就沒得搶了,能搶到的錢顯然是 0(base case)。
以上的邏輯很簡單吧,其實已經明確了「狀態」和「選擇」:你面前房子的索引就是狀態,搶和不搶就是選擇。
在兩個選擇中,每次都選更大的結果,最後得到的就是最多能搶到的 money:
//主函式 publicintrob(int[]nums) { return dp(nums, 0); } //返回nums[start..]能搶到的最大值 privateintdp(int[]nums,intstart) { 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; //主函式 publicintrob(int[]nums) { //初始化備忘錄 memo= new int[nums.length]; Arrays.fill(memo,-1); //強盜從第0間房子開始搶劫 return dp(nums, 0); } //返回dp[start..]能搶到的最大值 privateintdp(int[]nums,intstart) { 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; }
這就是自頂向下的動態規劃解法,我們也可以略作修改,寫出自底向上的解法:
introb(int[]nums) {
int n=nums.length;
// dp[i]= x 表示:
//從第i間房子開始搶劫,最多能搶到的錢為x
//basecase: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)。
introb(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;
}
打家劫舍II(LeetCode 213題 難度:中等)
這道題目和第一道描述基本一樣,強盜依然不能搶劫相鄰的房子,輸入依然是一個數組,但是告訴你這些房子不是一排,而是圍成了一個圈。
也就是說,現在第一間房子和最後一間房子也相當於是相鄰的,不能同時搶。比如說輸入陣列nums=[2,3,2]
,演算法返回的結果應該是 3 而不是 4,因為開頭和結尾不能同時被搶。
首先,首尾房間不能同時被搶,那麼只可能有三種不同情況:
- 要麼都不被搶;
- 要麼第一間房子被搶最後一間不搶;
- 要麼最後一間房子被搶第一間不搶。
那就簡單了啊,這三種情況,哪種的結果最大,就是最終答案唄!不過,其實我們不需要比較三種情況,只要比較情況二和情況三就行了,****因為這兩種情況對於房子的選擇餘地比情況一大呀,房子裡的錢數都是非負數,所以選擇餘地大,最優決策結果肯定不會小。
publicintrob(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]的最優結果
introbRange(int[]nums,intstart,intend) {
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;
}
打家劫舍III(LeetCode 337題 難度:中等)
第三題又想法設法地變花樣了,此強盜發現現在面對的房子不是一排,不是一圈,而是一棵二叉樹!房子在二叉樹的節點上,相連的兩個房子不能同時被搶劫:
整體的思路完全沒變,還是做搶或者不搶的選擇,取收益較大的選擇。甚至我們可以直接按這個套路寫出程式碼:
Map<TreeNode,Integer>memo= new HashMap<>();
publicintrob(TreeNoderoot) {
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
為數的節點數。
但是這道題讓我覺得巧妙的點在於,還有更漂亮的解法。比如下面是我在評論區看到的一個解法:
introb(TreeNoderoot) {
int[]res=dp(root);
return Math.max(res[0],res[1]);
}
/*返回一個大小為2的陣列arr
arr[0]表示不搶root的話,得到的最大錢數
arr[1]表示搶root的話,得到的最大錢數*/
int[] dp(TreeNoderoot){
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};
}