前言
上一篇 DP 入门 建立了”状态 + 转移”的框架。这一篇聚焦 DP 最经典的模型——背包问题。
场景很简单:你有一个容量有限的背包,面前有很多物品,每个有重量和价值。你想在不超过背包容量的前提下,使选中物品的总价值最大。
听起来朴素,但这个模型极其强大。“去不去旅行”是 0-1 背包(每件事要么做要么不做),“零钱兑换”是完全背包(每种硬币可以用无限次),“分组背包”是加了约束的变种。掌握了背包,你就掌握了 DP 最重要的一类状态设计。
问题的本质
0-1 背包的核心矛盾
每件物品要么选,要么不选——没有”选一半”这种操作。暴力枚举 种方案不可行。
DP 的关键观察:如果我已经决定了前 件物品怎么选,第 件物品只有两种可能——选或不选。我只需要知道”用了多少容量”就足够了,不需要知道具体选了哪些物品。
这就是状态设计: = 容量恰好为 时能获得的最大价值。
循环方向的秘密
0-1 背包内层循环 必须从大到小。为什么?
考虑当前处理物品 (重量 ,价值 )。如果 从小到大遍历:
j = w: dp[w] = max(dp[w], dp[0] + v) ← 用了 dp[0]
j = 2w: dp[2w] = max(dp[2w], dp[w] + v) ← 但 dp[w] 刚被更新过!
这意味着物品 被选了两次——违反了”每件只能选一次”的规则。
从大到小就没这个问题:
j = 2w: dp[2w] = max(dp[2w], dp[w] + v) ← dp[w] 还是旧值
j = w: dp[w] = max(dp[w], dp[0] + v) ← 现在才更新 dp[w]
物品 每个容量只被考虑一次。✓
完全背包恰恰相反——每件物品可以选无限次,所以 从小到大,允许同一物品被多次使用。
当 太大时:互换重量和价值
标准的 0-1 背包时间 ,但如果 ,直接做会 TLE。
关键技巧:如果价值 很小(比如 ),那就把状态定义换过来—— = 达到总价值 所需的最小重量。 这样状态数从 变成 。
理论 + 代码
0-1 背包(标准版)
状态: = 容量 下的最大价值。
转移:, 从 到 倒序。
long long dp[100006]; // dp[j] = 容量 j 时的最大价值
for (int i = 1; i <= N; i++)
for (int j = W; j >= w[i]; j--)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]); // ① 选 or 不选
逐行解析:
- ① 对于容量 ,要么不选物品 (保持 ),要么选()。取较大值。
- 从 到 倒序,保证每件物品只被考虑一次。
模拟:,物品 。
| 阶段 | dp[0] | dp[3] | dp[4] | dp[5] | dp[7] | dp[8] |
|---|---|---|---|---|---|---|
| 初始 | 0 | 0 | 0 | 0 | 0 | 0 |
| 物品1(w=3,v=30) | 0 | 30 | 0 | 0 | 0 | 0 |
| 物品2(w=4,v=50) | 0 | 30 | 50 | 50 | 80 | 80 |
| 物品3(w=5,v=60) | 0 | 30 | 50 | 60 | 80 | 90 |
,对应选物品 1 和 3(, )。✓
0-1 背包( 很大时)
状态: = 达到总价值 所需的最小重量。
转移:。
答案:最大的 使得 。
long long dp[100009]; // dp[j] = 价值 j 时的最小重量
// 初始化:dp[0] = 0, 其余 = INF
for (int i = 1; i <= N; i++)
for (int j = Vmax; j >= v[i]; j--)
dp[j] = min(dp[j], dp[j - v[i]] + w[i]); // ① 互换!
// ② 找最大的 j 使得 dp[j] <= W
long long ans = 0;
for (int j = 0; j <= Vmax; j++)
if (dp[j] <= W) ans = j;
- ① 和标准 0-1 背包结构完全一样,只是把 和 互换了。
- ② 答案不再是 ,而是遍历所有可能的价值 ,找最大的满足重量约束的。
例题
例题 1:M&A 030 — Knapsack 1
题目: 个物品,重量 ,价值 ,背包容量 。求最大总价值。
数据范围:,,
分析:标准 0-1 背包。,用 的标准做法。注意 最大 ,答案要用 long long。
输入样例:
3 8
3 30
4 50
5 60
输出:90
代码:
#include <cstdio>
#include <algorithm>
using namespace std;
long long dp[100006];
int main() {
int N, W;
scanf("%d%d", &N, &W);
for (int i = 0; i < N; i++) {
int w, v;
scanf("%d%d", &w, &v);
for (int j = W; j >= w; j--) // ① 倒序!
dp[j] = max(dp[j], dp[j - w] + v); // ② 选 or 不选
}
printf("%lld\n", dp[W]);
}
逐行解析:
- ① 从 到 倒序,保证每件物品只被选一次。
- ② 表示”如果选了当前物品,从剩余容量 的最优解加上当前价值”。
例题 2:TB B19 — Knapsack 2( 很大)
题目: 个物品,重量 ,价值 ,背包容量 。求最大总价值。
数据范围:,,
分析:,标准 不可行。但 ,所以总价值最多 。互换重量和价值—— = 达到价值 所需的最小重量。
教材 4.4 节专门讲解了这个技巧。
输入样例:
4 7
3 13
3 17
5 29
1 10
输出:40
代码:
#include <cstdio>
#include <algorithm>
using namespace std;
const long long INF = 1e18;
long long dp[100009]; // dp[j] = 价值 j 时的最小重量
int main() {
int N, W;
scanf("%d%d", &N, &W);
int w[109], v[109];
for (int i = 1; i <= N; i++) scanf("%d%d", &w[i], &v[i]);
// ① 初始化
for (int j = 0; j <= 100000; j++) dp[j] = INF;
dp[0] = 0; // 价值 0 不需要任何重量
// ② DP
for (int i = 1; i <= N; i++)
for (int j = 100000; j >= v[i]; j--)
dp[j] = min(dp[j], dp[j - v[i]] + w[i]); // ③ 互换!
// ④ 找答案
long long ans = 0;
for (int j = 0; j <= 100000; j++)
if (dp[j] <= W) ans = j; // ⑤ 满足容量约束的最大价值
printf("%lld\n", ans);
}
逐行解析:
- ① (价值 0 不需要重量),其余初始化为 INF(不可达)。
- ③ 和标准 0-1 背包结构一样,但 和 互换了位置。
- ⑤ 遍历所有价值,找满足 的最大 。
模拟:样例 1,,物品 。总价值上限 = 。
物品 1 后:。 物品 2 后:,。 物品 3 后:,,,。 物品 4 后:,,,, …
最大 使得 : 时 。✓
例题 3:M&A 096 — Cooking(双炉灶调度)
题目: 道菜,第 道需要时间 。两个炉灶同时工作,每道菜只能在一个炉灶上做。求做完所有菜的最短时间。
数据范围:,
分析:两个炉灶,每道菜分配到其中一个。总时间 = 两个炉灶中较大的那个完成时间。我们希望这个最大值尽可能小。
设总时间 。如果炉灶 A 的工作时间为 ,那炉灶 B 的时间是 。总时间 = 。要让这个值最小, 应尽量接近 。
所以问题转化成:从 个数中选一些,使它们的和尽可能接近 。这就是一个子集和问题——0-1 背包的判定版本。
代码:
#include <cstdio>
#include <algorithm>
using namespace std;
int main() {
int N;
scanf("%d", &N);
int T[109], S = 0;
for (int i = 0; i < N; i++) { scanf("%d", &T[i]); S += T[i]; }
bool dp[100009] = {};
dp[0] = true; // ① 和为 0 恒可达
for (int i = 0; i < N; i++)
for (int j = S; j >= T[i]; j--)
dp[j] = dp[j] || dp[j - T[i]]; // ② bool 版 0-1 背包
int ans = S;
for (int j = 0; j <= S; j++)
if (dp[j]) ans = min(ans, max(j, S - j)); // ③ 取最优分割
printf("%d\n", ans);
}
逐行解析:
- ① :不选任何数,和为 0。
- ② 标准的 0-1 背包转移,但不需要记录价值,只记录”和为 是否可达”。
- ③ 枚举所有可达的和 ,炉灶 A 用 ,炉灶 B 用 ,取两者最大值的最小值。
例题 4:TB A18 — Subset Sum(子集和判定)
题目: 个正整数 ,目标 。能否从中选一些使和恰好为 ?如果能,输出方案。
数据范围:,
分析:0-1 背包的判定版本。 = 考虑前 个数,和为 是否可达。如果还要还原方案,需要额外记录”选择了哪个”。
代码:
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
bool dp[69][10009]; // dp[i][j] = 前 i 个数中和 j 是否可达
int main() {
int N, S;
scanf("%d%d", &N, &S);
int A[69];
for (int i = 1; i <= N; i++) scanf("%d", &A[i]);
dp[0][0] = true; // ① 初始
for (int i = 1; i <= N; i++)
for (int j = 0; j <= S; j++) {
if (j < A[i]) dp[i][j] = dp[i-1][j]; // ② 不够选
else dp[i][j] = dp[i-1][j] || dp[i-1][j-A[i]]; // ③ 选 or 不选
}
if (!dp[N][S]) { printf("-1\n"); return 0; }
// ④ 路径还原
vector<int> ans;
int pos = S;
for (int i = N; i >= 1; i--) {
if (dp[i-1][pos]) continue; // ⑤ 不选 i
ans.push_back(i);
pos -= A[i]; // ⑥ 选了 i
}
reverse(ans.begin(), ans.end());
printf("%d\n", (int)ans.size());
for (int i = 0; i < (int)ans.size(); i++) {
if (i) printf(" ");
printf("%d", ans[i]);
}
printf("\n");
}
逐行解析:
- ②③ 转移:要么不选 (),要么选(),任一可达即可。
- ⑤⑥ 从终点 倒推:如果 为 true,说明不选 也能达到 ;否则一定选了 , 减去 。
模拟:。
| i\j | 0 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| 0 | T | ||||||||
| 1(2) | T | T | |||||||
| 2(2) | T | T | T | ||||||
| 3(3) | T | T | T | T | T | T | |||
| 4(5) | T | T | T | T | T | T | T | T | T |
,还原路径:。选了 1,2,4。✓
例题 5:T90 037 — Don’t Leave the Spice(★5,区间背包)
题目: 种料理,料理 的价值 ,消耗香料 (可以选择范围内任意量)。能否恰好消耗 香料?如果能,求最大总价值。
数据范围:,
分析:这是 0-1 背包的变体——每件物品的重量不是固定值,而是一个区间 。对于每个容量 ,物品 可以贡献 中的任意值。
状态还是 = 消耗恰好 香料时的最大价值。转移时,需要从 转移过来,其中 。
朴素做法对每个 枚举 , 会超时。用**滑动窗口最大值(单调队列)**优化:对于固定的物品 ,按 分组,每组内用单调队列维护窗口 中的最大值。
但更简单的做法是: 在本题数据范围内可以接受( 平均较小)。
代码(朴素版本):
#include <cstdio>
#include <algorithm>
#include <cstring>
using namespace std;
const long long INF = 1e18;
long long dp[10009], ndp[10009];
int main() {
int W, N;
scanf("%d%d", &W, &N);
int L[509], R[509], V[509];
for (int i = 1; i <= N; i++) scanf("%d%d%d", &L[i], &R[i], &V[i]);
for (int j = 0; j <= W; j++) dp[j] = -INF;
dp[0] = 0; // ① 消耗 0 香料,价值 0
for (int i = 1; i <= N; i++) {
for (int j = 0; j <= W; j++) ndp[j] = dp[j]; // ② 不选料理 i
for (int j = L[i]; j <= W; j++) {
// ③ 枚举选了料理 i,消耗 k 香料
for (int k = L[i]; k <= R[i] && j - k >= 0; k++) {
if (dp[j - k] >= 0)
ndp[j] = max(ndp[j], dp[j - k] + V[i]);
}
}
memcpy(dp, ndp, sizeof(dp));
}
printf("%lld\n", dp[W] >= 0 ? dp[W] : -1); // ④ 判断是否可达
}
逐行解析:
- ① :消耗 0 香料的价值是 0,其他状态不可达。
- ②
ndp[j] = dp[j]:不选料理 的方案,价值不变。 - ③ 选料理 ,消耗 香料,从 转移。
- ④ 如果 ,说明恰好消耗 香料不可行,输出 -1。
模拟:,料理 。
选料理 1,3,4,各消耗 (在 内),总价值 。✓
参考文献
教材讲解 — 競技プログラミングの鉄則 第 4 章
基础练习 — アルゴリズムと数学 演習問題集
系统练习 — 競技プログラミングの鉄則
- A18 Subset Sum(子集和判定+还原)【例题】
- A19 Knapsack 1(标准 0-1 背包)
- B18 Subset Sum with Restoration(子集和还原)
- B19 Knapsack 2(价值互换 )【例题】
实战练习 — 競プロ典型 90 問
系列索引
第零章 基础工具
第一章 搜索技术
第二章 数学基础
第三章 数据结构
- 03-01 栈、队列与单调栈
- 03-02 堆与优先队列
- 03-03 并查集
- 03-04 树状数组
- 03-05 线段树
- 03-06 懒标记线段树
- 03-07 Sparse Table 与倍增
- 03-08 字符串哈希
第四章 图论
- 04-01 图的遍历
- 04-02 最短路—Dijkstra 与 01-BFS
- 04-03 最短路—Bellman-Ford 与 Floyd
- 04-04 拓扑排序
- 04-05 最小生成树
- 04-06 强连通分量与 2-SAT
- 04-07 二分图与网络流
- 04-08 树上问题
第五章 动态规划
- 05-01 DP入门—状态与转移
- 05-02 背包问题族
- 05-03 LIS、LCS与编辑距离
- 05-04 区间DP
- 05-05 状态压缩DP
- 05-06 树形DP与数位DP
- 05-07 矩阵快速幂与线性递推
第六章 贪心
第七章 字符串
第八章 进阶