0-1背包问题详解:二维数组
文章讲解
视频讲解
0-1 背包问题:有 n 件物品和一个最多能背重量为 w 的背包。第 i 件物品的重量是 weight[i],价值是 value[i],每件物品只能用一次,求解将物品装入背包里物品价值总和最大为多少
例:
背包最大重量为4
物品为:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
问背包能背的物品最大价值是多少?
依然动态规划五部曲!
1、确定 dp 数组下标及值的含义
dp[i][j]:下标 i, j 表示从下标为 [0 - i] 的物品中任意取,放进容量为 j 的背包,dp[i][j] 的值表示从物品下标 [0 - i] 任取物品放进容量为 j 的背包所能装入的最大价值
2、确定递推公式
dp[i][j] 的值表示从下标为 [0 - i] 的物品中任取,放进容量为 j 的背包所能装入的最大价值,怎么求这个 dp[i][j] 呢?为了求 dp[i][j],肯定需要考虑从下标为 0 到 i 的物品中取物然后装进容量为 j 的背包的装取方案。得到这个 dp[i][j] 的装取方案有两种:一种是不放物品 i 就能得到最大价值,另一种是放了物品 i 才能得到最大价值
- 不放物品 i:已经确定里面不放物品 i 的最大价值,为物品 0 到 i - 1 装进容量为 j 的背包的最大价值,即 dp[i - 1][j]
- 放物品 i:已经确定要放物品 i 时的最大价值,为物品 0 到 i - 1 装进容量为 j - weight[i] 的背包的最大价值加上物品 i 的价值,即 dp[i - 1][j - weight[i]] + value[i]
这两种装取方案中的最大值就是从物品 [0 - i] 任取物品放进容量为 j 的背包所能装入的最大价值,即递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
注意这个递推公式的前提条件是背包要能装下物品 i,否则只需要考虑不放物品 i 的方案
3、dp 数组初始化
由递推公式,我们首先观察到 dp[i][j] 的值都是由上一行(层)的值推出来的(即由 i - 1 那一行推出来),故一定要初始化第一行,即 dp[0][j] 需要被初始化
怎么初始化 dp[0][j] 呢?考虑 dp 数组下标及值的含义:取物品 0(从下标为 0 到 0 的物品中取)放进容量为 j 的背包,能装入的最大价值
显而易见
其他位置随意初始化,反正都会被遍历覆盖
4、确定遍历顺序
从上层(上一行)遍历填充到下层(下一行)就可以(因为当前层的值是由上一层推出来的,需要保证上一层是已经更新后的正确的值),单层中从左到右或者从右到左遍历填充均可
代码中,外层 for 循环遍历物品即可(一个物品的索引代表一行,一行一行遍历)
5、打印 dp 数组验证
代码如下
void bag_problem_2d() {
cout << "请输入背包容量:";
int bagSize;
cin >> bagSize;
cout << "请输入物品个数:";
int n;
cin >> n;
cout << "请依次输入物品重量:" << endl;
vector<int> weight(n);
for (int i = 0; i < n; ++i) {
cin >> weight[i];
}
cout << "请依次输入物品价值:" << endl;
vector<int> value(n);
for (int i = 0; i < n; ++i) {
cin >> value[i];
}
cout << "背包容量:" << bagSize << endl;
cout << "物品重量:";
for (auto i : weight)
cout << i << "\t\t";
cout << endl;
cout << "物品价值:";
for (auto i : value)
cout << i << "\t\t";
cout << endl << endl;
// 定义dp数组下标及含义:dp[i][j]:从物品0到i中任取,放进容量为bagSize的背包,得到最大价值为dp[i][j]
vector<vector<int> > dp(n, vector<int>(bagSize + 1, 12345)); // 12345表示其他位置随意初始化都行
// 递推公式:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i])
// 初始化:初始化第一行,即i=0那一行即可
for (int j = 0; j <= bagSize; ++j) {
if (j >= weight[0]) // 当能装下物品0,才装
dp[0][j] = value[0];
else // 装不下物品0
dp[0][j] = 0;
}
// 遍历填充dp数组:一行一行填充
for (int i = 1; i < weight.size(); ++i) // 遍历物品(即遍历每一行)
for (int j = 0; j <= bagSize; ++j) // 遍历背包(遍历行中的元素)
{
if (j >= weight[i])
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i]);
else
dp[i][j] = dp[i-1][j];
}
// 打印dp数组验证
cout << "dp数组如下:" << endl;
for (const auto & line : dp) {
for (const auto item : line)
cout << item << "\t\t";
cout << endl;
}
return;
}
0-1背包问题详解:滚动数组
文章讲解
视频讲解
上面用了二维 dp 数组实现 0-1 背包问题,有没有办法优化,用一维 dp 数组实现?
回顾一下二维的递推公式(前提条件是背包要能装下物品 i)
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
重点在于:dp[i][j] 的值都是由上一行(层)的值推出来的
即更新当前层的时候,只需要用到上一层的数据就行了。我们能不能只维护一层的数据?
只维护红框中的一层数据
我们将 dp 数组压缩成一行
先看看压缩后的递推公式:因为状态压缩了,dp 数组变成了一维,下标 j 代表背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
等式左边的 dp[j] 代表的是第 i 层的dp,这是更新后的dp;右边的 dp[j] 代表的是第 i-1 层的 dp, dp[j-w[i]] 代表的是第 i-1 层的 dp
左图为原二维 dp 数组,右图是我们压缩后的滚动数组
本质上就是就地更新 dp 数组的每一层
我们从图中可以看出几个关键点
- 因为滚动数组维护的是单层二维 dp 数组的数据,为了实现一层一层遍历,外层 for 循环为物品 i,表示在二维数组中需要一层一层填充,每遍历完一轮外层的循环,表明更新完了一层二维 dp 数组
- 更新每层中的元素时,即更新我们的一维数组时,需要倒序遍历填充!因为我们更新当前层某位置元素依赖的是上一层元素,更详细地说是正上方及正上方左边的元素,对应到滚动数组中,更新某位置元素依赖的是当前位置元素的旧值及当前位置左边的元素的旧值(这里的旧值指的是上一轮外层 for 循环所填充的值)。只有倒序遍历从右向左填充,才能保证当前位置及当前位置左边的元素保留上一轮外层循环的旧值。如果正序遍历,则当前位置的左边元素的值可能在本轮外层循环中已经被更新了,无法持有上一轮循环更新得到的值了
- 另外提一下,如果正序遍历,相当于物品能够多次添加(这个后面再展开讲解)
代码如下:
void bag_problem_1d() {
cout << "请输入背包容量:";
int bagSize;
cin >> bagSize;
cout << "请输入物品个数:";
int n;
cin >> n;
cout << "请依次输入物品重量:" << endl;
vector<int> weight(n);
for (int i = 0; i < n; ++i) {
cin >> weight[i];
}
cout << "请依次输入物品价值:" << endl;
vector<int> value(n);
for (int i = 0; i < n; ++i) {
cin >> value[i];
}
cout << "背包容量:" << bagSize << endl;
cout << "物品重量:";
for (auto i : weight)
cout << i << "\t\t";
cout << endl;
cout << "物品价值:";
for (auto i : value)
cout << i << "\t\t";
cout << endl << endl;
// 定义状态压缩后的dp数组
vector<int> dp(bagSize + 1);
// 递推公式:dp[j] = max(dp[j], dp[j-weight[i]]+value[i])
// 初始化:初始化第一行,i=0那一行,目前dp的值就是第一行
for (int j = 0; j <= bagSize; ++j) {
if (j >= weight[0]) // 当能装下物品0,才装
dp[j] = value[0];
else // 装不下物品0
dp[j] = 0;
}
// 打印一下第一行的dp数组值
for (auto item : dp)
cout << item << "\t\t";
cout << endl;
// 遍历填充dp数组:一行一行填充
for (int i = 1; i < weight.size(); ++i) // 遍历物品(一层一层遍历,第一行已经被初始化了,从第二行开始)
{
for (int j = bagSize; j >= 0; --j) // 遍历背包(倒序遍历)
{
if (j >= weight[i]) // 当能装下物品i才装
dp[j] = max(dp[j], dp[j-weight[i]]+value[i]);
// else // 装不下物品i
// dp[j] = dp[j];
}
// 遍历完了一轮外层循环,我们打印看看这层dp数组
for (auto item : dp)
cout << item << "\t\t";
cout << endl;
}
return;
}
416.分割等和子集
力扣题目链接/文章讲解
视频讲解
本题能够将问题转化为 0-1 背包问题
0-1背包问题是用物品装入背包,求装入的最大价值
要把01背包问题套到本题上来,需要确定
- 背包容量
- 物品价值
- 物品重量
怎么转化呢?回归主题:首先,本题要求集合里能否出现总和为 sum / 2 的子集
假如我们将元素看成一定重量的物品,问题转化成想将这些具有一定重量的物品装入容量为 sum / 2 的背包,看看能不能恰好装满
这种应该怎么设置物品重量和价值?答:重量为元素的数值,价值也为元素的数值
这样,可装入的最大价值 = 可装入的最大重量,可以通过装入的最大价值(可装入的最大重量)是否和容量相等判断是否能装满
这样问题就转过来了:
- 背包容量:sum / 2
- 物品价值:元素数值
- 物品重量:元素数值
接下来可以开始套用0-1背包的方法
代码如下:
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum % 2) // 如果sum为奇数,则肯定无法分成两个相等子集
return false;
// 背包大小 sum / 2
// 物品重量 nums
// 物品价值 nums
int bagSize = sum / 2;
vector<int> dp(bagSize + 1);
for (int j = 0; j <= bagSize; ++j) { // 初始化二维数组第一行
if (j >= nums[0]) // 如果容量为 j 的背包能装下物品0
dp[j] = nums[0];
else
dp[j] = 0;
}
for (int i = 1; i < nums.size(); ++i) { // 遍历物品(一层一层遍历)
for (int j = bagSize; j >= 0; --j) { // 倒序遍历背包(从右向左遍历层中的元素)
if (j > nums[i]) { // 如果容量 j 能装下物品
dp[j] = max(dp[j], dp[j-nums[i]] + nums[i]);
}
else // 不装物品
dp[j] = dp[j];
}
}
return dp[bagSize] == bagSize;
}
};
回顾总结
时刻记住滚动数组是如何与二维 dp 对应的
具体而言在操作滚动数组的某个位置的时候,脑海中要能对应出操作的是二维数组中的哪个位置
此外,一定要记住二维数组中行和列索引分别代表的是物品还是背包容量