01背包问题
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
一个背包最大的容量为4,问:背包能装的最大的价值为多少?
01背包问题是一个很经典的问题,后面可以延伸出很多同类的题型,下面我们按照动态规划的解题步骤一步一步的来思考
-
确定dp函数
dp[i][j]
:从0到i的物品中任取一件物品,放进容量为j的背包中,最大的价值。 -
确定dp递推函数
当遍历到物品i的时候,有什么事决定当前的dp数组的值的呢
-
不放当前物品i:当前的最大容量为
dp[i-1][j]
-
放当前物品i(当前物品放得下):当前最大容量为:
dp[i-1][j-weight[i]] + value[i]
故当前的dp数组的值为两者的最大值:
Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
-
-
初始化,因为
dp[i][j]
是根据i-1
和j-weight[i]
来决定的,所以,第一列和第一行很定要初始化,那么初始化为多少呢-
第一列:指的是当背包容量为0的时候的最大价值为多少,因为没有重量为0的物品,故第一列都是0;
-
第一行:指的是当物品0在放在背包里的最大价值为多少。
-
当物品0的重量 > 背包容量的时候,dp数组的值为0
-
当物品0的重量 < 背包容量的时候,do数组的值为物品0的价值,即为value[0]
-
-
-
确定遍历顺序:根据递推公式可知,数组的值都来自于左侧和左上角,所以递归顺寻基本为从左到右,从上到下,但是是先遍历物品还是先遍历背包,都是可以的。我们这里以先遍历物品为例写出代码
for(int i = 1; i < weight.length; i++) { for(int j = 1; j <= pageSize; j++) { if(j < weight[i]) { do[i][j] = dp[i-1][j]; }else { dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]); } } }
-
距离推导dp数组
最终的结果就是
dp[2][4]
public class BagProblem { public static void main(String[] args) { int[] weight = {1,3,4}; int[] value = {15,20,30}; int bagSize = 4; testWeightBagProblem(weight,value,bagSize); } /** * 动态规划获得结果 * @param weight 物品的重量 * @param value 物品的价值 * @param bagSize 背包的容量 */ public static void testWeightBagProblem(int[] weight, int[] value, int bagSize){ // 创建dp数组 int goods = weight.length; // 获取物品的数量 int[][] dp = new int[goods][bagSize + 1]; // 初始化dp数组 // 创建数组后,其中默认的值就是0 for (int j = weight[0]; j <= bagSize; j++) { dp[0][j] = value[0]; } // 填充dp数组 for (int i = 1; i < weight.length; i++) { for (int j = 1; j <= bagSize; j++) { if (j < weight[i]) { /** * 当前背包的容量都没有当前物品i大的时候,是不放物品i的 * 那么前i-1个物品能放下的最大价值就是当前情况的最大价值 */ dp[i][j] = dp[i-1][j]; } else { /** * 当前背包的容量可以放下物品i * 那么此时分两种情况: * 1、不放物品i * 2、放物品i * 比较这两种情况下,哪种背包中物品的最大价值最大 */ dp[i][j] = Math.max(dp[i-1][j] , dp[i-1][j-weight[i]] + value[i]); } } } // 打印dp数组 for (int i = 0; i < goods; i++) { for (int j = 0; j <= bagSize; j++) { System.out.print(dp[i][j] + "\t"); } System.out.println("\n"); } } }
优化
我们来观察下,可以放进背包的时候,dp数组的递推公式:Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
,我们发现如果把i-1层的数据拷贝到i层,再做赋值的话,原递推公式可以写成:Math.max(dp[i][j], dp[i][j-weight[i]] + value[i])
,此时我们发现,其实用一个一维数组dp[j]
就可以解决01背包问题了。这种数组就是滚动数组。
-
确定数组的定义:
dp[j]
:容量为j的背包,所背的物品的最大价值。 -
确定递推公式:
dp[j] = Math.max(dp[j], dp[j-weight[i]] + value[i])
-
初始化:
dp[j]
表示:容量为j的背包,所背的物品价值可以最大为dp[j]
,那么dp[0]
就应该是0,因为背包容量为0所背的物品的最大价值就是0。那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?
看一下递归公式:
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。
-
确定遍历顺序
for (int i = 0; i < wLen; i++){ for (int j = bagWeight; j >= weight[i]; j--){ dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]); } }
可以发现,和二维数组的情况下,都是先遍历物品再遍历背包,但是遍历背包的顺寻是发声变化的。
背包变成从后往前遍历了。因为如果从前往后的话,会导致重复的选取一个物品。我们来举个例子:
重量 价值 物品0 1 15 物品1 3 20 物品2 4 30 假设我们此时遍历到i = 0的时候,我们来试一下从前往后遍历,dp数组的赋值
-
dp[1] = dp[1- weight[0]] + value[0] = dp[0] + value[0] = 15
-
dp[2] = dp[2- weight[0]] + value[0] = dp[1] + value[0] = 15
,此时我们发现,这个地方加了两次物品0。
那为什么二维的时候不会进行重复的选取呢?我们把二维的遍历赋值的代码再写出来一次
for(int i = 1; i < weight.length; i++) { for(int j = 1; j <= pageSize; j++) { if(j < weight[i]) { do[i][j] = dp[i-1][j]; }else { dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]); } } }
因为对于二维
dp
,dp[i][j]
都是通过上一层即dp[i - 1][j]
计算而来,本层的dp[i][j]
并不会被覆盖!再来看遍历顺序,这里直接给出结论,一位数组的遍历时不可以先遍历背包再遍历物品的!只能先遍历物品再遍历背包
因为一维dp的写法,背包容量一定是要倒序遍历,如果遍历背包容量放在上一层,那么每个
dp[j]
就只会放入一个物品,即:背包里只放入了一个物品。 -
-
举例推导dp数组
public static void main(String[] args) { int[] weight = {1, 3, 4}; int[] value = {15, 20, 30}; int bagWight = 4; testWeightBagProblem(weight, value, bagWight); } public static void testWeightBagProblem(int[] weight, int[] value, int bagWeight){ int wLen = weight.length; //定义dp数组:dp[j]表示背包容量为j时,能获得的最大价值 int[] dp = new int[bagWeight + 1]; //遍历顺序:先遍历物品,再遍历背包容量 for (int i = 0; i < wLen; i++){ for (int j = bagWeight; j >= weight[i]; j--){ dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]); } } //打印dp数组 for (int j = 0; j <= bagWeight; j++){ System.out.print(dp[j] + " "); } }
本题一开始看起来比较像是回溯类的问题,结果回溯算法写出来以后,可能会超时,所以,用01背包问题试着进行解答。
根据题意,我们可以理解成:找出一个总和为原数组总和一半的子集。转化为01背包问题有以下几个要点
-
背包容量为原数组总和一半
-
物品为nums数组的每个元素
-
nums中的每个元素,既是物品的价值又是物品的重量
-
背包中的的物品不能重复
-
当背包正好装满,则说明找到了等和子集
class Solution { public boolean canPartition(int[] nums) { int sum = 0; for (int num : nums) { sum+=num; } if (sum%2 != 0) return false;//首先排除总和为奇数的情况,肯定没有等和子集 int pageSize = sum/2; //回溯也可以解决,但是可能会超时,使用01背包 //背包容量:sum/2,重量 = 价值,是否能正好装满,每个元素不可重复放入 int[] dp = new int[pageSize+1]; for (int i = 0; i < nums.length; i++) { for (int j = pageSize; j >= nums[i] ; j--) { dp[j] = Math.max(dp[j], dp[j-nums[i]] + nums[i]); } } if (dp[pageSize] == pageSize) return true; else return false; } }
本题和上一题其实本质上是一样的,本题是尽可能的把石头分成重量相近的两堆,然后两堆石头重量的差值就是题目要求的。
前面的解题思路和上题一样,
-
先求出总和的一半作为背包容量(但是不用排除奇数的情况,除出来的不是整数,直接去尾法)
-
物品为stones的每个元素,其既是物品的价值又是物品的重量
-
背包中的的物品不能重复
class Solution { public int lastStoneWeightII(int[] stones) { int sum = 0; for (int stone : stones) { sum+=stone; } int target = sum/2; int[] dp = new int[target+1]; for (int i = 0; i < stones.length; i++) { for (int j = target; j >= stones[i]; j--) { dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]); } } return sum - 2*dp[target]; } }
本题是有公式推导环节的,第一次做真的不容易做出来,但是公式后面基本就是01背包的思想了,但是本题求的是有多少种组合的方式,而不是求和,所以递推公式还要再改一下。
我们先来推导公式:
根据题目要求,其实本质就是把数组的数字分成两堆(left和right),然后让left - right。得出来的差值就是target。而left+right是固定不变得,我们命名为sum
于是我们有以下两个公式
-
left + right = sum
-
left - right = target
两个式子联立,得到left = (sum + target) / 2。此时本题就变成了,从nums中取物品,放到背包容量为left的背包里,问有多少种放法。
-
dp数组
dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法
-
递推公式
我们要想清楚dp[j]的来源,只来自于一个地方,就是dp[j - nums[i]],只需要每次循环把dp[j - nums[i]]加上去即可。
dp[j] += dp[j - nums[i]]//这个公式可以记住,会经常用到
-
初始化
根据递推公式,我们可以知道,dp数组只和它前面的值有关系,只在前面的值上加减,所以dp[0]一定不能是0。
此处我们初始化为1。
-
遍历顺序
就是普通的一维数组的01背包的遍历顺序
for (int i = 0; i < nums.length; i++) { for (int j = pageSize; j >=nums[i] ; j--) { dp[j] += dp[j - nums[i]]; } }
-
举例
输入:nums = [1,1,1,1,1], target = 3
class Solution { public int findTargetSumWays(int[] nums, int target) { //有一定的公式推导 //求和 int sum = 0; for (int num : nums) { sum+=num; } //排除不存在的情况 if (sum < Math.abs(target)) return 0; if ((sum+target)%2 != 0) return 0; int pageSize = (sum+target)/2; //定义dp[j]数组,dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法 int[] dp = new int[pageSize+1]; //初始化 dp[0] = 1; for (int i = 0; i < nums.length; i++) { for (int j = pageSize; j >=nums[i] ; j--) { dp[j] += dp[j - nums[i]]; } } return dp[pageSize]; } }
本题还有比较难的,主要是不太好理解,不知道该怎么定义dp数组,怎么将背包问题套进这道题。
我们很容易就把m和n当成是物品了,然后想当然的认为结果集合为背包,这样就很容易把思路弄乱
我们应该把m和n看成是背包,只不过是两个维度的背包,此时就不能用滚动数组来一维的表示dp数组了。然后把strs中每个str作为物品
-
确定dp数组
dp[i][j]
:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]
。 -
推导公式
我们要知道当前
dp[i][j]
的值,就要知道当前str中有多少个0和1,我们设为zeroNum和oneNum。dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
-
初始化
全部初始化为0
-
确定遍历顺序
-
内外层的遍历顺序,先物品还是先背包都可以,我们习惯先物品
-
但是循环内部应该都是从后往前的,因为从前往后的话,可能会出现当前的zeroNum(oneNum)大于m(n)的情况,从而出现数组下标越界。
for (int i = 1; i <= zeroNum; i++) { for (int j = 1; j <= oneNum; j++) { dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1); } }
所以应该是从后往前遍历才能避免越界的情况
for (int i = 1; i <= zeroNum; i++) { for (int j = 1; j <= oneNum; j++) { dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1); } }
-
-
举例
class Solution { public int findMaxForm(String[] strs, int m, int n) { //dp[i][j]表示i个0和j个1时的最大子集 int[][] dp = new int[m + 1][n + 1]; int oneNum, zeroNum; for (String str : strs) { oneNum = 0; zeroNum = 0; for (char ch : str.toCharArray()) { if (ch == '0') { zeroNum++; } else { oneNum++; } } //倒序遍历 for (int i = m; i >= zeroNum; i--) { for (int j = n; j >= oneNum; j--) { dp[i][j] = Math.max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1); } } } return dp[m][n]; } }