0-1背包問題:
n件物品,它們裝入背包所占的容量分別為w1、w2……wn;它們所擁有的價值分別為v1、v2 ……vn;
有一個總容量為C的背包;
在裝滿背包的情況下,如何使得包內的總價值最大?
該問題的特點是:每個物品僅有一個,可以選擇放或者不放,也就是說每個物品只能使用一次。
思路:
1.首先定義一個狀態轉移數組dp,dp[i][j]表示前i件物品放入容量為j的背包中所能得到的最大價值;
2.尋找數組元素之間的關系式,也就是狀態轉移方程,我們將第i件物品是否放入背包中這個子問題拿出來進行分析,首先要明確的是我們的一切目標都是使得在既有的背包容量下,能夠得到最大的價值,
所以對於第i件物品,如果放入能夠使得在現有的容量下背包價值最大,則dp[i][j] = dp[i-1][j-w] + v;如果不能,則就不把第i件物品放入背包,那么在dp[i][j] = dp[i-1][j],即前i件物品放入容量為j的背包中所得到的
最大價值就是前i-1件物品放入容量為j的背包中所得的的最大價值。
總結一下,狀態轉移方程就是: dp[i][j] = max{dp[i][j] = dp[i-1][j-w] + v,dp[i][j] = dp[i-1][j]}
3.確定初始值,dp[0][0]表示前0件物品放入容量為0的背包中的最大價值,那么就是0,而對於多有i=0和j=0的元素,其值都是0;
模擬過程:
舉一個例子來模擬程序整個的執行過程;
i | 1 | 2 | 3 |
w | 1 | 2 | 3 |
v | 6 | 9 | 13 |
現在有三件物品,這些物品的價值和所占容量如上表所示,有一個容量為5的背包,在裝滿背包的情況下,如何使得背包里的價值最大?
通過一個表格來顯示狀態轉移數組的內部情況:
i\j | 0 | 1 | 2 | 3 | 4 | 5 |
0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 6 | 6 | 6 | 6 | 6 |
2 | 0 | 6 | 9 | 15 | 15 | 15 |
3 | 0 | 6 | 9 | 15 | 19 | 22 |
該表格表示dp數組內部的元素值,程序的執行過程如下:
i = 1 ==> w = 1,v = 6 :
1 | dp[1][1] = max(dp[0][1],dp[0][0]+6) |
2 | dp[1][2] = max(dp[0][2],dp[0][1]+6) |
3 | dp[1][3] = max(dp[0][3],dp[0][2]+6) |
4 | dp[1][4] = max(dp[0][4],dp[0][3]+6) |
5 | dp[1][5] = max(dp[0][5],dp[0][4]+6) |
i = 2 ==> w = 2,v=9:
…………
以此類推,可以得到狀態轉移表格中的數據。
優化使用空間:
通過狀態轉移方程 dp[i][j] = max{dp[i-1][j-w] + v,dp[i-1][j]} 我們可以發現,背包從前i件物品所能得到的最大價值只和前i-1件件物品所能得到的最大價值有關,所以可以將狀態轉移數組簡化成一維數組,只存儲在既有的容量下所能得到的
最大價值,但是內循環的容量變化順序應該翻轉一下,即容量應該從最大的總量依次向下變小,否則在正序計算的時候會發生錯誤計算,也就是會隱形的放大在當前容量下,能夠放入背包的物品的選擇范圍。比如對於優化后的方程dp[j] = max{dp[j-w] + v,dp[j]},
dp[j]實際是dp[i][j],而dp[j-w]和dp[j]實際是dp[i-1][j-w]和dp[i-1][j];如果正序計算的話那么,dp[j-w]和dp[j]實際是dp[i][j-w]和dp[i][j],所得出的語義結論就成了前i件物品在既有背包容量j的情況下,如果將第i件物品放入背包,最大價值是當前物品價值加上前i件物品在
容量j-w下的最大價值,這是不正確的;對於不放入背包的情況也是一樣的,第i件物品你都不讓入背包了,實際的最大價值怎么可能還是前i件物品在既有容量j下所得的的最大價值。所以內循環應該倒序計算。
實現的代碼如下:
public int knapsacks(int W,int N,int[] weights,int[] values){ int[][] dp = new int[N+1][W+1]; /* i: 代表當前的物品總數量 j: 代表當前的背包總體積 */ dp[0][0] = 0; dp[0][1] = 0; dp[1][0] = 0; for (int i = 1; i <= N; i++) { /* w: 代表第i個物品的體積 v: 代表第i個物品的價值 */ int w = weights[i-1]; int v = values[i-1]; for (int j = 1; j <= W; j++) { if (j>=w){ dp[i][j] = Math.max(dp[i-1][j],dp[i-1][j-w]+v); }else { // 如果當前這個第i個物品的體積比當前背包的總體積要大,那說明不能放入背包, // 直接就是考慮前i-1個物品放入背包,所能得到的最大價值 dp[i][j] = dp[i-1][j]; } } } return dp[N][W]; }
優化空間后的代碼:
public int knapsacks2(int W,int N,int[] weights,int[] values){ int[] dp = new int[W + 1]; for (int i = 1; i <= N; i++) { int w = weights[i - 1], v = values[i - 1]; for (int j = W; j >= 1; j--) { if (j >= w) { dp[j] = Math.max(dp[j], dp[j - w] + v); } } } return dp[W];
LeetCode練習:
第416題:
給定一個只包含正整數的非空數組。是否可以將這個數組分割成兩個子集,使得兩個子集的元素和相等。
注意:
每個數組中的元素不會超過 100
數組的大小不會超過 200
思路:
對於此題,如果能夠分割成兩個子集,同時這兩個子集的元素和相等,那么前提是這個數組的所有元素的和是一個偶數,否則一定不能分割成兩個元素和相等的子集;
如果所有的元素和是一個偶數,那么符合要求,我們接下來的目標就是尋找一個子集,這個子集的元素和應該是數組所有元素和的一半,就只要能夠找到一個子集合它的所有元素和是 sum / 2,那么返回true,暫且把sum/2命名為target;
經過分析可以直到,這是一個典型的0-1背包問題,target相等於背包的容量,只不過我們在這里不是如何使得背包裝滿的情況下,使得其價值最大,而只要能夠裝滿即可,所以采用動態規划的解決方案如下:
1.定義一個狀態轉移數組dp,dp[i][j]表示前i個元素中能否找到和為j的元素的子集,dp數組的類型是布爾類型
2.尋找狀態轉移方程:對於第i個元素,如果其放入“背包”中,那么dp[i][j]值應該是dp[i][j] = dp[i-1][j-nums[i]];如果不放入“背包”中,那么dp[i][j] = dp[i-1][j];
3.確定初始值,dp[0][0] = true,因為對於前0個元素,其和就是0,所以是能夠找到和為0的子集合的。
代碼如下:
public boolean canPartition(int[] nums) { if (nums.length == 1 && nums[0] != 0) return false; int sum = 0; for (int num : nums) { sum += num; } if (sum % 2 !=0) return false; int target = sum / 2; boolean[][] dp = new boolean[nums.length+1][target+1]; dp[0][0] = true; // 元素從第1個到第n個
// 容量從0到target
for (int i = 1; i <= nums.length; i++) { for (int j = 0; j <= target; j++) { if (j >= nums[i-1]) dp[i][j] = dp[i-1][j] || dp[i-1][j - nums[i-1]]; else dp[i][j] = dp[i-1][j]; } } return dp[nums.length][target]; }
優化空間后的代碼:
public boolean canPartition2(int[] nums) { if (nums.length == 1 && nums[0] != 0) return false; int sum = 0; for (int num : nums) { sum += num; } if (sum % 2 !=0) return false; int target = sum / 2; boolean[] dp = new boolean[target+1]; dp[0] = true; for (int i = 0; i < nums.length; i++) { for (int j = target; j >= nums[i]; j--) { if (j >= nums[i]) dp[j] = dp[j] || dp[j - nums[i]]; } } return dp[target]; }
繼續優化,通過分析狀態轉移方程我們可以直到,我們要的最后的結果是dp[target],而最終的dp[target]是從dp[target - nums[nums.length-1]]得到,以此類推,對於dp[i]我們知道,推導出它的公式是 dp[i] = dp[target - sum(nums[i..nums.length-1])],所以我們可以對內循環的邊界進行進一步的優化,從而減少循環的次數,優化的邊界值如下:
bound = Math.max(nums[i],target - sumarray(nums,i))
修改后的程序如下所示:
public boolean canPartition3(int[] nums) { if (nums.length == 1) return false; int sum = sumarray(nums,0); if (sum % 2 !=0) return false; int target = sum / 2; boolean[] dp = new boolean[target+1]; dp[0] = true; for (int i = 0; i < nums.length; i++) { int bound = Math.max(nums[i],target - sum); for (int j = target; j >= bound; j--) { dp[j] = dp[j] || dp[j - nums[i]]; } sum = sum - nums[i]; } return dp[target]; } private int sumarray(int[] nums,int index){ int res = 0; for (int i = index; i < nums.length; i++) { res += nums[i]; } return res; }