1. 程式人生 > 其它 >15.動態規劃:打家劫舍

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};  
}