朴素解法
关于01背包问题及其动态规划解法的讲解(特别是针对C++代码)重新梳理成题解。
01背包问题
你拥有:
N
件物品。
一个容量为 V
的背包。
每件物品 i
有一个体积 v_i
和一个价值 w_i
。
约束:每件物品只能使用一次(即,要么选择放入背包,要么不放)。
目标:
选择物品的一个子集放入背包。
所选物品的总体积不得超过背包容量 V
。
所选物品的总价值应尽可能大。
输出这个可能的最大总价值。
C++ 代码分析
我们来看一下你代码中的变量和结构:
n
:物品的数量(从输入读取,对应问题描述中的 N
)。
m
:背包的容量(从输入读取,对应问题描述中的 V
)。
v[i]
:第 i
件物品的体积(1索引,即 v[1]
到 v[n]
)。
w[i]
:第 i
件物品的价值(1索引,即 w[1]
到 w[n]
)。
* f[i][j]
:动态规划(DP)表。这是解决方案的核心。
动态规划方法 - 步骤详解
-
识别最优子结构:
该问题具有最优子结构。如果我们想求解n
件物品在容量为m
的背包中的最大价值,我们可以考虑第n
件物品:- 情况1:我们不放入第
n
件物品。 问题就简化为在前n-1
件物品中,用同样的容量m
来获取最大价值。 - 情况2:我们放入第
n
件物品(前提是其体积v[n]
小于或等于当前容量m
)。 获得的价值将是w[n]
加上在前n-1
件物品中,用剩余容量m - v[n]
所能获得的最大价值。
最优解将是这两种情况中价值较大的那个。这个逻辑递归地适用于任何物品i
和容量j
。
- 情况1:我们不放入第
-
定义状态(匹配你代码中的
f[i][j]
):
在你代码中,状态f[i][j]
代表:
从前i
件物品中(即物品 1, 2, …,i
)进行选择,当背包容量为j
时,所能获得的最大总价值。i
的范围是从0
到n
(已考虑的物品数量)。j
的范围是从0
到m
(当前正在考虑的背包容量)。
-
推导状态转移方程(你代码循环中的核心逻辑):
对于每件物品i
(从1到n
),以及对于每种可能的容量j
(从0到m
):我们需要决定是否放入第
i
件物品(其体积为v[i]
,价值为w[i]
)。-
选项1:不放入第
i
件物品。
如果我们不放入第i
件物品,那么在容量为j
的情况下我们能得到的最大价值,就等于只使用前i-1
件物品在同样容量j
下所能得到的最大价值。
所以,价值为f[i-1][j]
。
这对应代码行:f[i][j] = f[i-1][j];
-
选项2:放入第
i
件物品。
这只有在当前容量j
大于或等于第i
件物品的体积时才可能(即j >= v[i]
)。
如果我们放入第i
件物品,它将贡献w[i]
的价值。背包的剩余容量变为j - v[i]
。然后我们需要用前i-1
件物品来最优地填充这个剩余容量。该子问题的最大价值是f[i-1][j - v[i]]
。
所以,如果我们放入第i
件物品,价值将是f[i-1][j - v[i]] + w[i]
。
合并选项: 我们想要最大化价值。所以
f[i][j]
将是这两个选项中的最大值(如果选项2有效)。
这正是你的代码所做的:
c++ f[i][j] = f[i-1][j]; // 选项1:假设我们不拿第 i 件物品 if (j >= v[i]) { // 检查选项2是否可行 // 选项2:拿第 i 件物品,并与选项1的价值比较 f[i][j] = max(f[i][j], f[i-1][j - v[i]] + w[i]); }
-
-
确定边界条件/初始状态:
f[0][j] = 0
对于所有j
从0
到m
:如果我们不考虑任何物品(即考虑0件物品),无论背包容量如何,能获得的最大价值都是0。f[i][0] = 0
对于所有i
从0
到n
:如果背包容量为0,我们无法放入任何物品,所以最大价值是0。
在你的C代码中,
f
是一个全局数组。在C中,如果全局数组没有显式初始化,它们会被自动零初始化。这有效地将所有的f[0][j]
和f[i][0]
(当i=0
或j=0
时)设置为0,从而满足了边界条件。
当i = 1
时,f[i-1][j]
变成f[0][j]
,其值为0。
当j = 0
时,f[i][0]
将是f[i-1][0]
(其值为0)。条件if (0 >= v[i])
通常为假(假设物品体积v[i]
是正数),所以f[i][0]
保持为f[i-1][0]
。 -
确定计算顺序(你代码中的循环):
你代码中的循环定义了f
表是如何被填充的:
c++ for (int i = 1; i <= n; i++) // 外层循环:遍历物品 for (int j = 0; j <= m; j++) // 内层循环:遍历容量 { // ... f[i][j] 的计算 ... }
- 外层循环
i
从1
遍历到n
(逐个考虑物品)。 - 内层循环
j
从0
遍历到m
(考虑当前物品集合下的所有可能容量)。
这个顺序至关重要。在计算
f[i][j]
时,它依赖于前一行f[i-1][...]
的值(即少考虑一件物品的子问题的解)。这种“自底向上”的方法确保了在计算f[i][j]
时,f[i-1][j]
和f[i-1][j - v[i]]
都已经被计算并存储好了。 - 外层循环
-
最终答案:
循环完成后,f
表就被填充完毕。根据我们对状态的定义,f[n][m]
将存储:
“从前n
件物品中(即所有物品)进行选择,当总体积不超过m
(总背包容量)时,所能获得的最大价值。”
这正是原问题的解。
你的代码通过以下语句输出这个结果:cout << f[n][m] << endl;
01背包DP思考过程总结:
- 我要优化什么? (总价值)
- 对于每件物品我有哪些选择? (拿,或者不拿)
- 我如何定义一个状态
dp[i][j]
(或f[i][j]
)来代表一个子问题的解?- 通常涉及
i
(到目前为止考虑的物品数量)和j
(当前的容量/约束)。 f[i][j]
= 使用前i
件物品,在容量为j
的情况下的最大价值。
- 通常涉及
- 我如何从较小的子问题过渡到较大的子问题? (递推关系式)
f[i][j]
基于f[i-1][...]
。- 考虑不拿第
i
件物品:f[i-1][j]
。 - 考虑拿第
i
件物品:第 i 件物品的价值 + f[i-1][j - 第 i 件物品的体积]
。 f[i][j] = max (这两个选项)
。
- 最简单的情况(边界条件)是什么?
f[0][j] = 0
(没有物品,没有价值)。f[i][0] = 0
(没有容量,没有价值)。
- 我应该按什么顺序填充我的DP表?
- 先遍历物品,再遍历容量。
这种系统性的方法,与你的C++代码相结合,为使用动态规划解决01背包问题提供了一条清晰的路径。
空间优化解法
好的,我们来讨论如何对上面讲解的01背包问题进行空间优化。
原来的二维DP解法中,我们使用了 f[i][j]
这样一个二维数组(或 vector<vector<int>>
),其空间复杂度是 O(N*V)
(其中 N
是物品数量,V
是背包容量)。当 N
和 V
很大时,这可能会导致内存超限。
空间优化的思路
观察状态转移方程:
f[i][j] = f[i-1][j]
(不选第 i
件物品)
f[i][j] = max(f[i][j], f[i-1][j - v[i]] + w[i])
(如果 j >= v[i]
,考虑选第 i
件物品)
我们发现,在计算第 i
行(即 f[i][...]
)的状态时,我们仅仅依赖于第 i-1
行(即 f[i-1][...]
)的状态。我们不需要 f[i-2]
, f[i-3]
等更早的状态。
这就启发我们,其实没有必要存储整个二维 f
表。理论上,我们只需要两个一维数组:一个存储第 i-1
行的状态,另一个用来计算并存储第 i
行的状态。甚至,我们可以优化到只用一个一维数组。
使用一维数组进行优化
我们可以定义一个一维数组 dp[j]
(或者在你的代码中,可以继续叫 f[j]
,只是它现在是一维的),其中 dp[j]
表示:
在处理到当前物品为止,对于容量为 j
的背包,所能获得的最大价值。
当我们考虑第 i
件物品(体积 v[i]
,价值 w[i]
)时,我们想要更新 dp[j]
的值。
状态转移方程可以改写成(尝试):
dp[j] = max(dp[j], dp[j - v[i]] + w[i])
这里有一个关键问题:当我们计算 dp[j]
时,dp[j - v[i]]
应该代表什么?
它应该代表 在不考虑第 i
件物品时,容量为 j - v[i]
的背包所能获得的最大价值(即对应二维表中的 f[i-1][j - v[i]]
)。
而 dp[j]
在等号左边,代表的是 考虑了第 i
件物品后,容量为 j
的背包所能获得的最大价值(即对应 f[i][j]
)。
* dp[j]
在等号右边的 max
函数的第一个参数,也应该代表 不考虑第 i
件物品时,容量为 j
的背包所能获得的最大价值(即对应 f[i-1][j]
)。
关键点:内层循环(容量 j
)的遍历顺序
如果我们在内层循环中,像二维DP那样正序遍历容量 j
(从 0
到 m
):
for (int j = v[i]; j <= m; j++) {
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
当计算 dp[j]
时,我们用到的 dp[j - v[i]]
可能已经是本轮(即考虑第 i
件物品时)更新过的值。例如,计算 dp[5]
时用到 dp[2]
,如果 dp[2]
已经在考虑第 i
件物品时被更新了,那么就相当于第 i
件物品在计算 dp[5]
时被间接考虑了多次(因为它影响了新的 dp[2]
,而新的 dp[2]
又用来计算 dp[5]
)。这就变成了完全背包问题(每件物品可以选多次)。
为了确保 dp[j - v[i]]
是上一轮(即处理第 i-1
件物品时)的状态值,我们必须逆序遍历背包容量 j
(从 m
向下到 v[i]
)。
for (int j = m; j >= v[i]; j--) {
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
这样,当我们计算 dp[j]
时:
dp[j]
(在 max
的第一个参数) 存储的是不放入物品 i
时,容量 j
的最大价值 (这是上一个物品 i-1
迭代结束时的 dp[j]
值,或者是本轮 i
更大容量 j'
更新前的值,但对于当前的 j
,它代表了不放物品 i
的情况)。
dp[j - v[i]]
存储的是不放入物品 i
时,容量 j - v[i]
的最大价值。因为 j - v[i]
比 j
小,所以在逆序遍历中,dp[j - v[i]]
尚未被当前物品 i
更新,它仍然是上一轮迭代(处理物品 i-1
)的结果。
这样,空间复杂度就从 O(N*V)
降到了 O(V)
。
具体的C++代码 (空间优化后)
使用与你之前代码相似的变量名:
#include <iostream>
#include <algorithm> // 为了 std::max
#include <vector> // 使用 vector 更灵活,但也可以用静态数组
using namespace std;
// 如果使用静态数组,可以像之前一样定义常量
// const int MAX_N = 1010; // 最大物品数量
// const int MAX_M = 1010; // 最大背包容量
int main() {
ios_base::sync_with_stdio(false); // 加速cin/cout
cin.tie(NULL);
int n, m; // n: 物品数量, m: 背包容量
cin >> n >> m;
// 使用 vector 存储物品的体积和价值
// v_item 和 w_item 下标从 0 开始,对应物品 0 到 n-1
// 或者,如果你习惯 1-based 索引,可以声明 vector 大小为 n+1
vector<int> v(n); // 体积
vector<int> w(n); // 价值
for (int i = 0; i < n; i++) {
cin >> v[i] >> w[i];
}
// dp[j] 表示容量为 j 的背包能获得的最大价值
// 对应原二维 f[i][j] 中的滚动数组思想
vector<int> dp(m + 1, 0); // 初始化所有元素为0,容量从0到m
// 遍历每一件物品
for (int i = 0; i < n; i++) { // 物品索引从 0 到 n-1
// 逆序遍历背包容量
// 确保每个物品只被考虑一次(01背包特性)
// j 必须大于等于当前物品体积 v[i] 才能考虑放入
for (int j = m; j >= v[i]; j--) {
// 决策:
// 1. 不放入第 i 件物品:dp[j] 的值保持不变(来自上一个物品迭代的结果)
// 2. 放入第 i 件物品:价值为 w[i] + dp[j - v[i]]
// (dp[j - v[i]] 是上一个物品迭代中,容量为 j - v[i] 的最大价值)
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
cout << dp[m] << endl; // 最终答案是容量为 m 时的最大价值
return 0;
}
代码说明与对应关系:
- 原
f[i][j]
:现在由一维数组dp[j]
滚动更新。 - 物品循环
for (int i = 1; i <= n; i++)
:对应新代码中的for (int i = 0; i < n; i++)
。这里的v[i]
和w[i]
是当前正在考虑的物品。 - 容量循环
for (int j = 0; j <= m; j++)
中的f[i-1][j]
:这部分信息隐含在dp[j]
更新前的值中。 - 容量循环
for (int j = 0; j <= m; j++)
中的f[i-1][j - v[i]]
:这部分信息通过逆序遍历,在dp[j - v[i]]
中获取,确保它是上一个物品迭代的结果。 - 核心状态转移
if (j >= v[i]) f[i][j] = max(f[i-1][j], f[i-1][j - v[i]] + w[i]); else f[i][j] = f[i-1][j];
:
在优化后的代码中,dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
实现了这个逻辑。dp[j]
(max的第一个参数) 代表不选当前物品i
时的价值,即f[i-1][j]
。dp[j - v[i]] + w[i]
代表选当前物品i
时的价值,其中dp[j - v[i]]
来自f[i-1][j - v[i]]
。- 如果
j < v[i]
,则循环for (int j = m; j >= v[i]; j--)
不会执行到这个j
,dp[j]
的值保持不变,天然对应了f[i][j] = f[i-1][j]
的情况。
这种空间优化是01背包问题的一个标准技巧,非常实用。