0-1背包问题的学习及LeetCode相关习题练习
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; }