第 3 章用二进制枚举列过「每件拿或不拿」的所有子集; 第 20 章拿 01 背包当靶子,证明了「按性价比排序」的贪心是错的(160 vs 220)。
两次都欠着同一个东西:那正确的解法到底是什么。 这一章把它补上。
而这一章真正的难关不在「写出 DP」—— 二维表照着转移方程填,十分钟就会。 难关在最后那一步压缩:把二维压成一维之后,内层循环必须倒着写。
正着写不报错、不崩溃、不警告,只是安静地给你一个偏大的答案 —— 和第 21 章那个「填错顺序」是同一类毛病。这次我们把它画出来, 而且会证明一件更有意思的事:它不是随机地错,它精确地解了另一道题。
1一句话问题
有 n 件物品,第 i 件价值 v[i]、重量 w[i],每件最多拿一件(不能切开、不能拿两件)。
背包最多装 W 的重量。求能装走的最大总价值。
输入
4 9 6 3 5 4 8 5 2 2
输出
14
第一行是物品数 n = 4 和背包容量 W = 9,之后每行一件物品的「价值 重量」
(第一件价值 6、重 3,依此类推)。
「01」这两个字就是说:每件物品的选择只有 0(不拿)和 1(拿)两种,没有中间状态。 (可以切开的那个版本叫「部分背包」,第 20 章讲过 —— 那题贪心是对的。 一字之差,难度天差地别,原因第 20 章也讲透了:交换论证里「拿出来一点点」这一步做不了。)
2先用手算一遍
容量只有 9,把装得下的组合都列出来:
| 拿哪几件 | 总重 | 总价值 |
|---|---|---|
| ① + ③ | 3 + 5 = 8 | 6 + 8 = 14 |
| ② + ③ | 4 + 5 = 9 | 5 + 8 = 13 |
| ① + ② + ④ | 3 + 4 + 2 = 9 | 6 + 5 + 2 = 13 |
| ① + ② | 3 + 4 = 7 | 6 + 5 = 11 |
| ③ + ④ | 5 + 2 = 7 | 8 + 2 = 10 |
| 只拿 ③ | 5 | 8 |
三件的组合只有 ①②④ 装得下(其它都超 9),四件全拿是 14 更装不下。 所以答案是 14,拿第 ① 和第 ③ 件,总重 8 —— 还空着一格没装满。
最优解并不需要正好装满。这件事在第 12 步会变成一整个坑: 题目一旦改成「必须恰好装满」,同一组数据的答案就从 14 掉到 13。
3暴力:2ⁿ 枚举子集(第 20 章那份,原样搬过来)
// 01 背包 —— 2ⁿ 枚举子集:每件物品「拿 / 不拿」,所有组合都试//// ⚠ **第 23 章也直接用这一份**(那一章 import 的就是这个文件),拿它当 01 背包 DP 的// 对拍标准答案,以及「2ⁿ vs O(nW)」那张耗时表的左半边。// ⇒ 改这份代码要连第 23 章一起看,那边还有一张跟着它跑出来的表。//// 这是这一章第二个对拍的标准答案。它慢,但它绝对不会错 ——// 因为它根本没有「想法」,只是把所有可能都列了一遍(接第 3 章的二进制枚举)。//// 题意:n 件物品,第 i 件价值 v[i]、重量 w[i],每件**最多拿一件**(不能切开)。// 背包最多装 W 的重量。求能装走的最大总价值。//// 输入:第一行 n W,接下来 n 行每行两个数 v w// 输出:最大总价值//// n ≤ 20 左右,再大就跑不完了。真正的解法是第 23 章的 DP,// 但这一章我们只需要它当尺子 —— 用来量贪心到底差多少。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W; if (!(cin >> n >> W)) return 0; vector<long long> v(n), w(n); for (int i = 0; i < n; i++) cin >> v[i] >> w[i];
long long best = 0; for (int mask = 0; mask < (1 << n); mask++) { long long sw = 0, sv = 0; for (int i = 0; i < n; i++) if (mask >> i & 1) { sw += w[i]; sv += v[i]; } if (sw <= W) best = max(best, sv); }
cout << best << "\n"; return 0;}点「运行 ▶」看结果
每件物品「拿或不拿」,2ⁿ 种组合全试一遍,装得下就更新答案。
没有任何想法,所以绝对不会错 —— 这一章后面所有写法都拿它当尺子。
这份代码和它的生成器一个字都不用改就能复用,因为第 20 章打贪心的时候就是用它当标准答案的。 (第 21 章复用第 17 章的暴力,也是同一件事。标准答案和生成器是跨章节资产,别重写。)
4实测:暴力慢在哪
实测(容量固定 W = 1000,只改物品件数 n)。
20 到 27 每一档都在,中间一档都没跳 —— 「每加 1 翻一倍」这句话,只有连着的行才看得出来:
| n | 2ⁿ 枚举子集 | 比上一行 | O(nW) 的 DP |
|---|---|---|---|
| 20 | 0.05 秒 | —— | 量不出来 |
| 21 | 0.11 秒 | ×2.2 | 量不出来 |
| 22 | 0.22 秒 | ×2.0 | 量不出来 |
| 23 | 0.45 秒 | ×2.0 | 量不出来 |
| 24 | 0.93 秒 | ×2.1 | 量不出来 |
| 25 | 1.89 秒 | ×2.0 | 量不出来 |
| 26 | 3.84 秒 | ×2.0 | 量不出来 |
| 27 | 7.86 秒 | ×2.0 | 量不出来 |
(这台机器:i5-13500H / WSL2,2026-08-24,单进程独占,每档跑三次取中位数 —— 三次之间差不到 0.01 秒。换台机器秒数一定会变,但「比上一行」那一列不会。 你自己点上面那个按钮跑出来的数,和这张表对得上的应该是倍数,不是秒数。)
不是谦虚。页面上 DP 那一档的读数是 2~3 毫秒,而一个什么都不做的空程序
(int main(){},同一个按钮)也是 2~3 毫秒 —— 这一列量到的几乎全是「起一个进程」的开销,
n × W = 27000 个格子那点活,秒表这一侧根本看不见。
⇒ 尺子不够用的时候就换一把:数格子、数循环次数、数入队次数 —— 后面每一章都在换这把尺子。 这一档是它彻底失效的样子:读数不为 0,但读到的全不是你要量的东西。
n 每加 1,暴力的时间就翻一倍 —— 「比上一行」那一列七行全是 ×2.0 上下,
一行例外都没有(这就是为什么中间那几档不能跳着量:跳着量只能看出「每加 2 翻四倍」,
翻一倍这句话是补出来的,不是量出来的)。
而 DP 那一列压根没动 —— 因为它的工作量是 n × W 个格子,
n 从 20 涨到 27,格子从 20000 涨到 27000,根本不算涨。
一条是指数,一条是多项式。n = 100 时暴力要 2¹⁰⁰ 步(宇宙年龄不够用),
DP 只要 10 万格,还是眨眼的事。
慢在哪:2ⁿ 个子集里,绝大多数只是「前几件的选择相同、后面不同」的重复劳动。
DP 要做的就是把「前 i 件已经选完之后的局面」归成一类,只算一次。
5★ 关键一步(一):状态,以及那个决定一切的 i−1
沿用第 21、22 章的三件套。状态要能回答「做后面的决定还需要知道什么」, 这题需要知道两件事:还剩几件物品没考虑、背包还能装多少。于是两维:
f[i][j]= 只在前i件物品里挑、总重量不超过j时,能拿到的最大价值
转移用第 21 章那句万能问法 —— 最后一件物品(第 i 件)是拿还是不拿?
只有两种,都试一遍取大的:
f[i][j] = f[i-1][j] // 不拿第 i 件
f[i][j] = max(f[i][j], f[i-1][j - w[i]] + v[i]) // 拿第 i 件(前提 j >= w[i])边界:f[0][j] = 0 —— 一件都不挑,价值当然是 0。这一行不用想,白送的。
顺序:i 从小到大(第 i 行依赖第 i-1 行),j 随便。答案在 f[n][W]。
★ 现在盯住转移右边那两个式子:它们的第一维都是 i-1,一个 i 都没有。
这不是巧合,是「每件最多拿一件」这句题意的全部化身:
第 i 件物品只能从还没考虑过它的局面上叠加。
一旦右边出现 f[i][...],那就是从「已经考虑过第 i 件」的局面再加一件第 i 件 —— 它就被拿了两次。
这一句是整章的地基。后面所有的坑,坑底都是它。
// 01 背包 —— 二维 DP,最老实的写法//// 为什么先写这一份:一维那份(fast.cpp)是从它压出来的。// 直接背一维的三行代码,你会背错倒序;但如果先把二维写明白,// 倒序就不是「规定」,而是压缩之后的必然结果。**先二维,再压。**//// 状态:f[i][j] = 只在前 i 件物品里挑,背包容量恰好不超过 j 时的最大价值// 转移:第 i 件物品只有两种命运 ——// 不拿:f[i][j] = f[i-1][j]// 拿 :f[i][j] = f[i-1][j - w[i]] + v[i] (前提 j >= w[i])// 取两者的较大值。// 边界:f[0][j] = 0(一件都不挑,价值 0)// 答案:f[n][W]//// ★ 注意转移右边两个式子的第一维**都是 i-1**。// 这是这一章后面所有事情的根源:**第 i 件物品只能从「还没考虑它」的那一行取值**,// 否则它就可能被拿第二次。//// 输入:第一行 n W,接下来 n 行每行两个数 v w(和第 20 章 knapBrute.cpp 完全一致,方便对拍)// 输出:最大总价值//// 复杂度 O(nW) 时间、O(nW) 空间。n = 100、W = 10000 时是 100 万格,随便跑;// 但 n = 1000、W = 10^5 就是 1 亿格 × 8 字节 = 800 MB,**空间先炸** —— 这就是要压成一维的原因。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W; if (!(cin >> n >> W)) return 0; vector<long long> v(n + 1), w(n + 1); for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
// f[i][j],下标从 1 开始(第 0 行全是 0,就是「一件都不挑」) vector<vector<long long>> f(n + 1, vector<long long>(W + 1, 0));
for (int i = 1; i <= n; i++) { for (long long j = 0; j <= W; j++) { f[i][j] = f[i - 1][j]; // 不拿第 i 件 if (j >= w[i]) // 拿得下才谈「拿」 f[i][j] = max(f[i][j], f[i - 1][j - w[i]] + v[i]); // ★ 右边是 i-1 行 } }
cout << f[n][W] << "\n"; return 0;}点「运行 ▶」看结果
6动画:二维表是怎么填出来的
每填一格,画面会同时高亮它的两个来源:正上方(不拿)和左上方 j−w(拿)。
看两遍,把「两个来源都在上一行」这件事看进眼睛里 —— 下一步就不用背口诀了。
动画最后几步是倒着走一遍还原方案:从 f[n][W] 出发,
和正上方一样就是「没拿」,不一样就是「拿了」,往左上跳 w[i] 格。默认数据上走出来是第 1、3 件,总重 8。
7第一刀:只留两行(滚动数组)
二维表有个现实问题:n = 1000、W = 10⁵ 时是 1 亿格 × 8 字节 = 800 MB,
空间先炸,跟时间没关系。
但看一眼转移就会发现:f[i][*] 只用到 f[i-1][*],再往前的行一辈子用不着了。
那留着 n+1 行干什么?留两行,轮流当「上一行」和「这一行」:
cur = i & 1,pre = cur ^ 1,转移一个字没改。这里 j 正着倒着都行 ——
因为 cur 和 pre 是两块不同的内存,写 cur 的时候 pre 那行是完整的、没被这轮碰过的上一行。
请把这句话记住一秒钟,因为下一刀砍掉的正是它。
8★ 关键一步(二):第二刀砍掉之后,倒序是唯一的活路
两行也别留了。反正每一格只是「拿自己和 j−w 那格比一比」,就在一行上原地改:
for (int i = 1; i <= n; i++)
for (long long j = W; j >= w[i]; j--) // ★ 倒着!
f[j] = max(f[j], f[j - w[i]] + v[i]);★ 为什么必须倒着。现在上一行和这一行挤在同一块内存里了,
所以每次读 f[j - w[i]] 都要问一句:这一格现在是「上一行的值」还是「这一行的值」?
j - w[i] 比 j 小,所以答案完全取决于扫描方向:
| 方向 | 比 j 小的格子这轮…… | 读到的是 | 相当于二维的 |
|---|---|---|---|
倒序 j = W → w[i] |
还没轮到 | 上一行的值 ✓ | f[i-1][j-w] ✓ |
正序 j = w[i] → W |
刚刚被改过 | 这一行的值 ✗ | f[i][j-w] ✗ |
而 f[i][j-w] 里可能已经装了第 i 件物品 —— 再加一件,它就被拿了第二次、第三次……
倒序不是规定,是「不许出现 f[i][...]」这条铁律在一维下的唯一实现方式。
你现在不需要背它,只需要记得第 5 步那句:转移右边的第一维必须是 i−1。
// 01 背包 —— 一维倒序,竞赛里就写这三行//// 从 roll.cpp 再砍一刀:两行也别留了,只留一行,原地改。//// for (int i = 1; i <= n; i++)// for (long long j = W; j >= w[i]; j--)// f[j] = max(f[j], f[j - w[i]] + v[i]);//// ★★ 这一章的关键一步:**第二层循环必须倒着写。**//// 为什么。把一维的 f 想成「上一行和这一行挤在同一块内存里」:// 当你正在算 f[j] 的时候,f[j - w[i]] 这一格到底是「上一行的」还是「这一行的」?//// 倒序(j 从大到小):j - w[i] < j,而比 j 小的格子**这一轮还没轮到**,// 所以读到的一定是上一行的值 —— 和 dp2.cpp 的 f[i-1][j-w[i]] 一模一样。✓// 正序(j 从小到大):j - w[i] < j,而比 j 小的格子**这一轮刚刚被改过**,// 读到的是这一行的值 f[i][j-w[i]] —— 那一格里可能已经装了第 i 件物品,// 于是第 i 件物品被拿了第二次、第三次…… ✗//// 而且正序**不会报错、不会崩、不会警告**,只是安静地给你一个偏大的答案。// (它其实精确地解了另一道题 —— 见 complete.cpp。)//// 另外两个细节:// ① 循环写成 `j >= w[i]` 而不是 `j >= 0`,省掉了 j < w[i] 时的判断(那些格子必然维持原值)。// ② f 一定要开在循环外面并且**只初始化一次**:f[j] 的含义是「前 i 件物品、容量 j」,// 每一轮是在上一轮的基础上继续,不是重来。//// 输入输出同 dp2.cpp。复杂度 O(nW) 时间、O(W) 空间。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W; if (!(cin >> n >> W)) return 0; vector<long long> v(n + 1), w(n + 1); for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
vector<long long> f(W + 1, 0);
for (int i = 1; i <= n; i++) for (long long j = W; j >= w[i]; j--) // ★ 倒序!正着写就是完全背包 f[j] = max(f[j], f[j - w[i]] + v[i]);
cout << f[W] << "\n"; return 0;}点「运行 ▶」看结果
9动画:把「同一件物品被拿了三次」直接画出来
下拉框可以切换倒序 / 正序。盯住两样东西:
- 被读的那一格是什么颜色:黄色 = 这轮还没动过(正常),红色 = 这轮刚被改过(出事了);
- 每一格下面那排圆点:这一格里装了几件当前这一轮的物品。一个点正常,两个点以上就是重复拿。
倒序那边圆点永远不超过一个,红色一次都不会出现。
正序那边处理第一件物品时就已经出事:一件价值 6、重 3 的东西,在 f[9] 里凑出了 18(3 × 6)。
10逐行看:trace 把两种方向并排打出来
// 把一维数组的每一步都打出来:倒序 vs 正序,逐行对照//// 为什么要有这份代码:光看最终答案「14 和 18」,你只知道正序错了,不知道它错在哪一格。// 这份代码把每处理完一件物品之后的整个 f 数组打出来,// 两边并排一看就明白:**正序那边,某些格子在同一轮里被同一件物品填了不止一次。**//// 默认数据(也是正文和动画用的那组):// n = 4,W = 9,物品 (v,w) = (6,3) (5,4) (8,5) (2,2)// 正确答案 14(拿第 1、3 件,重量 3+5=8)// 正序答案 18(第 1 件被拿了三次:3×3=9 正好装满,3×6=18)//// 注意看正序那边第一行就已经错了:只处理了第一件物品,f[9] 就变成了 18。// 一件物品,价值 6,却贡献了 18 —— 拿了三次。//// 输出的每一行是 f[0..W]。check-viz 会拿它和动画里的数组**逐行**比对,// 不只比最后那个答案(否则「答案对了但中间过程画的是另一回事」就查不出来)。//// 输入:同 dp2.cpp(不给输入就用上面那组默认数据)
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W; vector<long long> v, w;
if (cin >> n >> W) { v.resize(n + 1); w.resize(n + 1); for (int i = 1; i <= n; i++) cin >> v[i] >> w[i]; } else { n = 4; W = 9; v = {0, 6, 5, 8, 2}; w = {0, 3, 4, 5, 2}; }
cout << "物品:"; for (int i = 1; i <= n; i++) cout << "(v=" << v[i] << " w=" << w[i] << ") "; cout << " 容量 W = " << W << "\n";
for (int pass = 0; pass < 2; pass++) { bool down = (pass == 0); cout << "\n" << (down ? "倒序(正确)" : "正序(错误)") << "\n";
vector<long long> f(W + 1, 0); cout << "初始 :"; for (long long j = 0; j <= W; j++) cout << f[j] << " \n"[j == W];
for (int i = 1; i <= n; i++) { if (down) for (long long j = W; j >= w[i]; j--) f[j] = max(f[j], f[j - w[i]] + v[i]); else for (long long j = w[i]; j <= W; j++) f[j] = max(f[j], f[j - w[i]] + v[i]);
cout << "第 " << i << " 件后:"; for (long long j = 0; j <= W; j++) cout << f[j] << " \n"[j == W]; } cout << "答案 = " << f[W] << "\n"; } return 0;}点「运行 ▶」看结果
不给输入就用默认那组数据。输出长这样:
倒序(正确) 正序(错误)
初始 :0 0 0 0 0 0 0 0 0 0 初始 :0 0 0 0 0 0 0 0 0 0
第 1 件后:0 0 0 6 6 6 6 6 6 6 第 1 件后:0 0 0 6 6 6 12 12 12 18 ← 已经错了
第 2 件后:0 0 0 6 6 6 6 11 11 11 第 2 件后:0 0 0 6 6 6 12 12 12 18
第 3 件后:0 0 0 6 6 8 8 11 14 14 第 3 件后:0 0 0 6 6 8 12 12 14 18
第 4 件后:0 0 2 6 6 8 8 11 14 14 第 4 件后:0 0 2 6 6 8 12 12 14 18
答案 = 14 答案 = 18
正序那边第一行就已经错了:只处理了一件物品,f[6] 就是 12、f[9] 就是 18。
这就是为什么 check:viz 对这份代码验的是每一行,而不只是最后的答案 ——
「答案蒙对了但中间过程早就错了」这种情况,只比答案是查不出来的。
(这也是本站所有动画的规矩:连画面上的计数器一起比。)
11★ 正序不是「随机地错」—— 它精确地解了另一道题
把 fast.cpp 的倒序改成正序,你得到的不是垃圾,而是一份完全正确的完全背包代码
(完全背包 = 每件物品有无限多件,第 24 章的内容)。
// 01 背包(每件最多一件)
for (long long j = W; j >= w[i]; j--) f[j] = max(f[j], f[j - w[i]] + v[i]);
// 完全背包(每件无限件)
for (long long j = w[i]; j <= W; j++) f[j] = max(f[j], f[j - w[i]] + v[i]);两份代码逐字符对比,唯一的区别就是 j 的方向。
道理是同一个,只是这回反过来用:正序时 f[j-w] 是本轮已经更新过的值,
也就是「已经考虑过第 i 件」之后的最优解 —— 在它上面再叠一件第 i 件,
正是完全背包想要的「这件还能再拿」。
check:viz 把这件事钉死了:用第 20 章的生成器造 300 组数据,
wrong.cpp 和 complete.cpp 的输出 300 组一模一样,一组不差。
初学者在二维表里最常见的手滑,是把转移右边写成 f[i][j - w[i]](少打了个 -1)。
它和一维正序是同一个 bug,而且不是「差不多」,是一模一样: 拿同样 300 组数据跑,这两份错误代码的输出 300 组完全相同。
所以「倒序」和「右边要写 i-1」根本是一句话的两种说法。记住一句就够了。
12⚠ 另一个坑:「恰好装满」只改初始化
题目改一个字:要求把背包正好装满(装不满输出 -1),求最大价值。
转移方程一个字都不用改。要改的只有 f 的初值:
| 题目要求 | 初值 | 为什么 |
|---|---|---|
| 不要求装满 | 全部 f[j] = 0 |
「容量 j,什么都不装」是合法状态,价值 0 |
| 必须装满 | f[0] = 0,其余 f[j] = -∞ |
「容量 j 正好装满」在还没放东西时根本不存在 |
// 「恰好装满」的变体 —— 01 背包排第二的坑,而且它只藏在**初始化**里//// 题目一改成「必须把背包**正好**装满,求最大价值(装不满输出 -1)」,// 转移方程一个字都不用改,要改的只有 f 的初值://// 不要求装满:f[j] = 0 —— 「容量 j,什么都不装」是一个合法状态,价值 0// 要求装满 :f[0] = 0,其余 f[j] = -∞// —— 「容量 j 正好装满」在还没放东西时**根本不存在**,// 用 -∞ 表示「这个状态不可达」//// ★ 一句话记法:**初值是在回答「这个状态一开始存不存在」。**// 转移只会从存在的状态转出去(-∞ 加多少还是负的,永远抢不过别人),// 于是不可达就自动传播下去了。// 同一个套路还会用在「方案数」(初值 f[0]=1 其余 0)、「最小价值」(初值 +∞)上。//// 这份代码同时用两种完全不同的思路算这道题,自己跟自己对拍:// ① 2ⁿ 枚举子集(n ≤ 20),挑出重量正好等于 W 的组合// ② DP,初值 -∞// check-viz 会验这两行 300 组数据一模一样 —— **不同思路才能验出想法错误**(第 20 章那条规矩)。//// 默认数据上有个刚好能说明问题的巧合:// n=4 W=9 物品 (6,3)(5,4)(8,5)(2,2) → 不要求装满是 14,要求正好装满只有 13。// 14 那个方案(第 1、3 件)重量是 8,差一格没装满。//// 输入输出:输入同 dp2.cpp;输出两行,分别是两种思路算出的答案(装不满都输出 -1)
#include <bits/stdc++.h>using namespace std;
const long long NEG = LLONG_MIN / 4; // 不用 LLONG_MIN,否则加 v[i] 会溢出
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W; if (!(cin >> n >> W)) return 0; vector<long long> v(n), w(n); for (int i = 0; i < n; i++) cin >> v[i] >> w[i];
/* ① 2ⁿ 枚举子集:只看重量正好 = W 的那些组合 */ long long bruteBest = -1; if (n <= 20) { for (int mask = 0; mask < (1 << n); mask++) { long long sw = 0, sv = 0; for (int i = 0; i < n; i++) if (mask >> i & 1) { sw += w[i]; sv += v[i]; } if (sw == W) bruteBest = max(bruteBest, sv); } }
/* ② DP:转移和 fast.cpp 一字不差,只有初值不同 */ vector<long long> f(W + 1, NEG); f[0] = 0; // ★ 唯一一个「一开始就存在」的状态 for (int i = 0; i < n; i++) for (long long j = W; j >= w[i]; j--) if (f[j - w[i]] > NEG) // 不可达的状态不往外转移 f[j] = max(f[j], f[j - w[i]] + v[i]); long long dpBest = (f[W] > NEG) ? f[W] : -1;
cout << "2ⁿ 枚举(恰好装满):" << bruteBest << "\n"; cout << "DP(f 初值 -∞) :" << dpBest << "\n"; return 0;}点「运行 ▶」看结果
同一组默认数据:不要求装满是 14,要求正好装满只有 13。 因为 14 那个方案(第 1、3 件)重量是 8,差一格没装满 —— 就是第 2 步让你记住的那件事。
-∞ 不是什么魔法数字,它的意思是「不可达」。
转移只会从存在的状态转出去(-∞ 再加多少还是极负,永远抢不过别人),
于是「不可达」这个性质就自动一路传播下去了,最后 f[W] 还是 -∞ 就说明装不满。
同一个套路还会反复出现:
求方案数初值 f[0] = 1 其余 0;求最小价值初值 f[0] = 0 其余 +∞。
转移方程管的是「怎么算」,初值管的是「从哪儿开始、哪些地方压根没有」。
⚠ 实现细节:-∞ 别真写 LLONG_MIN,加上 v[i] 会溢出。写 LLONG_MIN / 4 之类留出余量,
或者干脆判一句「来源不可达就不转移」(exact.cpp 两样都做了)。
exact.cpp 用两种完全不同的思路算同一道题:2ⁿ 枚举出所有重量正好 = W 的组合,
和初值 -∞ 的 DP。check:viz 验这两行 300 组一致。
这是第 20 章立的规矩:标准答案要用完全不同的思路写,同一个思路写两遍只能验出打字错误。 顺带一个实测数字:300 组里有 96 组「恰好装满」和「不限装满」答案不同, 另有 39 组根本装不满 —— 这个坑在随机数据上出现得非常频繁,别指望蒙混过关。
13要输出「拿了哪几件」怎么办
// 01 背包 —— 不只要最大价值,还要说出**到底拿了哪几件**//// ★ 用的是第 22 章 path.cpp 那个通用套路的背包版,但有一处不同,值得单独讲://// LIS 那题记的是 pre[i](从哪个 j 转移来的);// 背包这里**什么都不用记** —— 因为二维表本身就把历史留住了。// 从 f[n][W] 出发倒着走:// f[i][j] == f[i-1][j] → 第 i 件没拿,跳到 (i-1, j)// 否则 → 第 i 件拿了,跳到 (i-1, j - w[i])//// ⚠ 而这正是「要输出方案就不能用一维」的原因:// 一维数组把中间过程全覆盖掉了,没有历史可以回溯。// **要方案 → 老老实实开二维表。** 这是所有 DP 的通用规律,不是背包特有的。//// 相同价值的方案可能有好几种,这里给的是「上面那条判断优先算作没拿」得到的那一条。//// 输出:第一行最大价值,第二行选中的物品编号(1 基,升序),第三行它们的总重量//// check-viz 对这份代码做的是**硬验证**:// ① 编号不重复、都在 1..n 范围内// ② 总重量 ≤ W// ③ 价值之和正好等于第一行那个最大价值// 只验第一行的话,「价值对了但方案是编的」这种 bug 是抓不出来的。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W; if (!(cin >> n >> W)) return 0; vector<long long> v(n + 1), w(n + 1); for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
vector<vector<long long>> f(n + 1, vector<long long>(W + 1, 0)); for (int i = 1; i <= n; i++) for (long long j = 0; j <= W; j++) { f[i][j] = f[i - 1][j]; if (j >= w[i]) f[i][j] = max(f[i][j], f[i - 1][j - w[i]] + v[i]); }
vector<int> take; long long j = W, sumW = 0; for (int i = n; i >= 1; i--) { if (f[i][j] == f[i - 1][j]) continue; // 这一件没拿(并列时算作没拿) take.push_back(i); sumW += w[i]; j -= w[i]; } reverse(take.begin(), take.end()); // 倒着走出来的,翻回去
cout << f[n][W] << "\n"; for (size_t k = 0; k < take.size(); k++) cout << take[k] << " \n"[k + 1 == take.size()]; if (take.empty()) cout << "\n"; // 一件都没拿也要占一行,方便解析 cout << sumW << "\n"; return 0;}点「运行 ▶」看结果
第 22 章那个「记 pre 再回溯」的通用套路,在背包这里有个更省事的版本:
什么都不用记,因为二维表本身就是历史。从 f[n][W] 倒着走:
f[i][j] == f[i-1][j]→ 第i件没拿,跳到(i-1, j)- 否则 → 第
i件拿了,跳到(i-1, j - w[i])
而这正好点破了一维写法的代价:它把中间过程全覆盖掉了,没有历史可以回溯。
要最优值 → 一维;要方案 → 老老实实开二维表。 这条规律对所有 DP 都成立,不是背包特有的。
check:viz 对这份代码做的是硬验证:选出的编号不重不越界、总重量 ≤ W、
而且价值之和正好等于第一行那个最大值。只验第一行的话,
「价值对了但方案是编的」这种 bug 一样抓不出来。
14顺带一测:一维到底省了多少空间
n = 500、W = 20000(一千万格)时,三种写法的峰值内存
(/usr/bin/time -v 量的;数据是 ./genBig 500 20000 造的,种子固定,你可以原样复现):
| 写法 | 峰值内存 | 答案 |
|---|---|---|
二维 f[n+1][W+1] |
84 312 KB(约 82 MB) | 181948 |
| 滚动两行 | 4 384 KB | 181948 |
| 一维 | 4 312 KB | 181948 |
把 DP 数组整个删掉、只留读入,峰值还是 4 176 KB —— 也就是说后两行量到的
主要是「一个用了 cin 和 vector 的进程」本身,数组在这个底噪里几乎看不见。
真正的数组是算得出来的:一维 (W+1) × 8 字节 = 160 KB,滚动两行就是两倍 = 320 KB。
⇒ 这张表能说明的是「二维那 80 MB 没了」,不能用来说「一维又比滚动省了一半」——
后面这句要算,不要量。(又一次:秒表和内存表都有量不到的东西,尺子得挑着用。)
答案完全一样,空间差 20 倍。竞赛的内存限制通常是 128 MB 或 256 MB ——
n = 1000、W = 10⁵ 时二维要 800 MB,这不是「优化」,是能不能交题的问题。
15★ 对拍
// 01 背包 —— 一维倒序,竞赛里就写这三行//// 从 roll.cpp 再砍一刀:两行也别留了,只留一行,原地改。//// for (int i = 1; i <= n; i++)// for (long long j = W; j >= w[i]; j--)// f[j] = max(f[j], f[j - w[i]] + v[i]);//// ★★ 这一章的关键一步:**第二层循环必须倒着写。**//// 为什么。把一维的 f 想成「上一行和这一行挤在同一块内存里」:// 当你正在算 f[j] 的时候,f[j - w[i]] 这一格到底是「上一行的」还是「这一行的」?//// 倒序(j 从大到小):j - w[i] < j,而比 j 小的格子**这一轮还没轮到**,// 所以读到的一定是上一行的值 —— 和 dp2.cpp 的 f[i-1][j-w[i]] 一模一样。✓// 正序(j 从小到大):j - w[i] < j,而比 j 小的格子**这一轮刚刚被改过**,// 读到的是这一行的值 f[i][j-w[i]] —— 那一格里可能已经装了第 i 件物品,// 于是第 i 件物品被拿了第二次、第三次…… ✗//// 而且正序**不会报错、不会崩、不会警告**,只是安静地给你一个偏大的答案。// (它其实精确地解了另一道题 —— 见 complete.cpp。)//// 另外两个细节:// ① 循环写成 `j >= w[i]` 而不是 `j >= 0`,省掉了 j < w[i] 时的判断(那些格子必然维持原值)。// ② f 一定要开在循环外面并且**只初始化一次**:f[j] 的含义是「前 i 件物品、容量 j」,// 每一轮是在上一轮的基础上继续,不是重来。//// 输入输出同 dp2.cpp。复杂度 O(nW) 时间、O(W) 空间。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; long long W; if (!(cin >> n >> W)) return 0; vector<long long> v(n + 1), w(n + 1); for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
vector<long long> f(W + 1, 0);
for (int i = 1; i <= n; i++) for (long long j = W; j >= w[i]; j--) // ★ 倒序!正着写就是完全背包 f[j] = max(f[j], f[j - w[i]] + v[i]);
cout << f[W] << "\n"; return 0;}把右边换成你自己写的,或者换成下面这些故意写错的版本。300 轮实测,每一种都被抓住了:
| 故意写错的地方 | 300 轮里被抓 | 第几轮首次被抓 |
|---|---|---|
一维正序(j = w[i] → W) |
251 轮 | 第 2 轮 |
二维转移右边写成 f[i][j-w[i]] |
251 轮 | 第 2 轮 |
每处理一件物品就把 f 清零 |
290 轮 | 第 1 轮 |
内层循环写成 j > w[i](差一,漏掉正好用完的那格) |
200 轮 | 第 1 轮 |
| 「恰好装满」却把初值全写成 0 | 96 轮 | 第 2 轮 |
第 1 轮就是蒙对的一轮 —— 那组数据只有 2 件物品、容量 2, 唯一装得下的那件恰好塞满,想拿第二件也没地方,正序和倒序自然一样。
所有 w[i] 都大于 W/2 的时候,正序和倒序结果必然相同(拿两件根本装不下)。
你要是随手编几组「东西很重、包很小」的数据自测,会全部通过。
这正是第 20 章那张「300 轮里错了 34 / 151 / 37 轮」的表想说的事: 九成场合都对的错误代码,比一眼就崩的错误代码危险得多。
16这一章可以带走的四样东西
【1】转移右边的第一维必须是 i−1。
这是「每件最多拿一件」唯一的技术含义。一维倒序、二维不能写 f[i][j-w],
都是它的推论。记这一条,别记两条口诀。
【2】先写二维,再压。 直接背一维那三行,你迟早会背错方向;从二维推下来,倒序就是必然结果而不是规定。 考场上写不确定的时候,就在草稿纸上把二维转移写出来,方向自己会跳出来。
【3】「安静地给你错答案」是 DP 最典型的失败方式。 第 21 章填错顺序如此,这一章循环写反也如此:不报错、不崩溃、还经常蒙对。 唯一靠得住的防线是对拍,而且生成器要让「错误直觉」必定失败 —— 这里靠的是「容量小、重量和容量同一量级」。
【4】转移管「怎么算」,初值管「从哪儿开始、哪些状态压根不存在」。
恰好装满用 -∞、方案数用 1、最小值用 +∞ —— 同一个套路,后面每一章都会再用。
第 24 章:完全背包与多重背包。
★ 关键一步这次是反过来的:完全背包就是要正序 —— 你在这一章亲手确认过的那个「bug」,到那里是唯一正确的写法。 两章要并排着看,才能真正明白方向的含义。
多重背包(每件有 k 个)则会引出一个漂亮的技巧:二进制拆分,
它跟第 3 章的二进制枚举、第 28 章的状压是同一族的东西。
17自测
- 洛谷 P1048 采药解析 → —— NOIP2005。最标准的 01 背包模板题,时间就是重量、价值就是价值。先交二维再交一维,对比一下内存
- 洛谷 P1049 装箱问题解析 → —— NOIP2001。只有体积没有价值 —— 把体积同时当价值就行。这一步转化是背包题的第一道门槛
- 洛谷 P1164 小A点菜解析 → —— 求「恰好花完」的方案数。本章第 12 步那个套路的直接应用:初值 f[0] = 1,其余 0,转移从 max 换成加法
- 洛谷 P1060 开心的金明解析 → —— NOIP2006。价值 = 价格 × 重要度,读懂题就是模板。适合用来确认自己是真的会了
- 洛谷 P2925 干草出售解析 → —— USACO。「恰好装满」的味道,而且数据范围大到必须用一维 —— 二维会 MLE
- 洛谷 P1877 音量调节解析 → —— 状态是「能不能达到某个音量」(布尔背包)。转移变成 f[j] |= f[j-w],倒序的道理一模一样