一、二维背包
文章讲解/视频讲解:https://programmercarl.com/%E8%83%8C%E5%8C%85%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%8001%E8%83%8C%E5%8C%85-1.html
状态:已解决
1.背包问题介绍
背包问题实则是一类问题的集合,有好多不同小类型,但本质还是一样的,基本组成都是:一个容量固定的背包、若干件物品(拥有体积、价值、数量三种属性),根据物品的数量的限制,可以将背包问题划分为以下几类:
背包问题是动态规划的经典问题,也是难点所在,一般来说,学会01背包和完全背包就够做题了,再往上就是竞赛级别的了。而完全背包实则也是在01背包的基础上进行的变化升级,因此,01背包是重中之重,我们必须掌握。
2.01背包问题
标准题意:有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
这种取或不取的问题,我们自然容易想到用回溯法遍历所有情况暴力去做,但这种方法时间复杂度太高(指数级),在有更优化的解法时不建议使用。
这里说的更优化的解法实则就是动态规划。具体怎么做我们利用动规五部曲来分析。
(1)确定dp数组以及下标含义:
我们知道动规中dp数组是用于表示状态的一个数组,那我们这道题有哪些状态呢?首先,我们需要一个状态说明此刻该抉择选取哪个物品了,另外我们需要一个状态来说明此时背包的容量。那么,我们就可以定义一个二维数组dp。dp[i][j] 表示从下标为[0-i]的物品里任意取(0 <= k <=i,可能取了物品k也可能没取物品k),放到容量为j的背包,价值总和最大是多少。画出dp数组如图:
(2)确定递推公式:
我们知道了dp[i][j]的含义:抉择第i个物品时,背包重量为j,那么结合逻辑,我们就可以得出状态dp[i][j]的来源:
- 不放物品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的最大价值,那么在容量为 j 时可以选择多放入物品 i ,dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值。
因此,递推公式为:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
(3)dp数组初始化:
首先从dp[i][j]的定义出发,如果背包容量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物品。代码初始化如下:
其余下标由于dp[i][j]都是由左上角或者上面推导过来的,因此初始化就无所谓,反正都会被覆盖,为了方便,统一初始化为0。
(4)遍历顺序:
我们知道dp是一个二维数组,也就是有两个维度:物品和背包容量,那么我们在进行循环的时候哪个维度放外层哪个维度放内层呢?我们根据递推公式可以知道一个元素是从左上和上面推出来的,也就是说,我们将物品作为外层,背包容量作为内层的话,那么从小到大遍历dp数组就是一排一排填满的,如果将背包容量作为外层,物品作为内层的话,那么dp数组就是一列一列的填满的。二者都可以顺利计算完整个dp数组(只要每排或每列是从小到大遍历的,那么我们做下一排或者下一列时,无论如何都能得到上面一格或者左上一格的元素)。
(5)举例推导dp数组:
按现在的逻辑得到dp数组如下:
最终结果就是dp[2][4]。
3.具体实现
卡码网46题就是一个经典的01背包问题,我们根据上述分析可以给出代码:
#include<iostream>
#include<vector>
using namespace std;
int main(void){
int m,n;
cin>>m>>n;
vector<int> weight(m,0);
vector<int> value(m,0);
vector<vector<int>> dp(m,vector<int>(n+1,0));
for(int i=0;i<m;i++){
cin>>weight[i];
}
for(int i=0;i<m;i++){
cin>>value[i];
}
for(int i=weight[0];i<=n;i++){
dp[0][i] = value[0];
}
for(int i=1;i<m;i++){
for(int j=0;j<=n;j++){
if(j-weight[i]>=0)
dp[i][j] = max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i]);
else
dp[i][j] = dp[i-1][j];
}
}
// for(int i=1;i<m;i++){
// for(int j=0;j<=n;j++){
// cout<<dp[i][j]<<" ";
// }
// cout<<endl;
// }
cout<<dp[m-1][n];
}
二、一维背包
文章讲解/视频讲解:https://programmercarl.com/%E8%83%8C%E5%8C%85%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%8001%E8%83%8C%E5%8C%85-2.html
状态:已解决
1.滚动数组
一维背包实则还是01背包问题,只是我们将状态数组从二维降到了一维。依据是什么呢?我们根据二维背包的分析可以得知二维dp数组的每一层的每一个格子(除初始化的最左侧和最上方)实则都是从上一层的左上角格子和正上方格子推导出来的,也就是说,每一层的值只跟上一层的值有关,跟这层的值没有关系。也就是说,我们只需要保存上一层的状态,然后推导出这一层的状态后,舍弃上一层状态,更新为新推导出的这一层状态,由此再去推导下一层。即,整个二维数组被压缩成一行(层),然后不断地向下滚动着更新,故称之为滚动数组。由于数组降维,从两个状态变为了一个状态,故这种优化方法也被称为状态压缩。
(1)确定dp数组及下标含义:
在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
(2)一维dp数组的递推公式:
我们知道二维dp数组的递推公式是:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]),i是物品标号也是层数标号,当层数被压缩为一层时,我们知道当前一维数组实际就是推导完的上层状态,那么原先上层的dp[i - 1][j], dp[i - 1][j - weight[i]],实际就是现在的dp[j]、dp[j-weight[i]]。故递推公式为:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
含义:dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j])
此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j],即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i。
(3)dp数组的初始化:
dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该取0,因为容量为0的书包装不下任何东西,故所背的物品的最大价值就是0。那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
(4)一维dp数组的遍历顺序:
假如现在外层是依次遍历物品。那么,内层的背包容量也还是从小到大来遍历吗?漏!!!我们知道,内层循环现在是背包容量,由于现在的dp[ j ]是由dp[j]和dp[j-weight[i]]决定的,那么我们推导dp[j]时,本身需要的是上一层在 j 和 j-weight[i]时的值,而如果每次对于物品i,都从小到达遍历容量的话,那么在算这层的 j容量 之前时,它前面的容量值((比如j - weight[i]))就已经被更新成这层的推算值,而不是上层的推算值了。因此,我们只会反复根据第一层物品的价值和重量进行推导,而不是这层物品的相关值。
例:物品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,被放入了两次,所以不能正序遍历。
为什么倒序遍历,就可以保证物品只放入一次呢?因为倒序从当层的末尾容量开始推导,那么只动了j容量后面的值,j前面的值仍然为上层的推算值,由此推得的dp[j] 也就是由上层推算值推导出来的正确的dp[j]。倒序就是先算dp[2]dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)dp[1] = dp[1 - weight[0]] + value[0] = 15。所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?不可以!因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。
倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。
(5)举例推导dp数组:
2.代码实现
还是以卡码网46题为例。
#include<iostream>
#include<vector>
using namespace std;
int main(void){
int m,n;
cin>>m>>n;
vector<int> weight(m,0);
vector<int> value(m,0);
vector<int> dp(n+1,0);
for(int i=0;i<m;i++){
cin>>weight[i];
}
for(int i=0;i<m;i++){
cin>>value[i];
}
for(int i=0;i<m;i++){
for(int j=n;j>=0;j--){
if(j-weight[i]>=0)
dp[j] = max(dp[j],dp[j-weight[i]]+value[i]);
else
dp[j] = dp[j];
}
}
// for(int i=1;i<m;i++){
// for(int j=0;j<=n;j++){
// cout<<dp[i][j]<<" ";
// }
// cout<<endl;
// }
cout<<dp[n];
}
三、LeetCode 416.分割等和子集
题目链接/文章讲解/视频讲解:https://programmercarl.com/0416.%E5%88%86%E5%89%B2%E7%AD%89%E5%92%8C%E5%AD%90%E9%9B%86.html
状态:已解决
1.思路
这道题不难,关键在于如何将问题转换为01背包问题。题眼:将这个数组分割成两个子集,使得两个子集的元素和相等。我们知道,给定了数组,那么数组的和sum就是确定的,也就是说,我们现在的目标就是找一个集合使得集合元素之和等于sum/2,那么剩余元素构成的集合的和也就为sum/2了。现假设数组元素个数为m,那么套用到背包模型中,就是将m个物品(重量、价值均为nums[i]),装到容量为sum/2的背包中去,看是否能够装满背包。
那么怎么判断是否能够装满背包呢?看背包最多能装的总价值是否刚好等于sum/2,即dp[target] == target。理由:
(1)dp[target] > target 的情况不可能出现,因为现在一个物品的价值等于这个物品的重量,装满一个背包最多价值等于重量,不可能出现最终价值超过该背包容量的情况。
(2)dp[target] < target:说明尽量装背包装不到tagret,也就是装不满背包,故不可能凑到某个集合的元素之和等于sum/2。
2.代码实现
直接在上面的代码上做修改就行:m = num.size(),n = sum/2,weight[i] = nums[i],value[i] = nums[i]。
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
for(int i=0;i<nums.size();i++){
sum += nums[i];
}
int m = nums.size(),n = sum/2;
if(sum % 2 != 0) return false;//此处可剪枝
vector<int> dp(n+1,0);
for(int i=0;i<m;i++){
for(int j=n;j>=0;j--){
if(j-nums[i]>=0)
dp[j] = max(dp[j],dp[j-nums[i]]+nums[i]);
else
dp[j] = dp[j];
}
}
if(dp[n] == n) return true;
else return false;
}
};
时间复杂度:O(n^2)
空间复杂度:O(n)