动态规划01背包
0/1 背包是什么?
-
你有
n
个物品,第i
个物品有重量w[i]
和价值v[i]
。 -
有一只容量为
W
的背包,只能装重量和 ≤W
的东西。 -
0/1 的意思:每个物品要么不拿,要么拿一次(不能拆,也不能重复拿)。
-
目标:在不超过容量的前提下,让总价值最大。
一、二维 DP(最直观、最好理解)
1. 状态定义
dp[i][c]
:只考虑前 i 个物品,在背包容量为 c 时能得到的最大价值。
行是“用了多少件物品”,列是“当前容量”。
这就像一张表,我们从小问题(少物品、小容量)一步步填到大问题(多物品、大容量)。
2. 转移方程
对第 i
件物品(下标从 1 开始、重量 w[i]
,价值 v[i]
),对每个容量 c
:
-
不选第 i 件:
dp[i][c] = dp[i-1][c]
-
可选且选择:如果
c >= w[i]
,
dp[i][c] = max(dp[i-1][c], dp[i-1][c - w[i]] + v[i])
逻辑:选了它,就占用
w[i]
容量并获得v[i]
价值,剩下的容量在“前 i-1 件物品”里去凑。
3. 初始条件
-
dp[0][c] = 0
(没有物品时,价值为 0) -
dp[i][0] = 0
(容量为 0 时,背不了东西)
4. 代码(带极详细注释)
#include <bits/stdc++.h>
using namespace std;// 二维 DP:dp[i][c] = 前 i 件物品、容量 c 的最大价值
int knapsack_2d(const vector<int>& w, const vector<int>& v, int W) {int n = (int)w.size();vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));// 物品按 1..n 编号使用(w[i-1]/v[i-1] 访问原数组)for (int i = 1; i <= n; ++i) {for (int c = 0; c <= W; ++c) {// 不选第 i 件dp[i][c] = dp[i - 1][c];// 能选就尝试选一下,看是否更优if (c >= w[i - 1]) {dp[i][c] = max(dp[i][c], dp[i - 1][c - w[i - 1]] + v[i - 1]);}}}return dp[n][W];
}
5. 选哪些物品?(回溯路径)
二维 DP 很容易“把方案捞出来”:从 dp[n][W]
倒着看:
-
如果
dp[i][c] == dp[i-1][c]
→ 第i
件没选; -
否则必有
dp[i][c] == dp[i-1][c - w[i-1]] + v[i-1]
→ 第i
件选了,c -= w[i-1]
,继续看i-1
。
vector<int> reconstruct_items(const vector<int>& w, const vector<int>& v, int W) {int n = (int)w.size();vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));for (int i = 1; i <= n; ++i) {for (int c = 0; c <= W; ++c) {dp[i][c] = dp[i - 1][c];if (c >= w[i - 1]) {dp[i][c] = max(dp[i][c], dp[i - 1][c - w[i - 1]] + v[i - 1]);}}}// 回溯选品vector<int> picked; // 存下标(0..n-1)int c = W;for (int i = n; i >= 1; --i) {// 若选择更优,说明选了第 i 件if (c >= w[i - 1] && dp[i][c] == dp[i - 1][c - w[i - 1]] + v[i - 1]) {picked.push_back(i - 1);c -= w[i - 1];}// 否则没选,啥也不做}reverse(picked.begin(), picked.end());return picked;
}
二、一维 DP(空间优化,面试最常写)
二维表每次只用到上一行,所以可以压缩到一维数组:dp[c]
表示当前处理到的物品范围内,容量为 c
的最大价值。
关键点:容量 c 要倒序遍历!
-
公式:
dp[c] = max(dp[c], dp[c - w[i]] + v[i])
(当c >= w[i]
) -
为什么倒序? 如果正序
c=0..W
,你刚更新完dp[c]
,后面更新dp[c + w[i]]
时会用到“本轮已更新过”的dp[c]
,等价于重复使用了第 i 件物品(就变成了完全背包),所以 0/1 背包必须倒序。
int knapsack_1d(const vector<int>& w, const vector<int>& v, int W) {int n = (int)w.size();vector<int> dp(W + 1, 0);for (int i = 0; i < n; ++i) {for (int c = W; c >= w[i]; --c) { // 倒序,防止重复使用同一物品dp[c] = max(dp[c], dp[c - w[i]] + v[i]);}}return dp[W];
}
三、手工推一遍(加深理解)
例子:W = 5
物品(w, v):
-
(2, 3)
-
(3, 4)
-
(4, 8)
-
(5, 8)
最优答案是 8(拿第 3 件 (4,8)
或第 4 件 (5,8)
)。
看一维 DP 如何“长出来”:
-
初始:
dp = [0,0,0,0,0,0]
(索引是容量 0..5) -
物品1 (2,3):倒序更新
c=5..2
-
c=5:
max(0, dp[3]+3=0+3)=3
→[0,0,0,0,0,3]
-
c=4:
max(0, dp[2]+3=0+3)=3
→[0,0,0,0,3,3]
-
c=3:
max(0, dp[1]+3=0+3)=3
→[0,0,0,3,3,3]
-
c=2:
max(0, dp[0]+3=0+3)=3
→[0,0,3,3,3,3]
-
-
物品2 (3,4):
c=5..3
-
c=5:
max(3, dp[2]+4=3+4=7)=7
→[0,0,3,3,3,7]
-
c=4:
max(3, dp[1]+4=0+4=4)=4
→[0,0,3,3,4,7]
-
c=3:
max(3, dp[0]+4=0+4=4)=4
→[0,0,3,4,4,7]
-
-
物品3 (4,8):
c=5..4
-
c=5:
max(7, dp[1]+8=0+8=8)=8
→[0,0,3,4,4,8]
-
c=4:
max(4, dp[0]+8=0+8=8)=8
→[0,0,3,4,8,8]
-
-
物品4 (5,8):
c=5..5
-
c=5:
max(8, dp[0]+8=8)=8
→[0,0,3,4,8,8]
-
最终 dp[5]=8
,和我们直觉一致。
四、常见易错点(务必注意)
-
一维写法容量必须倒序;正序会变“完全背包”(可以无限取同一件)。
-
别忘了容量从 w[i] 开始(
c >= w[i]
时才可转移)。 -
初始化:
dp
默认 0 就行(0/1 背包是“最大化”,且允许不拿)。 -
权重/价值可能比较大时,用
long long
(尤其是多维扩展时)。 -
如果要恢复选品,建议用二维 DP(或维护
keep[i][c]
布尔表)。 -
若题目要求“恰好装满”而不是“容量不超过”,初始化会不一样(需要用 -INF/不可达表示);标准 0/1 背包默认“≤W”即可。
五、完整小模板(便于粘贴)
#include <bits/stdc++.h>
using namespace std;// 一维 0/1 背包:空间 O(W),时间 O(nW)
int knap01_1d(const vector<int>& w, const vector<int>& v, int W) {int n = (int)w.size();vector<int> dp(W + 1, 0);for (int i = 0; i < n; ++i) {for (int c = W; c >= w[i]; --c) { // 倒序!dp[c] = max(dp[c], dp[c - w[i]] + v[i]);}}return dp[W];
}// 二维 0/1 背包:便于恢复选品
pair<int, vector<int>> knap01_2d_with_pick(const vector<int>& w, const vector<int>& v, int W) {int n = (int)w.size();vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));for (int i = 1; i <= n; ++i) {for (int c = 0; c <= W; ++c) {dp[i][c] = dp[i - 1][c];if (c >= w[i - 1]) {dp[i][c] = max(dp[i][c], dp[i - 1][c - w[i - 1]] + v[i - 1]);}}}// 回溯选品vector<int> pick;int c = W;for (int i = n; i >= 1; --i) {if (c >= w[i - 1] && dp[i][c] == dp[i - 1][c - w[i - 1]] + v[i - 1]) {pick.push_back(i - 1);c -= w[i - 1];}}reverse(pick.begin(), pick.end());return {dp[n][W], pick};
}
六、拓展一口:与其它背包的区别
-
完全背包(可无限拿):一维写法容量正序(
for c=w..W
),因为同一轮允许复用当前物品。 -
多重背包(每件有数量上限):常用二进制拆分成若干 0/1 物品,或用单调队列优化。
-
恰好装满:初始化不同(不可达状态要用
-INF
),目标是dp[W]
可达且最大。
疑问:
// 计算“前 i 件物品、容量为 c”的最优值
dp[i][c] = dp[i-1][c]; // 不选第 i 件
if (c >= w[i-1]) { // ✅ 只有当容量 c 足以容纳第 i 件时dp[i][c] = max(dp[i][c],dp[i-1][c - w[i-1]] + v[i-1]); // 选第 i 件
}
你问“为什么写 c >= w[i-1]
,而不是‘减去 i 的重量’”。其实**“减去 i 的重量”就在右边的索引里**:dp[i-1][c - w[i-1]]
。
我们是在计算状态 dp[i][c]
时,考虑“选第 i 件”的方案,此时剩余容量就是 c - w[i-1]
,所以要从上一行(只用前 i-1 件)取到 dp[i-1][c - w[i-1]]
再加上这件的价值 v[i-1]
。这里并不是要去修改循环变量 c
,而是用“剩余容量”的状态值来转移。
if (c >= w[i-1])
的作用有两点:
-
可行性检查:只有当当前容量
c
足够装下第 i 件(c - w[i-1] >= 0
)时,才允许“选它”的分支;否则这件装不下,不能用这个转移。 -
防止越界:避免访问
dp[i-1][负数]
这种非法下标。
为什么是“≥”而不是 “>”?
-
当
c == w[i-1]
时,正好把这件装满也是合法的,必须允许,所以用>=
。 -
如果用
>
,就会错误地禁止“正好装满”的情况。
再补两点对比,避免常见误解:
-
不要改动循环变量
c
(比如写成c -= w[i-1]
):
我们是在“同一个c
”下比较两种选择(选/不选),改了c
就把状态弄混了,而且会影响后续循环。 -
一维优化时看起来没有
if
:
常见的一维 0/1 背包写法是for (int i = 0; i < n; ++i)for (int c = W; c >= w[i]; --c) // 从 W 到 w[i] 逆序dp[c] = max(dp[c], dp[c - w[i]] + v[i]);
这里把“
c >= w[i]
”体现在循环边界里了(从w[i]
开始倒着遍历),本质和二维版的if (c >= w[i-1])
完全一样。
一个小例子
容量 c=5
,第 i 件 w=3, v=4
:
-
计算
dp[i][5]
:可以不选 →dp[i-1][5]
;也可以选 →dp[i-1][5-3] + 4 = dp[i-1][2] + 4
。两者取大。 -
计算
dp[i][2]
:因为2 < 3
,选不了这件,只能dp[i][2] = dp[i-1][2]
。
这正是if (c >= w)
的意义。
一句话总结:
c >= w[i-1]
是在判断“当前容量是否足以装下这件”,而真正的“减去 i 的重量”体现在转移来源 dp[i-1][c - w[i-1]]
上;我们不改变 c
,而是用“剩余容量”的最优值来更新当前状态。
习题:
46. 携带研究材料(第六期模拟笔试)
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int main(){int m ,n;cin >> m >> n;vector<int> M(m,0); // 重量vector<int> N(m,0); // 价值for(auto & x: M){cin >> x;}for(auto & x : N){cin >> x;}vector<int> dp(n+1,0);for(int i = 0; i < m; i++){for(int j = n; j >= M[i]; j--){dp[j] = max(dp[j], dp[j-M[i]] + N[i]);}}cout << dp[n] << endl;
}