动态规划3️⃣:背包问题

Channing Hsu

20210117171307407

01 背包

n件物品和一个最多能背重量为w的背包。第i件物品的重量是weight[i],得到的价值是value[i]每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

暴力解法:

每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是,这里的n表示物品数量。所以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!

二维dp数组01背包

在下面的讲解中,举一个例子:

背包最大重量为4。物品为:

重量 价值
物品0 1 15
物品1 3 20
物品2 4 30

问背包能背的物品最大价值是多少?

  1. 确定dp数组以及下标的含义

使用动态规划来求解,核心在于理解如何通过前 i-1 件物品来推导出前 i 件物品的最大价值,我们定义 dp[i][j]为前 i 件物品在总重量不超过 j 的情况下可以获得的最大价值。即从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。如图:

20210110103003361

  1. 确定递推公式
  • 不放物品i:假设我们不选择第 i 件物品,那么此时的最大价值应该等于前 i-1 件物品在总重量不超过 j 的情况下的最大价值。也就是说,我们不考虑第 i 件物品,背包的状态和价值完全取决于前 i-1 件物品。

    状态转移方程:dp[i][j] = dp[i-1][j]

  • 放物品i:如果选择第 i 件物品,则我们需要确保背包剩余的容量可以容纳第 i 件物品。第 i 件物品的重量为 weight[i-1]。选择了第 i 件物品后,我们需要在剩余的容量 j - weight[i-1] 中计算前 i-1 件物品的最大价值,然后加上第 i 件物品的价值 value[i-1]。

    状态转移方程:dp[i][j] = dp[i-1][j-weight[i-1]] + value[i-1]

在每一步决策时,我们要么选择第 i 件物品,要么不选择。我们需要在这两种选择中取最大值,以确保当前状态下的最大价值。

  • 综合状态转移方程:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i-1]] + value[i-1])
  1. dp数组如何初始化

首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。如图:

2021011010304192

在看其他情况。

状态转移方程 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物品。

代码初始化如下:

1
2
3
4
5
6
7
8
for (int j = 0 ; j < weight[0]; j++) { 
// 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。
dp[0][j] = 0;
}
// 正序遍历
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}

此时dp数组初始化情况如图所示:20210110103109140

dp[0][j] dp[i][0]都已经初始化了,那么其他下标应该初始化多少呢?

其实从递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);可以看出dp[i][j]是由左上方数值推导出来了,那么其他下标初始为什么数值都可以,因为都会被覆盖。

初始-1,初始-2,初始100,都可以!

但只不过一开始就统一把dp数组统一初始为0,更方便一些。

如图:动态规划-背包问题10

1
2
3
4
5
// 初始化 dp
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
  1. 确定遍历顺序

在如下图中,可以看出,有两个遍历的维度:物品与背包重量

2021011010314055

那么问题来了,先遍历 物品还是先遍历背包重量呢?

其实都可以!! 但是先遍历物品更好理解

那么我先给出先遍历物品,然后遍历背包重量的代码。

1
2
3
4
5
6
7
8
// 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]);

}
}

先遍历背包,再遍历物品,也是可以的!(注意我这里使用的二维dp数组)

例如这样:

1
2
3
4
5
6
7
// 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]);
}
}

为什么也是可以的呢?

要理解递归的本质和递推的方向

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的。

dp[i-1][j]dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正上方向),那么先遍历物品,再遍历背包的过程如图所示:

202101101032124

再来看看先遍历背包,再遍历物品呢,如图:20210110103244701

大家可以看出,虽然两个for循环遍历的次序不同,但是dp[i][j]所需要的数据就是左上角,根本不影响dp[i][j]公式的推导!

但先遍历物品再遍历背包这个顺序更好理解。

其实背包问题里,两个for循环的先后循序是非常有讲究的,理解遍历顺序其实比理解推导公式难多了

  1. 举例推导dp数组

来看一下对应的dp数组的数值,如图:

20210118163425129

最终结果就是dp[2][4]

建议大家此时自己在纸上推导一遍,看看dp数组里每一个数值是不是这样的。

做动态规划的题目,最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

//二维dp数组实现
#include <bits/stdc++.h>
using namespace std;

int n, bagweight;// bagweight代表行李箱空间
void solve() {
vector<int> weight(n, 0); // 存储每件物品所占空间
vector<int> value(n, 0); // 存储每件物品价值
for(int i = 0; i < n; ++i) {
cin >> weight[i];
}
for(int j = 0; j < n; ++j) {
cin >> value[j];
}
// dp数组, dp[i][j]代表行李箱空间为j的情况下,从下标为[0, i]的物品里面任意取,能达到的最大价值
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));

// 初始化, 因为需要用到dp[i - 1]的值
// j < weight[0]已在上方被初始化为0
// j >= weight[0]的值就初始化为value[0]
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}

for(int i = 1; i < weight.size(); i++) { // 遍历科研物品
for(int j = 0; j <= bagweight; j++) { // 遍历行李箱容量
// 如果装不下这个物品,那么就继承dp[i - 1][j]的值
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
// 如果能装下,就将值更新为 不装这个物品的最大值 和 装这个物品的最大值 中的 最大值
// 装这个物品的最大值由容量为j - weight[i]的包任意放入序号为[0, i - 1]的最大值 + 该物品的价值构成
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() {
while(cin >> n >> bagweight) {
solve();
}
return 0;
}

一维dp数组(滚动数组)

二维动态规划解法

在二维动态规划中,我们定义 dp[i][j] 为从前 i 件物品中任意选择,放入容量为 j 的背包中,所能获得的最大价值。

递推公式如下:
dp[i][j] = \max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])

这个公式的意思是,对于第 i 件物品,有两种选择:

  1. 不选第 i 件物品,最大价值是 dp[i-1][j]
  2. 选第 i 件物品,最大价值是 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])

  1. 确定dp数组的定义

​ 在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。

  1. 一维动态规划的递推公式

    dp[j]为 容量为j的背包所背的最大价值,那么如何推导dp[j]呢?

    dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[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,指定是取最大的,毕竟是求最大价值,

所以递归公式为:

1
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

这公式表示,对于容量为 j 的背包,如果选择第 i 件物品,那么最大价值是 dp[j - weight[i]] + value[i]。如果不选择,则最大价值是 dp[j]

  1. 一维动态规划的初始化

dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0] 应该初始化为 0,表示容量为 0 的背包,所能装的最大价值是 0。其他 dp[j] 初始化为 0,因为我们假设物品价值为正数,初始值为 0 可以确保我们在递推时不会被初始值影响。

  1. 一维动态规划的遍历顺序

​ 为了确保每件物品只使用一次,背包容量 j 的遍历顺序应为从大到小。具体代码如下:

1
2
3
4
5
6
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遍历的时候,背包是从大到小。

倒序遍历是为了确保每件物品只使用一次。如果采用正序遍历,会导致一个物品在同一轮次中被多次选取,下面通过具体例子解释这一点。

假设有一个物品 0,其重量 weight[0] = 1,价值 value[0] = 15,初始的 dp 数组为 [0, 0, 0, 0, 0]

正序遍历的示例

若正序遍历背包容量,从小到大进行:

1
2
3
for (int j = weight[i]; j <= bagWeight; j++) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
  • 更新 dp[1]:

    更新后的 dp 数组为 [0, 15, 0, 0, 0]

  • 更新 dp[2]:

    更新后的 dp 数组为 [0, 15, 30, 0, 0]

可以看到,当更新 dp[2] 时,已经更新过的 dp[1] 的值(15)被用来计算 dp[2],使得 dp[2] 的值变成了 30。这意味着物品 0 被认为可以再次放入背包中,这就导致了物品 0 被重复放入,产生了错误的结果。

倒序遍历的示例

若倒序遍历背包容量,从大到小进行:

1
2
3
for (int j = bagWeight; j >= weight[i]; j--) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
  • 更新 dp[2]:

    更新后的 dp 数组为 [0, 0, 15, 0, 0]

  • 更新 dp[1]:

    更新后的 dp 数组为 [0, 15, 15, 0, 0]

通过倒序遍历,我们确保在更新 dp[j] 时,使用的是前一轮次的值,而不是本轮次中已经更新过的值。这样就能避免重复选取同一个物品。

为什么二维 DP 不需要倒序遍历?

在二维 DP 中,我们更新 dp[i][j] 时,是基于上一层 dp[i-1][j] 的值,当前层 dp[i][j] 的更新不会影响到同层的其他值。也就是说,在更新 dp[i][j] 时,不会用到当前层 dp[i][j] 以外的其他值,因此可以从小到大进行正序遍历。

总结来说,倒序遍历在一维 DP 中的关键作用是防止同一物品在一轮次中被重复选取,而在二维 DP 中,由于当前层的更新不会影响到其他同层值,所以可以采用正序遍历。

再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?

不可以!

因为一维dp的写法,背包容量一定是要倒序遍历,如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。

倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。

所以一维dp数组的背包在遍历顺序上和二维其实是有很大差异的!,这一点大家一定要注意。

  1. 举例推导

20210110103614769

以背包容量 N = 4,物品为 [{1, 15}, {3, 20}, {4, 30}] 为例:

  1. 初始化 dp = [0, 0, 0, 0, 0]
  2. 遍历物品 0(重量 1,价值 15):
    • 更新 dp[4] = max(dp[4], dp[3] + 15) = 15
    • 更新 dp[3] = max(dp[3], dp[2] + 15) = 15
    • 更新 dp[2] = max(dp[2], dp[1] + 15) = 15
    • 更新 dp[1] = max(dp[1], dp[0] + 15) = 15

更新后的 dp 数组为 [0, 15, 15, 15, 15]

  1. 遍历物品 1(重量 3,价值 20):

    • 更新 dp[4] = max(dp[4], dp[1] + 20) = 35
    • 更新 dp[3] = max(dp[3], dp[0] + 20) = 20

    更新后的 dp 数组为 [0, 15, 15, 20, 35]

  2. 遍历物品 2(重量 4,价值 30):

    • 更新 dp[4] = max(dp[4], dp[0] + 30) = 35

    更新后的 dp 数组为 [0, 15, 15, 20, 35]

最终,dp[4] 即为背包容量为 4 时能装入的最大价值,为 35。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 一维dp数组实现
#include <iostream>
#include <vector>
using namespace std;

int main() {
// 读取 M 和 N
int M, N;
cin >> M >> N;

vector<int> costs(M);
vector<int> values(M);

for (int i = 0; i < M; i++) {
cin >> costs[i];
}
for (int j = 0; j < M; j++) {
cin >> values[j];
}

// 创建一个动态规划数组dp,初始值为0
vector<int> dp(N + 1, 0);

// 外层循环遍历每个类型的研究材料
for (int i = 0; i < M; ++i) {
// 内层循环从 N 空间逐渐减少到当前研究材料所占空间
for (int j = N; j >= costs[i]; --j) {
// 考虑当前研究材料选择和不选择的情况,选择最大值
dp[j] = max(dp[j], dp[j - costs[i]] + values[i]);
}
}

// 输出dp[N],即在给定 N 行李空间可以携带的研究材料最大价值
cout << dp[N] << endl;

return 0;
}

总结

以上的讲解可以开发一道面试题目(毕竟力扣上没原题)。

就是本文中的题目,要求先实现一个纯二维的01背包,如果写出来了,然后再问为什么两个for循环的嵌套顺序这么写?反过来写行不行?再讲一讲初始化的逻辑。

然后要求实现一个一维数组的01背包,最后再问,一维数组的01背包,两个for循环的顺序反过来写行不行?为什么?

注意以上问题都是在候选人把代码写出来的情况下才问的。

就是纯01背包的题目,都不用考01背包应用类的题目就可以看出候选人对算法的理解程度了。

完全背包

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

与01背包不同的就是,每种物品有无限件

举个例子:背包最大重量为4。物品为:

重量 价值
物品0 1 15
物品1 3 20
物品2 4 30

每件商品都有无限个!

问背包能背的物品最大价值是多少?

首先回顾一下 01 背包的核心代码:

1
2
3
4
5
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]);
}
}

我们知道01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。

而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:

1
2
3
4
5
6
7
// 先遍历物品,再遍历背包
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]);

}
}

dp状态图如下:

动态规划-完全背包

为什么遍历物品在外层循环,遍历背包容量在内层循环?

01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。

在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!

因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。

遍历物品在外层循环,遍历背包容量在内层循环,状态如图:

动态规划-完全背包2

遍历背包容量在外层循环,遍历物品在内层循环,状态如图:

20210729234011

看了这两个图,大家就会理解,完全背包中,两个for循环的先后循序,都不影响计算dp[j]所需要的值(这个值就是下标j之前所对应的dp[j])。

本题力扣上没有原题,可以去卡码网第52题去练习,题意是一样的,C++代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <vector>
using namespace std;

// 先遍历背包,再遍历物品
void test_CompletePack(vector<int> weight, vector<int> value, int bagWeight) {

vector<int> dp(bagWeight + 1, 0);

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 << dp[bagWeight] << endl;
}
int main() {
int N, V;
cin >> N >> V;
vector<int> weight;
vector<int> value;
for (int i = 0; i < N; i++) {
int w;
int v;
cin >> w >> v;
weight.push_back(w);
value.push_back(v);
}
test_CompletePack(weight, value, V);
return 0;
}

总结

对于纯完全背包问题,其for循环的先后循环是可以颠倒的!但如果题目稍稍有点变化,就会体现在遍历顺序上。

如果问装满背包有几种方式的话? 那么两个for循环的先后顺序就有很大区别了,而leetcode上的题目都是这种稍有变化的类型。

这个区别,我将在后面讲解具体leetcode题目中给大家介绍,因为这块如果不结合具题目,单纯的介绍原理估计很多同学会越看越懵!

最后,又可以出一道面试题了,就是纯完全背包,要求先用二维dp数组实现,然后再用一维dp数组实现,最后再问,两个for循环的先后是否可以颠倒?为什么?

评论