开门见山
背包问题是动态规划中非常典型的例题,新手较为难懂,因为背包问题的动态转移方程不是那么容易直接看出来的,但是理解后会发现实现起来变得非常机械,直接套用动态转移方程即可。看到的大多数资料都是各种奇怪的语法写伪代码,不太好理解,记忆反而不方便,下面直接给出转态转移方程得到的部分代码,可以直接记忆,后续再逐步分析推导,深入理解,加深记忆。
01背包
问题:给定n种物品和一背包。物品 i 的重量为w[i],其价值为 v[i],背包的容量为c。问应该如何选择装入背包中的物品,使得装入背包中物品的总价值最大?
定义
dp[i][j]
表示 把前i
个物品装进容器为j
的背包可以获得的最大价值
01背包问题部分代码:
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= c; ++j) {
if (j >= w[i - 1]) {
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]);
}
}
}
// 空间优化后
for (int i = 1; i <= n; ++i) {
for (int j = c; j >= 0; --j) {
if (j >= w[i - 1]) {
dp[j] = max(dp[j], dp[j - w[i - 1]] + v[i - 1]);
}
}
}
完全背包
完全背包与01背包不同就是每种物品可以有无限多个。
完全背包问题部分代码:
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= c; ++j) {
if (j >= w[i - 1]) {
dp[i][j] = max(dp[i - 1][j], dp[i][j - w[i - 1]] + v[i - 1]);
}
}
}
// 空间优化后
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= c; ++j) {
if (j >= w[i - 1]) {
dp[j] = max(dp[j], dp[j - w[i - 1]] + v[i - 1]);
}
}
}
分析
01背包
背包问题的状态的转移方程是不太可能直接看出来的,需要通过表格的方式逐步推导,了解了具体的计算过程,下面使用具体的计算数据为例,背包容量为10,3件物品,重量分别为3,4,5,价值分别为4,5,6
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
重量 | 价值 | 物品 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
3 | 4 | 1 | 0 | 0 | 0 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 |
4 | 5 | 2 | 0 | 0 | 0 | 4 | 5 | 5 | 5 | 9 | 9 | 9 | 9 |
5 | 6 | 3 | 0 | 0 | 0 | 4 | 5 | 6 | 6 | 9 | 10 | 11 | 11 |
上表中第一列代表背包容量为1-10的,表中数据为对应容量下,背包可以装下物品的最大价值。为了计算方便,同样也考虑了没有物品和背包容量为0时候的值,
在只有物品1的情况下,第一行在容量3以上全部为4,也即物品1的价值,因为这个时候只能拿物品1。为啥3以下为0,很容易理解,因为背包装不下,因此可以可到一个条件就是背包容量需要大于当前物品的重量,否则不用考虑。
在引入物品2后,判断变得不一样,在容量为4的情况下,背包既可以装物品1,也可以装物品2,显然这个时候需要选择物品2,因为价值更大,在只考虑物品1的情况,容量为4的时候,最大价值为4,而在不选物品1,选物品2的情况下,最大价值是5,这里存在比较,比较条件为,不选物品2和选择物品2,不选的值为之前不考虑物品2时,和当前容量一样的值,也即图中蓝色的4,选的值为当前容量减去物品2的重量时背包可以装下的最大值,也即图中蓝色的零,在加上物品2的价值,发现选物品2的价值更大,于是选择物品2,这样便得到图中红色数字5,为此可以得到如下规律。
第
i
个装不下时,所得价值为dp[i - 1][j]
第i
个可以装的下,所得价值为max(dp[i - 1][j]
,dp[i - 1][j - w[i]] + v[i]
);
根据这个规律便可以得到全表,也即可以得到考虑全部物品,不同容量背包得到的物品的最大价值。
完全背包
与01背包不同是,同一个物品可以无限制取,但是毕竟容量有限,一件物品不可能无限装,有最大上限,即c/w[i]
,如此就可以将物品i
扩展成c/w[i]
个,然后再按照01背包的规律便可以得到如下的表格。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
重量 | 价值 | 物品 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
3 | 4 | 1-1 | 0 | 0 | 0 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 |
3 | 4 | 1-2 | 0 | 0 | 0 | 4 | 4 | 4 | 8 | 8 | 8 | 8 | 8 |
3 | 4 | 1-3 | 0 | 0 | 0 | 4 | 4 | 4 | 8 | 8 | 8 | 12 | 12 |
4 | 5 | 2-1 | 0 | 0 | 0 | 4 | 5 | 5 | 8 | 9 | 9 | 12 | 13 |
4 | 5 | 2-2 | 0 | 0 | 0 | 4 | 5 | 5 | 8 | 9 | 10 | 12 | 13 |
5 | 6 | 3-1 | 0 | 0 | 0 | 4 | 5 | 6 | 8 | 9 | 10 | 12 | 13 |
5 | 6 | 3-2 | 0 | 0 | 0 | 4 | 5 | 6 | 8 | 9 | 10 | 12 | 13 |
这里分割的方式也可以使用二进制的方式进行优化,这里不做详述
按照这样扩展可以得到最终答案,但是性能有些不足,需要做些优化,对于上表只保留每种物品最后一个物品的值,去除其他行数据,如此便可以得到下面的表格。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
重量 | 价值 | 物品 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
3 | 4 | 1 | 0 | 0 | 0 | 4 | 4 | 4 | 8 | 8 | 8 | 12 | 12 |
4 | 5 | 2 | 0 | 0 | 0 | 4 | 5 | 5 | 8 | 9 | 10 | 12 | 13 |
5 | 6 | 3 | 0 | 0 | 0 | 4 | 5 | 6 | 8 | 9 | 10 | 12 | 13 |
对比与01背包问题,该表格也有一定的计算规律。在只考虑第一种物品时,由于可以再取,在容量为6时与01背包有所不同,可以取两次物品1得到最大价值为8,以图中红色的9为例,当背包容量为7时,考虑是否取物品2时,需要比较的是,不考虑物品2,值为图中上一列蓝色的8,考虑物品2,图中同列蓝色的4,位置为当前背包容量减当前物品重量,值再加上物品2的价值。于是便得到如下规律
第
i
个装不下时,所得价值为dp[i - 1][j]
第i
个可以装的下,所得价值为max(dp[i - 1][j]
,dp[i][j - w[i]] + v[i]
);
与01背包二者区别在于使用同一列数据做计算。
空间优化
在计算的过程中,对于已经计算过数据其实后续不会再使用,最终也不会再用到,于是可以将二维数组压缩成为一维,这种方式便是滚动数组,得到优化后的状态转移方程。
dp[j] = max(dp[j],dp[j - w[i]] + v[i])
这时会惊奇的发现01背包和完全背包是一样的,确实是一样,但是计算方式上存在差异,对于01背包因为每次计算需要使用上一列的数值,所以在计算时需要反向计算,这样便可以保证,使用之前数值时是未更新之前的,而完全背包需要正向计算,这样使用前值便是更新后的值。
例题
通过上述表格逐步计算,得到状态转移方程,为了强化记忆,需要一些例题巩固,因为背包问题是一种思想,而不是一类问题,解决问题时,可能不太容易看出是背包问题,或者可以使用背包的思想,需要自己去抽象。什么对应背包,什么对应物品,需要是情况而定。
最后一块石头的重量 II
https://leetcode-cn.com/problems/last-stone-weight-ii/
可以抽象为将石头分为两组,对应这物品取和不取,两组总重量差值最小,也就是让需要取的那组重量最大,这样的最大重量便是一种抽象的背包。
int lastStoneWeightII(int *stones, int stonesSize)
{
int sum = 0;
for (int i = 0; i < stonesSize; ++i) {
sum += stones[i];
}
int bagSize = sum / 2;
int *dp = calloc(bagSize + 1, sizeof(int));
for (int i = 0; i < stonesSize; ++i) {
for (int j = bagSize; j >= stones[i]; --j) {
dp[j] = fmax(dp[j], dp[j - stones[i]] + stones[i]);
}
}
return sum - 2 * dp[bagSize];
}
零钱兑换
https://leetcode-cn.com/problems/coin-change/
int coinChange(int *coins, int coinsSize, int amount)
{
int dp[amount + 1];
for (int j = 0; j <= amount; ++j) {
dp[j] = (j == 0) ? 0 : INT_MAX / 2;
}
for (int i = 0; i < coinsSize; ++i) {
for (int j = coins[i]; j <= amount; ++j) {
dp[j] = fmin(dp[j], dp[j - coins[i]] + 1);
}
}
return dp[amount] == INT_MAX / 2 ? -1 : dp[amount];
}
一和零
https://leetcode-cn.com/problems/ones-and-zeroes/
0和1是两种不同维度的约束条件,是二维背包问题。
int findMaxForm(char ** strs, int strsSize, int m, int n)
{
int *count0 = calloc(strsSize, sizeof(int));
int *count1 = calloc(strsSize, sizeof(int));
for (int i = 0; i < strsSize; i++) {
for (int j = 0; j < strlen(strs[i]); j++) {
strs[i][j] == '0' ? count0[i]++ : count1[i]++;
}
}
int dp[m + 1][n + 1];
memset(dp, 0, sizeof(int) * (m + 1) * (n + 1));
for (int i = 0; i < strsSize; i++) {
for (int j = m; j >= count0[i]; j--) {
for (int k = n; k >= count1[i]; k--) {
dp[j][k] = fmax(dp[j][k], dp[j - count0[i]][k - count1[i]] + 1);
}
}
}
return dp[m][n];
}
总结
背包问题是动态规划中非常典型的例题,新手较为难懂,理解后编码会变得非常机械,
不是所有问题都可以明显看出可以使用背包的思想,需要具体问题具体分析,对不同概念抽象成背包和物品,再套用动态转移方程即可。
01背包和完全背包都可以进行空间优化将二维压缩成一维,二者的状态转移方程相同,不同的是计算方向。
实际编码时,可以通过遍历的范围和边界优化下判断条件,dp
数组一般从1开始计数,防止在减一时候出错。
更多背包问题可以参考: 背包九讲