0-1背包又没有思路,这也没涉及到取得价值最大,而且虽然对一块石头是取与不取,但是主要是关心取的顺序
经过代码随想录提示,其实最终就是要尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了,这两堆的差就是最后留下的最小石头,即按照之前的leetcode 416:分割等和的子集的思路,使尽量靠近sum / 2, 最后使用min(abs(sum - 2*dp[sum / 2]), abs(sum - 2*dp[sum / 2 + 1]));
来计算剩下的最小重量的石头
第一遍代码
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int sum = 0;
for(int i = 0; i < stones.size(); i++) {
sum += stones[i];
}
vector<int> dp(sum + 1, 0);
for(int i = 0; i < stones.size(); i++) {
for(int j = sum; j >= stones[i]; j--) {
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return min(abs(sum - 2*dp[sum / 2]), abs(sum - 2*dp[sum / 2 + 1]));
}
};
思路
动规五步曲:
1、确定dp数组以及下标的含义
dp[j]表示容量(这里说容量更形象,其实就是重量)为j的背包,最多可以背 最大重量 为dp[j],可以回忆一下01背包中,dp[j]的含义,容量为j的背包,最多可以装的价值为 dp[j]
相对于 01背包,本题中,石头的重量是 stones[i],石头的价值也是 stones[i] ,可以 “最多可以装的价值为 dp[j]” == “最多可以背的重量为dp[j]”
2、确定递推公式
01背包的滚动矩阵的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
本题则是:dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
根据1的分析很容易得到
3、dp数组如何初始化
既然 dp[j]中的j表示容量,那么最大容量(重量)是多少呢,就是所有石头的重量和
因为提示中给出1 <= stones.length <= 30,1 <= stones[i] <= 1000,所以最大重量就是30 * 1000,而我们要求的target其实只是最大重量的一半,所以dp数组开到15000大小就可以了
当然也可以把石头遍历一遍,计算出石头总重量 然后除2,得到dp数组的大小(第一遍代码直接整了一个石头总重量+1 的dp数组)
接下来就是如何初始化dp[j]呢,因为重量都不会是负数,所以dp[j]都初始化为0就可以了,这样在递归公式dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
中dp[j]才不会初始值所覆盖
4、确定遍历顺序
如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历
代码如下:
for (int i = 0; i < stones.size(); i++) { // 遍历物品
for (int j = target; j >= stones[i]; j--) { // 遍历背包
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
循环只要从sum / 2开始就行了,不需要从sum开始
5、举例推导dp数组
举例,输入:[2,4,1,1],此时target = (2 + 4 + 1 + 1)/2 = 4 ,dp数组状态图如下:
最后dp[target]里是容量为target的背包所能背的最大重量
那么分成两堆石头,一堆石头的总重量是dp[target],另一堆就是sum - dp[target]
在计算target的时候,target = sum / 2 因为是向下取整,所以sum - dp[target] 一定是大于等于dp[target]的
那么相撞之后剩下的最小石头重量就是 (sum - dp[target]) - dp[target]
个人感觉sum / 2向上取整的也需要考虑,所以第一遍代码最后返回的值是 return min(abs(sum - 2*dp[sum / 2]), abs(sum - 2*dp[sum / 2 + 1]));
代码随想录C++代码如下:
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
vector<int> dp(15001, 0);
int sum = 0;
for (int i = 0; i < stones.size(); i++) sum += stones[i];
int target = sum / 2;
for (int i = 0; i < stones.size(); i++) { // 遍历物品
for (int j = target; j >= stones[i]; j--) { // 遍历背包
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - dp[target] - dp[target];
}
};
本题其实和 leetcode 416:分割等和子集 几乎是一样的,只是最后对dp[target]的处理方式不同(即对返回值的处理),leetcode 416:分割等和子集 相当于是求背包是否正好装满,而本题是求背包最多能装多少
第一遍代码答案错误
0-1背包,1为加法,0为减法,dp[target]为结果为target - sum(为了包含负数)有几种可能
vector<int> dp(2*sum + 1, 0);
[-sum, sum] 一共可能获得的区间为2*sum - 1
因为下标又有加又有减,所以没办法避免重复
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
//0-1背包,1为加法,0为减法,dp[target]为结果为target - sum(为了包含负数)有几种可能
int sum = 0;
for(int i = 0; i < nums.size(); i++) {
sum += nums[i];
}
vector<int> dp(2*sum + 1, 0);//[-sum, sum]一共可能获得的区间为2*sum - 1
dp[sum - nums[0]] = 1;
dp[sum + nums[0]] = 1;
for(int i = 1; i < nums.size(); i++) {
for(int j = 2*sum; j >= 0; j--) {//因为下标又有加又有减,所以没办法避免重复
if(j >= nums[i] && j <= 2*sum - nums[i]) {
dp[j] = dp[j - nums[i]] + dp[j + nums[i]];
}
else if(j >= nums[i]) {
dp[j] = dp[j - nums[i]];
}
else if(j <= 2*sum - nums[i]) {
dp[j] = dp[j + nums[i]];
}
}
}
return dp[target + sum];
}
};
代码随想录思路
这道题目咋眼一看和动态规划背包啥的也没啥关系,本题要如何使表达式结果为target
既然为target,那么就一定有 left组合 - right组合 = target,相当于把 前面放加法 的数字加在一起是left组合,前面放减法 的数字加在一起是right组合,而自然left + right = sum,而所有数字的和sum是固定的,right = sum - left
公式来了, left - (sum - left) = target 推导出 left = (target + sum)/2
target是固定的,sum是固定的,left就可以求出来,此时问题就是在集合nums中找出和为left的组合,就和 1049.最后一块石头的重量II 在动态规划部分一致,就是0-1背包问题
还有初始化问题,这个初始化不好想,根据思路写第二遍代码:
不能整除就返回0
target超过了和的绝对值也是不行的
注意这个初始化,比较难想,可以想最开始的j = nums[0]的情况,那只有一个,要让dp[j - nums[i]] = 1才行
递归公式是累加,dp[j - nums[i]]加上nums[i]就可以达到j了,所以是累加
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for(int i = 0; i < nums.size(); i++) {
sum += nums[i];
}
if((target + sum) % 2 != 0) {//不能整除就返回0
return 0;
}
if(abs(target) > sum) return 0;
//target超过了和的绝对值也是不行的
int left = (target + sum) / 2;//目标就是找left
vector<int> dp(left + 1, 0);//都 >= 0
dp[0] = 1;//注意这个初始化,比较难想,可以想最开始的j = nums[0]的情况,那只有一个,要让dp[j - nums[i]] = 1才行
for(int i = 0; i < nums.size(); i++) {
for(int j = left; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
//递归公式是累加,dp[j - nums[i]]加上nums[i]就可以达到j了,所以是累加
}
}
return dp[left];
}
};
还有对于在集合nums中找出 和为left的组合 的问题还可以使用回溯,根据思路写第三遍代码:
先判断等不等,因为curSum已经更新,这个if需要在下一个if之前
回溯一定要先排序,因为有判断curSum + nums[startIndex] > target
就退出了
class Solution {
public:
vector<int> path;
vector<vector<int>> res;
void backTracking(vector<int>& nums, int target, int startIndex, int curSum) {
if(curSum == target) {
//先判断等不等,因为curSum已经更新,这个if需要在下一个if之前
res.push_back(path);
}
if(startIndex >= nums.size() || curSum + nums[startIndex] > target) {
return;
}
for(int i = startIndex; i < nums.size(); i++) {
curSum += nums[i];//在这里curSum更新过了
path.push_back(nums[i]);
backTracking(nums, target, i + 1, curSum);
path.pop_back();
curSum -= nums[i];
}
}
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for(int i = 0; i < nums.size(); i++) {
sum += nums[i];
}
if((target + sum) % 2 != 0) {
return 0;
}
if(abs(target) > sum) return 0;
int left = (target + sum) / 2;
sort(nums.begin(), nums.end());
//回溯一定要排序,因为有判断curSum + nums[startIndex] > target就退出了
backTracking(nums, left, 0, 0);
return res.size();
}
};
这是回溯中的组合总和问题,当然,也可以转变成序列区间选+ 或者 -,使用回溯法,那就是另一个解法
跟第三遍代码在判断条件的位置上有点区别,思路完全一致
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
if (sum == target) {
result.push_back(path);
}
// 如果 sum + candidates[i] > target 就终止遍历
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i + 1);
sum -= candidates[i];
path.pop_back();
}
}
public:
int findTargetSumWays(vector<int>& nums, int S) {
int sum = 0;
for (int i = 0; i < nums.size(); i++) sum += nums[i];
if (S > sum) return 0; // 此时没有方案
if ((S + sum) % 2) return 0; // 此时没有方案,两个int相加的时候要各位小心数值溢出的问题
int bagSize = (S + sum) / 2; // 转变为组合总和问题,bagsize就是要求的和
// 以下为回溯法代码
result.clear();
path.clear();
sort(nums.begin(), nums.end()); // 需要排序
backtracking(nums, bagSize, 0, 0);
return result.size();
}
};
如何转化为01背包问题呢,假设加法的总和为x,那么减法对应的总和就是sum - x
所以我们要求的是 x - (sum - x) = target
,x = (target + sum) / 2
,此时问题就转化为,装满容量为x(就相当于之前的left,完全一致)的背包,有几种方法
这里的x,就是bagSize,也就是我们后面要求的背包容量,大家看到(target + sum) / 2
应该担心计算的过程中向下取整有没有影响,这么担心就对了,例如sum 是5,S是2的话其实就是无解的,所以:
(C++代码中,输入的S 就是题目描述的 target)
if ((S + sum) % 2 == 1) return 0; // 此时没有方案
同时如果 S的绝对值已经大于sum,那么也是没有方案的
(C++代码中,输入的S 就是题目描述的 target)
if (abs(S) > sum) return 0; // 此时没有方案
再回归到01背包问题,为什么是01背包呢?
因为每个物品(题目中的1)只用一次(核心特征)
这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少,本题则是装满有几种方法。其实这就是一个组合问题了
1、确定dp数组以及下标的含义
dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法
其实也可以使用二维dp数组来求解本题,dp[i][j]:使用 下标为[0, i]的nums[i]能够凑满j(包括j)这么大容量的包,有dp[i][j]种方法
下面我都是统一使用一维数组进行讲解, 二维降为一维(滚动数组),其实就是上一层拷贝下来
2、确定递推公式
有 哪些来源可以推出dp[j] 呢?
只要搞到nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法
例如:dp[j],j 为5,
已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 容量为5的背包
已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 容量为5的背包
已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 容量为5的背包
已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 容量为5的背包
已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 容量为5的背包
那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来
所以求组合类问题的公式,都是类似这种:
dp[j] += dp[j - nums[i]]
3、dp数组如何初始化
从递推公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递推结果将都是0(第二遍代码出现困难的地方,因为递归公式的下标是减,所以需要初始化第0项)
这里有录友可能认为从dp数组定义来说 dp[0] 应该是0,也有录友认为dp[0]应该是1
其实不要硬去解释它的含义,咱就把 dp[0]的情况带入本题看看应该等于多少
如果数组[0] ,target = 0,那么 bagSize = (target + sum) / 2 = 0。 dp[0]也应该是1, 也就是说给数组里的元素 0 前面无论放加法还是减法,都是 1 种方法
所以本题我们应该初始化 dp[0] 为 1
可能有同学想了,那 如果是 数组[0,0,0,0,0] target = 0 呢
其实 此时最终的dp[0] = 32,也就是这五个零 子集的所有组合情况,但此dp[0]非彼dp[0],dp[0]能算出32,其基础是因为dp[0] = 1 累加起来的
dp[j]其他下标对应的数值也应该初始化为0,从递推公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来
4、确定遍历顺序
对于01背包问题一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序
5、举例推导dp数组
输入:nums: [1, 1, 1, 1, 1], S: 3
bagSize = (S + sum) / 2 = (3 + 5) / 2 = 4
dp数组状态变化如下:
代码随想录C++代码如下:
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int S) {
int sum = 0;
for (int i = 0; i < nums.size(); i++) sum += nums[i];
if (abs(S) > sum) return 0; // 此时没有方案
if ((S + sum) % 2 == 1) return 0; // 此时没有方案
int bagSize = (S + sum) / 2;
vector<int> dp(bagSize + 1, 0);
dp[0] = 1;
for (int i = 0; i < nums.size(); i++) {
for (int j = bagSize; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
return dp[bagSize];
}
};
时间复杂度:O(n × m),n为正数个数,m为背包容量
空间复杂度:O(m),m为背包容量
此时 大家应该不禁想起,我们之前讲过的回溯算法:39. 组合总和 是不是应该也可以用dp来做?
是的,如果仅仅是求个数的话,就可以用dp,但回溯算法:39. 组合总和 要求的是把所有组合列出来,还是要使用回溯法爆搜的
本题还是有点难度,大家也可以记住,在求装满背包有几种方法的情况下,递推公式一般为:dp[j] += dp[j - nums[i]];
第一遍代码
使用的动态规划背包存储数组dp[i][j]表示strs最大子集长度,初始值为0
其中最多有i个0,j个1,循环顺序方面外层每次循环一个元素,获取其0,1的数量
然后递归式就把之前的减去其0,1数量的数组元素加一,当然要比大小,判断是不是把那个元素加进去,内层循环还是从大到小避免重复
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
/*
使用的动态规划背包存储数组dp[i][j]表示strs最大子集长度
其中最多有i个0,j个1,循环顺序方面外层每次循环一个元素,获取其0,1的数量
然后递归式就把之前的减去其0,1数量的数组元素加一,当然要比大小,判断是不是把那个元素加进去
*/
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for(string str : strs) {
int zNum = 0;
int oNum = 0;
for(char c : str) {
if(c == '0') zNum++;
else oNum++;
}
for(int i = m; i >= zNum; i--) {
for(int j = n; j >= oNum; j--) {
dp[i][j] = max(dp[i][j], dp[i - zNum][j - oNum] + 1);
}
}
}
return dp[m][n];
}
};
代码随想录思路
多重背包是每个物品数量不同的情况。本题中strs 数组里的元素就是物品,每个物品都是一个!而m 和 n相当于是一个背包,两个维度的背包
理解成多重背包的同学主要是把m和n混淆为物品了,感觉这是不同数量的物品,所以以为是多重背包,但本题其实是01背包问题
只不过这个背包有两个维度,一个是m 一个是n,而不同长度的字符串就是不同大小的待装物品
动规五部曲:
1、确定dp数组(dp table)以及下标的含义
dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]
2、确定递推公式
dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1,dp[i][j] 就可以是 dp[i - zeroNum][j - oneNum] + 1,然后我们在遍历的过程中,取dp[i][j]的最大值
所以递推公式:dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
此时大家可以回想一下01背包的递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])
这就是一个典型的01背包,只不过物品的重量有了两个维度而已
3、dp数组如何初始化
01背包的dp数组初始化为0就可以,因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖
4、确定遍历顺序
01背包一定是外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历,本题也是,物品就是strs里的字符串,背包容量就是题目描述中的m和n
代码如下:
for (string str : strs) { // 遍历物品
int oneNum = 0, zeroNum = 0;
for (char c : str) {
if (c == '0') zeroNum++;
else oneNum++;
}
for (int i = m; i >= zeroNum; i--) { // 遍历背包容量且从后向前遍历!
for (int j = n; j >= oneNum; j--) {
dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
}
}
}
那个遍历背包容量的两层for循环先后顺序有没有什么讲究?没讲究,都是物品重量的一个维度,先遍历哪个都行
5、举例推导dp数组
以输入:[“10”,“0001”,“111001”,“1”,“0”],m = 3,n = 3为例
最后dp数组的状态如下所示:
代码随想录C++代码如下:
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
vector<vector<int>> dp(m + 1, vector<int> (n + 1, 0)); // 默认初始化0
for (string str : strs) { // 遍历物品
int oneNum = 0, zeroNum = 0;
for (char c : str) {
if (c == '0') zeroNum++;
else oneNum++;
}
for (int i = m; i >= zeroNum; i--) { // 遍历背包容量且从后向前遍历!
for (int j = n; j >= oneNum; j--) {
dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
}
}
}
return dp[m][n];
}
};
时间复杂度: O(kmn),k 为strs的长度
空间复杂度: O(mn)
至此讲解了0-1背包的多种应用
纯0-1背包 是求 给定背包容量 装满背包 的最大价值是多少
416. 分割等和子集 是求 给定背包容量,能不能装满这个背包
1049. 最后一块石头的重量II 是求 给定背包容量,尽可能装,最多能装多少
494. 目标和 是求 给定背包容量,装满背包有多少种方法
本题是求 给定背包容量,装满背包最多有多少个物品