01背包概念
01背包问题:
有 n 件物品和一个最多能背重量为 w 的背包。第i件物品的重量是 weight[i],得到的价值是 value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
这是标准的背包问题,以至于很多同学看了这个自然就会想到背包,甚至都不知道暴力的解法应该怎么解了。
这样其实是没有从底向上去思考,而是习惯性想到了背包,那么暴力的解法应该是怎么样的呢?
每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是 O(2^n),这里的 n 表示物品数量。
所以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!
01背包的动规五部曲
1、定义
dp[i][j] 表示从下标为 [0-i] 的物品里任意取,放进容量为 j 的背包,价值总和最大是多少。
-
不放物品 i:背包容量为j,里面不放物品i的最大价值是 dp[i - 1][j] 。
-
放物品 i:背包空出物品i的容量后,背包容量为 j - weight[i],dp[i - 1][j - weight[i]] 为背包容量为 j - weight[i] 且不放物品i的最大价值,那么 dp[i - 1][j - weight[i]] + value[i](物品i的价值),就是背包放物品 i 得到的最大价值。
2、递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3、初始化:初始 dp[i][j] 中,i = 0 和 j = 0 的 2 种情况。
4、遍历方向:从递推公式可知, dp[i][j] 是从左上角遍历而来的。
01背包问题之滚动数组
由于 dp[i][j] 是由正上方和左上角推出的,我们可以把上一层 拷贝 到当前层,再在当前层直接进行计算覆盖原来的数。这样就可以把二维数组降维成一维数组了。
1、含义:在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
2、递推公式:一维dp数组,其实就是上一层 dp[i-1] 这一层 拷贝的 dp[i]来。
所以在 上面递推公式的基础上,去掉i这个维度就好。
递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了
3、初始化:
dp[0]=0,dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。
这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
4、遍历顺序:
倒序遍历是为了保证物品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]并不会被覆盖!
看着弹幕总结了一下,就是一维数组的结果是复制上一层进行计算得到的,如果是正序遍历,就是修改左上方数据的值,导致本层的计算结果不正确;倒序遍历可以保证左边的值没有被修改,先计算本层最右边的结果,再往左更新,才能得到正确的结果。
01背包、完全背包、多重背包的概念
如下,主要是每个物品数量的不同。
而完全背包又是也是01背包稍作变化而来,即:完全背包的物品数量是无限的。
例题1
小明是一位科学家,他需要参加一场重要的国际科学大会,以展示自己的最新研究成果。他需要带一些研究材料,但是他的行李箱空间有限。这些研究材料包括实验设备、文献资料和实验样本等等,它们各自占据不同的空间,并且具有不同的价值。
小明的行李空间为 N,问小明应该如何抉择,才能携带最大价值的研究材料,每种研究材料只能选择一次,并且只有选与不选两种选择,不能进行切割。
动规五部曲
1.确定dp数组以及下标的含义
dp[i][j] 的含义:在下标 [0~i] 的物品中任取,装进容量为 j 的背包,所获得的最大价值为 dp[i][j].
官方给的图:
2.确定dp数组的递推式
由1,当前dp[i][j]的状态是由上一个 dp[i-1][j] 推导来的,dp[i-1][j] 已经做好了决策,那么到dp[i][j]的时候,我们可以选择放当前下标为 i 的物品,也可以选择不放。
如放,dp[i][j]=dp[i][j-weight[j]]+value[i]
如不放,dp[i][j]=dp[i-1][j]
此时我们要求dp最大值,故 dp[i][j]=max(dp[i-1][j],dp[i][j-weight[j]]+value[i])
3.初始化dp数组
对于dp二维数组,我们通常将i=0,j=0的一行一列进行初始化
当i=0时,dp[0][j]表示从下标为0的物体中取物装进背包。这时若 j>= weight[0] ,则放入背包,反之dp [0][j]都为0
当j=0时,背包容量为0,此时dp[i][0]都为0,放不下任何物品。
4.确定遍历顺序
由递推式可知,有两个遍历的维度:物品与背包重量,先遍历哪个都行,因为dp[i][j]需要靠dp[i-1][j]推导,那么在这里先遍历物品i。
5.举例推导dp数组
做动态规划的题目,最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!
code
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];
}
// 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();
}
例题2:分割等和子集
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意:每个数组中的元素不会超过 100 数组的大小不会超过 200
动规五部曲
1.确定dp数组以及下标的含义
如题,若集合元素的和为sum,分割为两个子集,那么如果其中一个子集的集合元素和为sum/2,另一个子集元素的和自然而然也为sum/2,所以我们只需验证,集合中能否找出集合元素元素和为sum/2的序列,也就是一个容量为sum/2的背包是否能被填满 的0-1背包问题。
dp[j]的含义:容量为j的背包,所能装的物品的最大价值为dp[j]。这样,当dp[j] == sum/2时,就证明true。
2.确定dp数组的递推式
根据dp[j]的含义,dp[j]每次在 放当前物品 和 不放当前物品 中取最大值,而物品的重量和价值是同一个表达式,即nums[i]。故dp[j] = max(dp[j], dp[j - weight[i]] + values[i])
3.初始化dp数组
dp[0]=0,当 j=0 时,背包容量为 0,放不下任何物品。
如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。
4.确定遍历顺序
二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。因为如果正序遍历背包的话,背包从小到大的过程中可能会放入多次的i相同的物品,导致一个背包放入了多次的相同物品。
那么可以先遍历背包再遍历物品吗?不可以。如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。
倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖
5.举例推导dp数组
code
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for(int i = 0; i < nums.size(); i++)
sum += nums[i];
if(sum % 2 != 0)
return false;
//dp[j]:容量为j的背包能装下的最大值
vector<int> dp(10001,0);//顺便初始化了 取10001是因为所有元素的和上限为200*100,我们区一半,上限为10000
//递推公式
for(int i = 0; i < nums.size(); i++){
for(int j = sum/2; j >= nums[i]; j--){
dp[j] = max(dp[j], dp[j-nums[i]] + nums[i]);
}
}
if(dp[sum/2] == sum/2) return true;
return false;
}
};
例题3:最后一块石头的重量II
有一堆石头,用整数数组 stones
表示。其中 stones[i]
表示第 i
块石头的重量。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x
和 y
,且 x <= y
。那么粉碎的可能结果如下:
- 如果
x == y
,那么两块石头都会被完全粉碎; - 如果
x != y
,那么重量为x
的石头将会完全粉碎,而重量为y
的石头新重量为y-x
。
最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0
。
动规五部曲
1.确定dp数组以及下标的含义
dp[j]表示容量(这里说容量更形象,其实就是重量)为j的背包,最多可以背最大重量为dp[j]。
此题尽可能将容量为 sum/2 的背包装满,这是第一堆石头,再用 sum- sum/2,这是第二堆石头。
2.确定dp数组的递推式
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]); 物品的价值和体积是同一个数组
3.初始化dp数组
全为0即可,不影响 max 的作用。
4.确定遍历顺序
为了不干扰到右边的数组计算,我们不从左边开始计算,保留上一层的数据,所以我们先从右边开始进行处理。
5.举例推导dp数组
code
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int sum = 0;
for(int i = 0; i < stones.size(); i++){
sum += stones[i];
}
//sort(stones.begin(), stones.end()); 排序了应该也不行,每次碰撞都需要重新排序
//此题像是尽可能将容量为 sum/2 的背包装满, 再用总和 - sum/2
vector<int> dp(1501,0);//dp[j]表示容量为 j 的背包尽可能可以装多少石头
for(int i = 0; i < stones.size(); i++){
for(int j = sum/2; j >= stones[i]; j--){
dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - dp[sum/2] - dp[sum/2];//一堆石头的总重量是dp[sum/2],另一堆就是sum - dp[sum/2]。
}
};
例题4:目标和
给你一个非负整数数组 nums
和一个整数 target
。
向数组中的每个整数前添加 '+'
或 '-'
,然后串联起所有整数,可以构造一个 表达式 :
- 例如,
nums = [2, 1]
,可以在2
之前添加'+'
,在1
之前添加'-'
,然后串联起来得到表达式"+2-1"
。
返回可以通过上述方法构造的、运算结果等于 target
的不同 表达式 的数目。
动规五部曲
既然为target,那么就一定有 left组合 - right组合 = target。
left + right = sum,而sum是固定的。right = sum - left
left - (sum - left) = target 推导出 left = (target + sum)/2 。
target是固定的,sum是固定的,left就可以求出来。
此时问题就是在集合nums中找出和为left的组合
1.确定dp数组以及下标的含义&确定dp数组的递推式
dp[j] 表示将容量为 j 的背包装满一共有 dp[j] 种方法,举个例子,dp[5] 表示把容量为 5 的背包填满的方法。
如果还有一个 4,那么 dp[5] = dp[1];
如果还有一个 3,那么 dp[5] = dp[2];
如果还有一个 2,那么 dp[5] = dp[3];
如果还有一个 1,那么 dp[5] = dp[4];
.........
所以 dp[j] += dp[ j - numbers[i] ]
2.初始化dp数组
dp[0]=1
3.确定遍历顺序
老规矩,先正序遍历物品,在逆序遍历背包容量。
4.举例推导dp数组
code
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(abs(target) > sum) return 0;
if((sum + target) % 2 != 0) return 0;
vector<int> dp((sum + target)/2+1,0);
dp[0] = 1;//全都初始化为0时,别忘了dp[0] = 1
//开始递推公式
for(int i = 0; i < nums.size(); i++){
for(int j = (sum + target)/2; j >= nums[i]; j--){
dp[j] += dp[j - nums[i]];
}
}
return dp[(sum + target)/2];
}
};
例题5:一和零
给你一个二进制字符串数组 strs
和两个整数 m
和 n
。
请你找出并返回 strs
的最大子集的长度,该子集中 最多 有 m
个 0
和 n
个 1
。
如果 x
的所有元素也是 y
的元素,集合 x
是集合 y
的 子集 。
示例 1:
输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3 输出:4 解释:最多有 5 个 0 和 3 个 1 的最大子集是 {"10","0001","1","0"} ,因此答案是 4 。 其他满足题意但较小的子集包括 {"0001","1"} 和 {"10","1","0"} 。{"111001"} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。
示例 2:
输入:strs = ["10", "0", "1"], m = 1, n = 1 输出:2 解释:最大的子集是 {"0", "1"} ,所以答案是 2 。
动规五部曲
1.确定dp数组以及下标的含义
dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]。
2.确定dp数组的递推式
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])
3.初始化dp数组
因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖。
4.确定遍历顺序
本题,物品就是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);
}
}
}
5.举例推导dp数组
code
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 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];
}
};