代码随想录1刷—动态规划篇(二):背包问题~
- 0-1背包问题 基础理论
- [416. 分割等和子集](https://leetcode.cn/problems/partition-equal-subset-sum/)
- [1049. 最后一块石头的重量 II](https://leetcode.cn/problems/last-stone-weight-ii/)
- [494. 目标和](https://leetcode.cn/problems/target-sum/)
- [474. 一和零](https://leetcode.cn/problems/ones-and-zeroes/)
- 完全背包理论基础
- [518. 零钱兑换 II](https://leetcode.cn/problems/coin-change-2/)
- [377. 组合总和 Ⅳ](https://leetcode.cn/problems/combination-sum-iv/)
- [70. 爬楼梯](https://leetcode.cn/problems/climbing-stairs/)
- [322. 零钱兑换](https://leetcode.cn/problems/coin-change/)
- [279. 完全平方数](https://leetcode.cn/problems/perfect-squares/)
- [139. 单词拆分](https://leetcode.cn/problems/word-break/)
- 多重背包理论基础
- 背包问题总结
0-1背包问题 基础理论
问题
有 n n n件物品和一个最多能背重量为 w w w的背包。第 i i i件物品的重量是 w e i g h t [ i ] weight[i] weight[i],得到的价值是 v a l u e [ i ] value[i] value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
暴力解法
每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是 O ( 2 n ) O(2^n) O(2n),这里的 n n n表示物品数量。所以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化。
动态规划
为便于理解,先规定背包最大重量为4,物品重量和价值如下,问背包能背的物品最大价值是多少?
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
1、dp[i][j]
表示从下标为 [0-i]
的物品里任意取,放进容量为 j
的背包,价值总和最大是多少。
2、递推公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
- 不放物品
i
:由dp[i - 1][j]
推出,即背包容量为j
,里面不放物品i
的最大价值,此时dp[i][j]
就是dp[i - 1][j]。
(其实就是当物品i的重量大于背包j
的重量时,物品i
无法放进背包中,所以被背包内的价值依然和前面相同。) - 放物品
i
:由dp[i - 1][j - weight[i]]
推出,dp[i - 1][j - weight[i]]
为背包容量为j - weight[i]
的时候不放物品i
的最大价值,那么dp[i - 1][j - weight[i]] + value[i]
(物品i
的价值),就是背包放物品i
得到的最大价值
3、初始化:
-
如果背包容量
j
为0
的话,即dp[i][0]
,无论是选取哪些物品,背包价值总和一定为0
。 -
从状态转移方程
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
可以看出i
是由i-1
推导出来,那么i
为0
的时候就一定要初始化。dp[0][j]
,即:i
为0
,存放编号0
的物品的时候,各个容量的背包所能存放的最大价值。- 当
j < weight[0]
的时候,dp[0][j]
应该是0
,因为背包容量比编号0的物品重量还小。 - 当
j >= weight[0]
时,dp[0][j]
应该是value[0]
,因为背包容量放足够放编号0
物品。
- 当
4、遍历顺序:根据递推公式可以发现 dp[i][j]
所需要的数据就是左上角( dp[i - 1][?]
),所以先遍历背包重量还是先遍历物品都不会不影响 dp[i][j]
公式的推导。两种遍历方式代码如下:
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}//先遍历物品,然后遍历背包重量
// weight数组的大小 就是物品个数
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
for(int i = 1; i < weight.size(); i++) { // 遍历物品
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}//先遍历背包,再遍历物品
5、举例推导 dp
数组
void test_2_wei_bag_problem1() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagweight = 4;
// 二维数组
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
// 初始化
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
} //因为定义二维数组时已经全部初始化为0,所以第一列需要为0,及第一行背包容量无法装下物品1所以容量为0的这部分初始化由于本身就是0了所以可以省略。
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
cout << dp[weight.size() - 1][bagweight] << endl;
}
int main() {
test_2_wei_bag_problem1();
}
滚动数组(二维dp降为一维dp的优化)
对于背包问题其实状态都是可以压缩的。在使用二维数组的时候,如果递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
其实可以发现如果把dp[i - 1]
那一层拷贝到dp[i]
上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);
与其把dp[i - 1]
这一层拷贝到dp[i]
上,不如只用一个一维数组了,只用dp[j]
(一维数组,也可以理解是一个滚动数组)。这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。
为便于理解,先规定背包最大重量为4,物品重量和价值如下,问背包能背的物品最大价值是多少?
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
1、dp[j]
表示:容量为j
的背包,所背的物品价值可以最大为dp[j]
2、dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
3、如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。这样才能在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
4、遍历顺序(和二维dp有很大不同!!):
-
for(int i = 0; i < weight.size(); i++) { // 遍历物品 for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量 dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); } }
-
二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。
倒序遍历是为了保证物品i只被放入一次!但如果一旦正序遍历了,那么物品0就会被重复加入多次!
其实该遍历本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。也就是倒序遍历的原因所在。
举一个例子:
物品0的重量
weight[0] = 1
,价值value[0] = 15
正序遍历:
-
dp[1] = dp[1 - weight[0]] + value[0] = 15
-
dp[2] = dp[2 - weight[0]] + value[0] = 30
此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。
为什么倒序遍历,就可以保证物品只放入一次呢?
-
倒序就是先算dp[2]
-
dp[2] = dp[2 - weight[0]] + value[0] = 15
(dp数组已经都初始化为0) -
dp[1] = dp[1 - weight[0]] + value[0] = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
为什么二维dp数组历的时候不用倒序呢?
- 对于二维dp,
dp[i][j]
都是通过上一层即dp[i - 1][j]
计算而来,本层的dp[i][j]
并不会被覆盖!
两个嵌套for循环的顺序,那可不可以先遍历背包容量嵌套遍历物品呢?
不可以!因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个
dp[j]
就只会放入一个物品,即:最终背包里只放入了一个物品。 -
5、举例推导dp
数组
void test_1_wei_bag_problem() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 初始化
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_1_wei_bag_problem();
}
一维dp 的01背包,要比二维简洁的多! 初始化 和 遍历顺序相对简单了,空间复杂度还降了一个数量级!
416. 分割等和子集
集合划分问题:回溯法(超时)
class Solution {
public:
bool eSum(vector<int>& nums, int i, int remain) {
if (remain == 0) return true;
if (remain < 0 || i == nums.size()) return false;
return eSum(nums, i+1, remain-nums[i]) || eSum(nums, i+1, remain);
//分到子集1 或者 分到子集2
}
bool canPartition(vector<int>& nums) {
int sum = 0;
for(int i = 0; i < nums.size(); i++){
sum += nums[i];
}
if (sum % 2 == 1)
return false;
return eSum(nums, 0, sum / 2);
}
};
//超出时间限制
类似题目:473. 火柴拼正方形
class Solution {
public:
bool makesquare(vector<int>& matchsticks) {
int totalLength = accumulate(matchsticks.begin(),matchsticks.end(),0); //求和
if( totalLength % 4 != 0 ) return false;
int edgeLength = totalLength /4; //边长为总和除以4
vector<int> edges(4,0); //记录4边的长度
sort(matchsticks.begin(),matchsticks.end(),greater<int>());
//排序,先放长的,提高效率
return dfs(0,edges,matchsticks,edgeLength);
}
bool dfs(int index,vector<int> &edges,vector<int>& matchsticks,int edgeLength)
{
if( index == matchsticks.size() ) return true; //边界条件,所有火柴放完了
for( int i = 0;i < 4; ++ i ) //做选择
{
if( edges[i] + matchsticks[index] > edgeLength )
continue; //第 i 条边不能放
if( i > 0 && edges[i] == edges[i-1] )
continue;
//第 i 条边长度和 i-1 长度相等,第 i-1 条边不可以放则第 i 条边也不可以放
edges[i] += matchsticks[index]; //放在第 i 条边
if( dfs(index+1,edges,matchsticks,edgeLength) )
return true; // 第 i 条边可以放
edges[i] -= matchsticks[index]; // 回溯
}
return false;
}
};
类似题目:698. 划分为k个相等的子集
用集合
n
u
m
s
nums
nums中所有元素之和
s
u
m
sum
sum除以
k
k
k得到每个子集中的元素和:target = sum/k
。那么这道题目就变为了:将集合
n
u
m
s
nums
nums划分为
k
k
k个子集,使每个子集中的的元素和为
t
a
r
g
e
t
target
target.
遍历所有数字,每个数字需要遍历k个子集,并选择放入某个子集中。如果某个数字放入子集会导致子集元素之和大于 t a r g e t target target,那么这个数字就放入下一个子集,直到可以放入为止。如果某个数字不能放入任何一个子集,那么就返回 f a l s e false false。
class Solution {
public:
bool canPartitionKSubsets(vector<int>& nums, int k) {
if(k > nums.size()) return false;
int sum = accumulate(nums.begin(), nums.end(), 0);// 求和
if(sum % k != 0) return false;
sort(nums.rbegin(), nums.rend());// 排序
bucket.resize(k); // 记录每个子集中数字之和
int target = sum/k; // 每个桶中的数字之和应该为target
return backtrack(nums, 0, target); // index = 0, 表示从0号元素开始遍历
}
private:
vector<int> bucket; // 记录每个子集中数字之和
bool backtrack(vector<int> &nums, int index, int target){
if(index == nums.size()){ // 如果所有数字遍历完了 也就意味着全部放入桶中了
return true;
}
for(int i = 0; i < bucket.size(); i++){ // 注意:i 表示第i个子集,index 表示第index个数字
// 如果这个数字放入子集i中使子集i中元素和超出target了
if(bucket[i] + nums[index] > target){
continue;
}
if(i > 0 && bucket[i] == bucket[i-1]){
continue;
} // 如果 当前子集的元素和 与 前一个子集的元素和 是一样的,那前一个放不了 这个也放不了 跳过
bucket[i] += nums[index]; // 将数字放入子集i中
if(backtrack(nums, index + 1, target)){ // 递归穷举下一个数字的情况
return true;
}
// 撤销选择 回溯
bucket[i] -= nums[index];
}
// 如果 nums[index] 放入哪个子集都不行
return false;
}
};
0-1背包解法
确定了如下四点,可以将0-1背包问题套到本题上来。
- 背包的体积为
sum / 2
- 背包要放入的商品(集合里的元素)重量为元素的数值,价值也为元素的数值
- 背包如果正好装满,说明找到了总和为
sum / 2
的子集。 - 背包中每一个元素是不可重复放入。
过程:
1、dp[j]
表示 背包总容量是j
,最大可以凑成j
的子集总和为dp[j]
;
2、本题相当于背包里放入数值,那么物品i的重量是nums[i]
,其价值也是nums[i]
。所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
3、首先dp[0]
一定是0
。如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。
4、如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
// dp[i]中的i表示背包内总和
// 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
// 总和不会大于20000,背包最大只需要其中一半,所以10001大小
vector<int> dp(10001, 0);
for (int i = 0; i < nums.size(); i++) {
sum += nums[i];
}
if (sum % 2 == 1) return false;
int target = sum / 2;
// 开始 01背包
for(int i = 0; i < nums.size(); i++) {
for(int j = target; j >= nums[i]; j--) {
// 每一个元素一定是不可重复放入,所以从大到小遍历
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
// 集合中的元素正好可以凑成总和target
if (dp[target] == target) return true;
return false;
}
};
1049. 最后一块石头的重量 II
核心:尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小。
本题物品重量为 s t o r e [ i ] store[i] store[i],物品价值为 s t o r e [ i ] store[i] store[i]。对应0-1背包里物品重量 w e i g h t [ i ] weight[i] weight[i]和物品价值 v a l u e [ i ] value[i] value[i]。
1、dp[j]
表示容量(这里说容量更形象,其实就是重量)为j
的背包,最多可以背dp[j]
这么重的石头。
2、dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
3、提示中给出1 <= stones.length <= 30,1 <= stones[i] <= 1000
,所以最大重量就是30 * 1000
。而要求的target
其实是最大重量的一半,所以dp数组开到15000
大小就可以了。也可以把石头遍历一遍,计算出石头总重量 然后除2
,得到dp数组的大小。因为重量都不会是负数,所以dp[j]都初始化为0
就可以了。
4、使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历。
最后,dp[target]
里是容量为target的背包所能背的最大重量
。那么分成两堆石头,一堆石头的总重量是dp[target]
,另一堆就是sum - dp[target]
。计算target时target = sum / 2 是向下取整
,所以sum - dp[target] ≥ dp[target]
。那么相撞之后剩下的最小石头重量就是 (sum - dp[target]) - dp[target]
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int sum = accumulate(stones.begin(), stones.end(), 0);
int target = sum / 2;
vector<int> dp(target + 1, 0);
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];
}
};
//时间复杂度:O(m × n) , m是石头总重量(准确的说是总重量的一半),n为石头块数;空间复杂度:O(m)
494. 目标和
本题要如何使表达式结果为target,既然为target,那么就一定有 left组合 - right组合 = target
。
left+right=sum
,sum是固定的。所以 left-(sum-left) = target->left = (target+sum)/2
。
target是固定的,sum是固定的,left就可以求出来。此时问题就是在集合nums中找出和为left的组合。
回溯算法
类似于 39. 组合总和 ,代码可套用。
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, int target, int sum, int startIndex) {
if (sum == target) {
result.push_back(path);
//所有的方案组合都要记下来,所以不能直接return;
}
// 如果 sum + nums[i] > target 就终止遍历
for (int i = startIndex; i < nums.size() && sum + nums[i] <= target; i++) {
sum += nums[i];
path.push_back(nums[i]);
backtracking(nums, target, sum, i+1);
sum -= nums[i];
path.pop_back();
}
}
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = accumulate(nums.begin(),nums.end(),0);
if (target > sum) return 0; // 此时没有方案
if ((target + sum) % 2) return 0;
int left = (target + sum)/2;
result.clear();
path.clear();
sort(nums.begin(), nums.end()); // 需要排序
backtracking(nums, left, 0, 0);
return result.size();
}
};
动态规划
假设加法总和为 x
,那么减法总和是 sum - x
。所以要求的是 x - (sum-x) = S
, x = (S+sum) / 2
,此时问题就转化为,装满容量为x背包,有几种方法?
看到(S + sum) / 2 应该担心计算的过程中向下取整有没有影响。例如sum 是5,S是2就是无解的,所以:
if ((S + sum) % 2 == 1) return 0; // 此时没有方案
同时如果 S的绝对值已经大于sum,那么也是没有方案的。
if (abs(S) > sum) return 0; // 此时没有方案
1、 dp[j]
表示:填满 j(包括j)
这么大容积的包,有 dp[j]
种方法
2、不考虑 nums[i]
的情况下,填满容量为 j - nums[i]
的背包,有 dp[j - nums[i]]
种方法。那么只要有 nums[i]
的话,凑成 dp[j]
就有dp[j - nums[i]]
种方法。
例如:dp[j],j 为5,
- 已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 dp[5]
- 已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 dp[5]
- 已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 dp[5]
- 已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 dp[5]
- 已经有一个5(nums[i]) 的话,有 dp[0]中方法 凑成 dp[5]
那么凑整dp[5]有多少方法呢,也就是把 所有的
dp[j - nums[i]]
累加起来。
所以求组合类问题的公式,都是类似这种: dp[j] += dp[j - nums[i]]
3、从递归公式可以看出,在初始化的时候dp[0]
一定要初始化为1
,因为dp[0]
是在公式中一切递推结果的起源,如果dp[0]
是0
的话,递归结果将都是0
。dp[0] = 1
,理论上也很好解释,装满容量为0
的背包,有1
种方法,就是装0
件物品。dp[j]
其他下标对应的数值应该初始化为0
,从递归公式也可以看出,dp[j]
要保证是0
的初始值,才能正确的由dp[j - nums[i]]
推导出来。
4、一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序。
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = accumulate(nums.begin(), nums.end(), 0);
int bagsize = (target + sum) / 2;
if(abs(target) > sum) return 0;
if((target + sum) % 2 == 1) return 0;
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];
}
};
记住:在求装满背包有几种方法的情况下,递推公式一般为: dp[j] += dp[j - nums[i]];
474. 一和零
本题中strs 数组里的元素就是物品,每个物品都是一个!而m 和 n相当于是两个维度的背包。
1、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);
而0-1背包的递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i])
,字符串本身的个数相当于物品的价值(value[i])。
也印证了这就是一个典型的0-1背包!只不过物品的重量有了两个维度而已!
3、因为物品价值不会是负数,初始为0即可。
4、0-1背包中外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历!那么本题也是,物品就是strs里的字符串,背包容量就是题目描述中的m和n。那遍历背包容量的两层for循环先后循序有没有什么讲究?没讲究,都是物品重量的一个维度,先遍历那个都行!
class Solution {
public:
int findMaxForm(vector<string>& strs, int m, int n) {
vector<vector<int>> dp(m+1,vector<int>(n+1,0));
for(string str:strs){
int oneNum = 0, zeroNum = 0;
for(char c:str){
if(c == '0') zeroNum++;
else if(c == '1') 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];
}
};
完全背包理论基础
有 N N N件物品和一个最多能背重量为 W W W的背包。第 i i i件物品的重量是 w e i g h t [ i ] weight[i] weight[i],得到的价值是 v a l u e [ i ] value[i] value[i]。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
分析
0-1背包和完全背包唯一不同就是体现在遍历顺序上。
为便于理解,先规定背包最大重量为4。物品重量及价值如下表,每件商品都有无限个(意味着可以放入背包多次),问背包能背的物品最大价值是多少?
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
回顾一下0-1背包的核心代码:
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
前文已经介绍了0-1背包内嵌的循环是从大到小遍历,这是为了保证每个物品仅被添加一次。而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:
// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = weight[i]; j <= bagWeight ; j++) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
// 先遍历背包,再遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
cout << endl;
}
0-1背包中 二维dp数组的两个for遍历的先后循序是可以颠倒的;
一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量;
在完全背包中,对于一维dp数组来说,两个for循环嵌套顺序可以颠倒! 因为 d p [ j ] dp[j] dp[j] 是根据 下标 j j j 之前所对应的 d p [ j ] dp[j] dp[j] 计算出来的。 只要保证下标 j j j 之前的 d p [ j ] dp[j] dp[j] 都是经过计算的就可以了。
void test_CompletePack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品 //两个for循环嵌套顺序可以颠倒
for(int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_CompletePack();
}
518. 零钱兑换 II
纯完全背包是能否凑成总金额,而本题是要求凑成总金额的个数!
同时需要注意题目描述中是凑成总金额的硬币组合数,组合不强调元素之间的顺序,排列强调元素之间的顺序。
例如: 5 = 2 + 2 + 1 ; 5 = 2 + 1 + 2
这是一种组合,都是 2 2 1。也是两种排列。
1、dp[j]:凑成总金额j的货币组合数为dp[j]
2、dp[j]
(考虑coins[i]
的组合总和) 就是所有的dp[j - coins[i]]
(不考虑coins[i]
)相加。所以递推公式:dp[j] += dp[j - coins[i]];
,和 494.目标和 题目中是一个道理。求装满背包有几种方法,一般公式都是:dp[j] += dp[j - nums[i]];
3、dp[0] = 1
(凑成总金额0的货币组合数为1),下标非
0
0
0 的dp[j]
初始化为
0
0
0
4、完全背包的两个for循环的先后顺序都是可以的。因为纯完全背包求得是能否凑成总和,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!所以纯完全背包是能凑成总和就行,不用管怎么凑的。
而本题要求的是凑成总和的组合数(方案个数),元素之间要求没有顺序。只能采用外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),分析如下:
假设:coins[0] = 1,coins[1] = 5。
外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。所以这种遍历顺序中 d p [ j ] dp[j] dp[j]里计算的是组合数!
外层for循环遍历背包(金钱总额),内层for遍历物品(钱币),背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。此时 d p [ j ] dp[j] dp[j]里算出来的就是排列数!(如果要求的是排列就必须要用这个顺序的for嵌套循环,但本题是组合数,所以不能用)
总结:
-
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
-
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount+1,0);
dp[0] = 1;
for(int i = 0; i < coins.size();i++){
for(int j = coins[i]; j <= amount; j++){
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
};
377. 组合总和 Ⅳ
题干中:请注意,顺序不同的序列被视作不同的组合。因此本题实际就是求排列问题。
回溯篇中 39.组合总和、40.组合总和II 和本题 377. 组合总和 Ⅳ 看起来很像,但实际上,本题是求排列总和的个数,并不是把所有的排列都列出来。如果要把排列都列出来的话,只能使用回溯算法爆搜,也就是题目39和题目要40的解法。
1、dp[i]: 凑成目标正整数为i的排列个数为dp[i]
2、dp[i] += dp[i - nums[j]];
3、题干中说明了给定目标值是正整数, 所以
d
p
[
0
]
dp[0]
dp[0] 是没有意义的,但
d
p
[
0
]
dp[0]
dp[0] 是递推公式推导的根基,所以需要进行初始化dp[0] = 1
,非0下标的dp[i] = 0
。
4、由于是排列,target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历。排列和组合在for嵌套循环遍历顺序上的不同和分析可以看上面518题内的分析。
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<int> dp(target + 1);
dp[0] = 1;
for(int i = 0; i <= target; i++){
for(int j = 0; j < nums.size(); j++){
if(i - nums[j] >= 0 && dp[i] < INT_MAX - dp[i - nums[j]]){
//测试用例有两个数相加超过int的数据,所以要进行判断dp[i] < INT_MAX - dp[i - nums[j]]
dp[i] += dp[i - nums[j]];
}
}
}
return dp[target];
}
};
70. 爬楼梯
题目升级
一步一个台阶,两个台阶,三个台阶,…,m个台阶。有多少种不同的方法可以爬到楼顶呢?
1阶,2阶,… m阶就是物品,楼顶就是背包。每一阶可以重复使用,例如跳了1阶,还可以继续跳1阶。问跳到楼顶有几种方法其实就是问装满背包有几种方法。这就是一个完全背包问题了!
1、dp[i]:爬到有i个台阶的楼顶,有dp[i]种方法。
2、dp[i] += dp[i - j]
3、dp[0] = 1
;下标非0的dp[i] = 0
4、完全背包求排列问题,所以需将 t a r g e t target target放在外循环,将 n u m s nums nums放在内循环,内循环需要从前向后遍历。
class Solution {
public:
int climbStairs(int n) {
vector<int> dp(n + 1, 0);
dp[0] = 1;
for (int i = 1; i <= n; i++) { // 遍历背包
for (int j = 1; j <= m; j++) { // 遍历物品
if (i - j >= 0) dp[i] += dp[i - j];
}
}
return dp[n];
}
}; //代码中m表示最多可以爬m个台阶,代码中把m改成2就可以AC题 70.爬楼梯 了。
322. 零钱兑换
1、dp[j]:凑足总额为j所需钱币的最少个数为dp[j]
2、dp[j] = min(dp[j], dp[j - coins[i]] + 1);
3、dp[0] = 0;
下标非
0
0
0 的
d
p
[
j
]
dp[j]
dp[j] 必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])
比较的过程中被初始值覆盖,dp[j] = INT_MAX
。
4、钱币有顺序和没有顺序都不影响钱币的最小个数,所以本题并不强调集合是组合还是排列,也就无所谓内外层的遍历顺序,随意就行。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for(int i = 0;i < coins.size(); i++){
for(int j = coins[i]; j <= amount; j++){
if(dp[j - coins[i]] != INT_MAX){
//如果dp[j-coins[i]] == INT_MAX则跳过。
dp[j] = min(dp[j], dp[j - coins[i]] + 1);
}
}
}
if(dp[amount] == INT_MAX) return -1;
return dp[amount];
}
};
279. 完全平方数
分析:完全平方数就是物品(可以无限件使用),凑正整数n就是背包,问凑满这个背包最少有多少物品?
1、dp[j]:和为j的完全平方数的最少数量为dp[j]
2、dp[j] = min(dp[j - i * i] + 1, dp[j]);
3、dp[0] = 0; dp[j] = INT_MAX
4、内外层循环随意就行,因为求的是最少数量,和排列组合没关系
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
for(int i = 0; i <= n;i++){
for(int j = 1; j * j <= i;j++){
dp[i] = min(dp[i - j * j] + 1, dp[i]);
}
}
return dp[n];
}
};
139. 单词拆分
131. 分割回文串:是枚举分割后的所有子串,判断是否回文。
本题是枚举分割所有字符串,判断是否在字典里出现过。
回溯解法(超时)
class Solution {
public:
bool backtracking(const string& s, const unordered_set<string>& wordSet,int startIndex){
if(startIndex >= s.size()){
return true;
}
for(int i = startIndex; i < s.size();i++){
string word = s.substr(startIndex, i - startIndex + 1);
if(wordSet.find(word) != wordSet.end() && backtracking(s, wordSet, i + 1)){
return true;
}
}
return false;
}
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
// wordDict = ["leet", "code"]
// wordSet: {[0] = "code", [1] = "leet"}
return backtracking(s, wordSet, 0);
}
};
记忆化递归(看不懂的玩意)
递归的过程中有很多重复计算,可以使用数组保存一下递归过程中计算的结果。
使用 m e m o r y memory memory数组保存每次计算的以 s t a r t I n d e x startIndex startIndex起始的计算结果,如果 m e m o r y [ s t a r t I n d e x ] memory[startIndex] memory[startIndex]里已经被赋值了,直接用 m e m o r y [ s t a r t I n d e x ] memory[startIndex] memory[startIndex]的结果。
举例:
"aab",["a","aa"]
程序运行顺序是
word:a -> a -> b(memeoy[2] = false) -> ab(memeoy[1] = false) ->aab(memeoy[0] = false)
class Solution {
public:
bool backtracking (const string& s, const unordered_set<string>& wordSet, vector<bool>& memory, int startIndex) {
if (startIndex >= s.size()) {
return true;
}
// 如果memory[startIndex]不是初始值了,直接使用memory[startIndex]的结果
if (!memory[startIndex]) {
return memory[startIndex];
}
for (int i = startIndex; i < s.size(); i++) {
string word = s.substr(startIndex, i - startIndex + 1);
if (wordSet.find(word) != wordSet.end() && backtracking(s, wordSet, memory, i + 1)) {
return true;
}
}
memory[startIndex] = false; // 记录以startIndex开始的子串是不可以被拆分的
return false;
}
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
vector<bool> memory(s.size(), 1); // -1 表示初始化状态
return backtracking(s, wordSet, memory, 0);
}
}; //可以AC啦
背包解法
1、dp[i]
: 字符串长度为i
的话,dp[i]
为true
,表示可以拆分为一个或多个在字典中出现的单词。
2、如果确定dp[j] = true
,且[j, i]
这个区间的子串出现在字典里,那么dp[i] = true
。(j < i
)。
所以递推公式是if([j, i] 这个区间的子串出现在字典里 && dp[j] = true)
那么 dp[i] = true
。、
3、dp[i]
的状态依靠 dp[j]
是否为true
,那么dp[0]
就是递归的根基,dp[0] = true
,否则递归下去后面都都是false
了。下标非0的dp[i] = false
,只要没有被覆盖说明都是不可拆分。
4、拆分为一个或多个在字典中出现的单词,说明可以重复,所以这是完全背包。本题最终要求的是是否出现过,所以对出现单词集合里的元素是组合还是排列并不在意!那么 使用求排列的方式,还是求组合的遍历方式都可以
。但本题因为是 求子串,因为分割子串的特殊性,最好是遍历背包放在外循环,将遍历物品放在内循环比较合适
。如果要是外层for循环遍历物品,内层for遍历背包,就需要把所有的子串都预先放在一个容器里。
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
vector<bool> dp(s.size() + 1, false);
dp[0] = true;
for (int i = 1; i <= s.size(); i++) { // 遍历背包
for (int j = 0; j < i; j++) { // 遍历物品
string word = s.substr(j, i - j); // substr(起始位置,截取的个数)
if (wordSet.find(word) != wordSet.end() && dp[j]) {
dp[i] = true;
}
}
}
return dp[s.size()];
}
};
- 时间复杂度: O ( n 3 ) O(n^3) O(n3),因为 s u b s t r substr substr返回子串的副本是 O ( n ) O(n) O(n)的复杂度(n = substring的长度)
- 空间复杂度: O ( n ) O(n) O(n)
多重背包理论基础
有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。
多重背包和01背包是非常像的, 为什么和01背包像呢?每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。
例如:
背包最大重量为10。
重量 | 价值 | 数量 | |
---|---|---|---|
物品0 | 1 | 15 | 2 |
物品1 | 3 | 20 | 3 |
物品2 | 4 | 30 | 2 |
和如下情况有区别么?
重量 | 价值 | 数量 | |
---|---|---|---|
物品0 | 1 | 15 | 1 |
物品0 | 1 | 15 | 1 |
物品1 | 3 | 20 | 1 |
物品1 | 3 | 20 | 1 |
物品1 | 3 | 20 | 1 |
物品2 | 4 | 30 | 1 |
物品2 | 4 | 30 | 1 |
毫无区别,这就转成了一个01背包问题了,且每个物品只用一次。
void test_multi_pack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
vector<int> nums = {2, 3, 2};
int bagWeight = 10;
for (int i = 0; i < nums.size(); i++) {
while (nums[i] > 1) { // nums[i]保留到1,把其他物品都展开
weight.push_back(weight[i]);
value.push_back(value[i]);
nums[i]--;
}
}
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
for (int j = 0; j <= bagWeight; j++) {
cout << dp[j] << " ";
}
cout << endl;
}
cout << dp[bagWeight] << endl;
}
int main() {
test_multi_pack();
}
//时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量
也有另一种实现方式,就是把每种商品遍历的个数放在01背包里面在遍历一遍。
void test_multi_pack() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
vector<int> nums = {2, 3, 2};
int bagWeight = 10;
vector<int> dp(bagWeight + 1, 0);
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
// 以上为01背包,然后加一个遍历个数
for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数
dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
}
}
// 打印一下dp数组
for (int j = 0; j <= bagWeight; j++) {
cout << dp[j] << " ";
}
cout << endl;
}
cout << dp[bagWeight] << endl;
}
int main() {
test_multi_pack();
}
//时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量
//从代码里可以看出是01背包里面在加一个for循环遍历一个每种商品的数量。 和01背包还是如出一辙的。
背包问题总结
步骤
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
常见递推公式
问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:
问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下:
问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ,对应题目如下:
问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); ,对应题目如下:
遍历顺序
0-1背包
- 二维dp数组01背包先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
- 一维dp数组01背包只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历。
完全背包
- 纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
- 如果求组合数就是外层for循环遍历物品,内层for遍历背包。
- 如果求排列数就是外层for遍历背包,内层for循环遍历物品。
相关题目如下:
如果求最小数,那么两层for循环的先后顺序就无所谓了,相关题目如下:
- 求最小数:[动态规划:322. 零钱兑换、动态规划:279.完全平方数