阶段 5 · 动态规划 · 第 23 章普及组 J

01 背包

阶段 5 的重头戏。二维表谁都会填,这一章真正要过的关是:压成一维之后,循环为什么必须倒着写。

这一章你已经见过两次了

第 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 章那份,原样搬过来)

knapBrute.cpp(第 20 章)2ⁿ 枚举子集
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

每件物品「拿或不拿」,2ⁿ 种组合全试一遍,装得下就更新答案。 没有任何想法,所以绝对不会错 —— 这一章后面所有写法都拿它当尺子。

这份代码和它的生成器一个字都不用改就能复用,因为第 20 章打贪心的时候就是用它当标准答案的。 (第 21 章复用第 17 章的暴力,也是同一件事。标准答案和生成器是跨章节资产,别重写。)

4实测:暴力慢在哪

同题对比:2ⁿ 枚举子集 vs O(nW) 的 DP
先跑 22,再改成 25、27。⚠ 容量 W 固定 1000 不跟着变,这样变的只有 n。别超过 28。
2ⁿ 枚举子集
O(nW) 的 DP

实测(容量固定 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 那一列为什么写「量不出来」

不是谦虚。页面上 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 件 —— 它就被拿了两次。

这一句是整章的地基。后面所有的坑,坑底都是它。

dp2.cpp二维 DP(先写这个)
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

6动画:二维表是怎么填出来的

二维表:每一格的两个来源都在上一行
答案 14
第 1 / 46 步
0
1
2
3
4
5
6
7
8
9
不挑
0
0
0
0
0
0
0
0
0
0
1: v6 w3
·
·
·
·
·
·
·
·
·
·
2: v5 w4
·
·
·
·
·
·
·
·
·
·
3: v8 w5
·
·
·
·
·
·
·
·
·
·
4: v2 w2
·
·
·
·
·
·
·
·
·
·
f[n][W]
…
回溯出的方案
…
总重量 / 容量
…
蓝色 = 正在填的 f[i][j],灰边 = 「不拿」的来源(正上方), 绿色 = 「拿」的来源(左上方 j−w 那一格,填实心表示这一格最后选了「拿」)。 两个来源**都在上一行** —— 记住这件事,下一个动画的倒序就不用背了。 最后那几步是倒着回溯方案,绿色连成的就是走过的路。
f[i][j] = 只在前 i 件物品里挑、容量不超过 j 时的最大价值。第 0 行是「一件都不挑」,全是 0 —— 这就是边界,不用想。

每填一格,画面会同时高亮它的两个来源:正上方(不拿)和左上方 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 行干什么?留两行,轮流当「上一行」和「这一行」:

roll.cpp滚动数组(两行)

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。

fast.cpp一维倒序(竞赛里就写这三行)
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

9动画:把「同一件物品被拿了三次」直接画出来

★ 一维数组:倒序读到的是上一行,正序读到的是这一行
第 1 / 32 步
物品:1(v6 w3) 2(v5 w4) 3(v8 w5) 4(v2 w2)
0
1
2
3
4
5
6
7
8
9
0
0
0
0
0
0
0
0
0
0
·
·
·
·
·
·
·
·
·
·
这种写法给出
0
正确答案(2ⁿ 枚举)
14
同一件物品最多被拿
0 次
蓝色 = 正在算的 f[j],黄色 = 它读的那一格 f[j−w],红色 = 它读的那一格本轮已经被改过(只有正序才会出现)。 下面那排圆点是「这一格里装了几件当前这轮的物品」:一个点正常, 两个点以上就是同一件东西被拿了不止一次。倒序那边这排点永远不超过一个。
一维 f[j] = 容量 j 时的最大价值。正序:j 从小到大 —— 盯住它读的那一格是什么颜色。

下拉框可以切换倒序 / 正序。盯住两样东西:

  • 被读的那一格是什么颜色:黄色 = 这轮还没动过(正常),红色 = 这轮刚被改过(出事了);
  • 每一格下面那排圆点:这一格里装了几件当前这一轮的物品。一个点正常,两个点以上就是重复拿。

倒序那边圆点永远不超过一个,红色一次都不会出现。 正序那边处理第一件物品时就已经出事:一件价值 6、重 3 的东西,在 f[9] 里凑出了 18(3 × 6)。

10逐行看:trace 把两种方向并排打出来

trace.cpp每处理完一件物品就打印整个 f
// 把一维数组的每一步都打出来:倒序 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

不给输入就用默认那组数据。输出长这样:

倒序(正确)                       正序(错误)
初始    :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 组一模一样,一组不差。

complete.cpp完全背包(正序就是它)
顺带解决一个二维写法里的等价错误

初学者在二维表里最常见的手滑,是把转移右边写成 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 正好装满」在还没放东西时根本不存在
exact.cpp两种思路并排算「恰好装满」
// 「恰好装满」的变体 —— 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

同一组默认数据:不要求装满是 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要输出「拿了哪几件」怎么办

path.cpp连方案一起还原
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 要方案 → 就不能用一维

第 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★ 对拍

对拍器
生成器是第 20 章打贪心时写的那个,一个字没改:容量小、重量和容量同一量级 —— 这样「装不下」才是常态,各种写错才会露出来。
// 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 轮
wrong.cpp✗ 故意写错:正序
⚠ 注意它错得有多温柔:300 轮里有 49 轮蒙对了

第 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自测

自测清单0 / 10
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)