题单 · 习题解析

洛谷 P1049 [NOIP 2001 普及组] 装箱问题

★★★ 同一个旋钮(体积上限)把两个 bug 推向相反方向:3 ⇒ 贪心 0 / 正序 300,39 ⇒ 贪心 124;而那两个 0 一个是概率低、一个是 12 × 3 = 36 < 40 算出来的

原题:洛谷 P1049出自 第 23 章 01 背包 的题单题面本地存档:2026-08-29
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1049,日期见页头。两边不一致时信原站。

题目描述

有一个箱子容量为 V,同时有 n 个物品,每个物品有一个体积。

现在从 n 个物品中,任取若干个装入箱内(也可以不取),使箱子的剩余空间最小。输出这个最小值。

输入格式

第一行共一个整数 V,表示箱子容量。

第二行共一个整数 n,表示物品总数。

接下来 n 行,每行有一个正整数,表示第 i 个物品的体积。

输出格式

共一行一个整数,表示箱子最小剩余空间。

数据规模与约定

对于 100% 的数据,满足 0 < n ≤ 301 ≤ V ≤ 20000

【题目来源】 NOIP 2001 普及组第四题

输入输出样例

输入

24
6
8
3
12
7
9
7

输出

0

箱子容量 24,六件物品 8 3 12 7 9 7 —— 有办法正好装满(8 + 9 + 7 = 24),所以剩余 0。

⚠ 这一组样例放过了本页三个错法里的两个(第 ① 步和第 ⑤ 步各一个), 只挡住了最显眼的那一个(第 ⑥ 步)。

★★ 这道题的第一道门槛不在算法,在读题

题单里给它写的提示是「只有体积没有价值 —— 把体积同时当价值就行」。 这一步转化是背包题的第一道门槛,值得说透:

01 背包的模板要两样东西 —— 每件物品的体积价值。 而这道题只给了体积。缺的那一样从哪儿来?

要「剩余空间最小」,就是要「装进去的体积之和最大」。 而「装进去的体积之和」正好就是「选中物品的体积和」。 ⇒ 于是 价值 = 体积,跑一遍标准 01 背包,答案是 V - f[V]

⚠ 而这一页真正想讲的不是这一步(它两行就说完了),是后面那三件事: 样例放过了什么、暴力凭什么看着能过、以及同一个旋钮怎么把两个 bug 推向相反的方向。

1第一版:先拿大的(而官方样例放过了它)

大多数人的第一反应是贪心:体积从大到小排序,能塞就塞

p1049Greedy.cpp错法一:先拿大的
// P1049 错法一:贪心 —— 体积从大到小,能塞就塞
//
// 这是大多数人的第一反应,而且**官方样例放过了它**:
// V = 24,物品 8 3 12 7 9 7 ⇒ 从大到小是 12 9 8 7 7 3,
// 塞 12(剩 12)、塞 9(剩 3)、8 塞不下、7 塞不下、7 塞不下、塞 3(剩 0)⇒ 输出 0,和正解一样。
//
// ⚠ 而它是错的,最小的反例只要三件物品:
// V = 10,物品 6 5 5 ⇒ 贪心先拿 6,剩 4,两个 5 都塞不下 ⇒ 剩 4
// 正解 5 + 5 = 10 ⇒ 剩 0
//
// ★ 「先拿大的」错在哪:它把「装得尽量满」偷换成了「先把大的安顿好」,
// 而背包问题里**一件物品该不该拿,取决于剩下的空间还能不能被别的物品配平** ——
// 这正是第 20 章那一整章在讲的事(贪心要证,不能猜)。
#include <bits/stdc++.h>
using namespace std;
int main() {
int V, n;
if (!(cin >> V >> n)) return 0;
vector<int> v(n);
for (int& x : v) cin >> x;
sort(v.begin(), v.end(), greater<int>());
int left = V;
for (int x : v)
if (x <= left) left -= x; // 能塞就塞
cout << left << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它在官方样例上是对的12 → 9 → 3 正好凑满 24,输出 0,和正解一模一样。

而最小的反例只要三件物品:

    V = 10,物品 6 5 5

    贪心  : 先拿 6,剩 4 —— 两个 5 都塞不下   ->  剩 4
    正解  : 5 + 5 = 10                        ->  剩 0
★ 「先拿大的」错在哪 —— 一句话

它把「装得尽量满」偷换成了「先把大的安顿好」。 而背包问题里,一件物品该不该拿,取决于剩下的空间还能不能被别的物品配平 —— 这正是第 20 章那一整章在讲的事:贪心要证,不能猜。

2★★★ 第二版:暴力 + 一句剪枝 —— 它看着能过,是因为你的数据太软

n ≤ 30 ⇒ 子集有 2³⁰ ≈ 1.07 × 10⁹ 个,第一眼就该判它过不去。 但只要加一句剪枝(装不下就不往下递归),随手造几组数据一测,它快得像正解:

p1049Dfs.cpp第二版:DFS + 一句剪枝
★★★ 用「递归被调用了多少次」这把机器无关的尺子量一遍

同一个 n,只换体积的形状V = 20000,形状 A 的数字是 20 组的平均):

n A:体积 ∈ [1, 20000] B:体积全 ≤ 3 B / A
20 4 585 2 097 151 457 倍
22 11 436 8 388 607 734 倍
24 21 540 33 554 431 1 558 倍

★ 形状 B 那一列不是「大概」,是精确的等式:体积全 ≤ 3 时怎么装都装不满, 那句剪枝一刀也剪不动,于是递归次数恰好是整棵二叉树的结点数 2ⁿ⁺¹ − 1 (2 097 151 = 2²¹ − 1,8 388 607 = 2²³ − 1,33 554 431 = 2²⁵ − 1,三个全中)。 ⇒ 题面顶格 n = 30 时它是 2 147 483 647 次 —— 这是算出来的,不是估的。

⚠⚠ 而形状 A 之所以快,是因为箱子塞两三件就满了,那棵树一进门就被砍光。

⚠⚠ 所以「暴力能不能过」的主语是数据的形状,不是 n

这是第 20 章 P5019 那条结论在搜索题上的又一次现场:

官方样例是个「一测就死」的过滤器 —— 但它筛的是「答案错」, 对「答案对但跑不完」完全无能为力。对拍也一样。

这一版的答案永远是对的(本页第 ④ 步的对拍里它一次都没错), 错的只是「你测的那几组数据恰好让它跑得飞快」。 ⇒ 判它死刑要靠数次数 + 造对形状,而不是靠跑一遍看表。

3★ 正解:把体积当价值(这一版就能 AC)

p1049.cpp★ 这一版就能 AC
// P1049 [NOIP 2001 普及组] 装箱问题 —— ★ 这一版就能 AC
//
// 题意:箱子容量 V,n 个物品各有一个体积,任取若干件装进去(也可以不取),
// 问箱子**剩余空间**最小是多少。
//
// ★★ 这道题的第一道门槛不在算法,在**读题**:它给的物品**只有体积,没有价值**。
// 而 01 背包的模板要「体积」和「价值」两样东西。缺的那样从哪儿来?
//
// **把体积同时当成价值。**
//
// 一句话的道理:要「剩余空间最小」,就是要「装进去的体积之和最大」——
// 而「装进去的体积之和」正好就是「选中物品的体积和」。
// ⇒ 于是 w[i] = v[i] = 体积,跑一遍标准 01 背包,答案是 `V - f[V]`。
//
// ⚠ 别忘了最后那一下减法:f[V] 是**装进去多少**,题目问的是**剩下多少**。
// (这一下漏掉的话官方样例当场就会打出 24 而不是 0 —— 见解析页第 ⑥ 步。)
//
// 复杂度 O(nV) = 30 × 20000 = 6 × 10^5 —— 而 2^n 是 1.07 × 10^9,差 1789 倍。
#include <bits/stdc++.h>
using namespace std;
int main() {
int V, n;
if (!(cin >> V >> n)) return 0;
vector<int> v(n);
for (int& x : v) cin >> x;
vector<int> f(V + 1, 0); // f[j] = 容量恰好 j 时,最多能装进去的体积
for (int i = 0; i < n; i++)
for (int j = V; j >= v[i]; j--) // ★ 倒序:每件最多用一次(第 23 章第 ⑧ 步)
f[j] = max(f[j], f[j - v[i]] + v[i]);
cout << V - f[V] << "\n"; // ⚠ 问的是剩余空间
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

复杂度 O(nV),顶格 30 × 20000 = 6 × 10⁵ —— 而 2³⁰1.07 × 10⁹差 1789 倍。这个比值就是「为什么非 DP 不可」的全部答案。

4另外两条路:布尔可达,以及顶格时的参照物

这道题根本没有「价值」,把体积当价值只是为了套模板。 更贴题意的记法是布尔可达f[j] =「体积和恰好为 j」凑不凑得出来。

p1049Bool.cpp等价写法:布尔可达

而状态一旦只剩一个 bit,一件物品就能一句话推完

p1049Bitset.cpp更快:bitset 一句 f |= f << v
★★ 顶格对拍的参照物不必是暴力 —— 折半枚举

2³⁰ 的暴力在顶格跑不动,那题面顶格上怎么对拍? 换一个「和 DP 一行代码都不共享、又跑得动」的算法就行 —— 折半枚举:30 件劈成两半,各枚举 2¹⁵ = 32768 个子集,一半排序、另一半逐个二分。

p1049Half.cpp顶格参照物:折半枚举

⇒ 和 P1803(换一个复杂度相同、选法完全不同的算法)、 P1223(同一个算法换一个类型)凑成第三种: 「对拍只能跑小数据」的主语从来不是对拍,是「你把暴力当成了唯一的参照物」。

★ 顺带一条:DP 是 O(nV),吃的是 V;折半枚举是 O(2^(n/2)·n),吃的是 n。 两把尺子量的不是同一个东西 —— 要是这道题的 V 开到 10¹⁸n 仍是 30, 该上的就是折半枚举了。

⚠ 顶格对拍还有一个坑:四成的轮次在验一个平凡值

n = 30、体积 ∈ [1, 20000] 随机 300 轮,DP 和折半枚举不一致 0 组 —— 看着很稳。 可是数一下答案本身:

顶格档(n = 30V = 20000,各 300 轮) 答案就是 0 的轮数
体积 ∈ [1, 20000] 129 / 300
体积 ∈ [1, 3000] 300 / 300
体积 ∈ [10000, 20000] 0 / 300

东西一多就随便凑得满 ⇒ 四成的轮次两边都在输出 0。 第二档更极端:整整 300 轮全是平凡的 0,那一档对拍等于没跑。

⇒ 这是第 14 章 P1746 那条「一致有两种:都算对了,和都没算」的又一次。 要让顶格对拍问出问题,得把体积下限也抬起来。

5★★★ 错法二:一维写成正序 —— 同一个旋钮,两个 bug 走相反的方向

第 23 章第 ⑧ 步那句「倒序」写成正序,这一版就成了完全背包

p1049Up.cpp错法二:一维正序
// P1049 错法二:一维写成正序 —— 它精确地解了另一道题
//
// 把第 23 章那句「倒序」写成正序,这一版就变成了**完全背包**:
// 每一捆……不对,每一件物品可以被**重复装进箱子任意多次**。
//
// ⇒ 于是它算出来的「装进去的体积」只会更大(或相等),剩余空间只会更小(或相等)。
// **它不是随机地错,它恒等于「每件物品有无限多个」那道题的答案。**
// (解析页第 ⑤ 步把这句话量成了 300 组逐组相等。)
//
// ★ 而在这道题上,这个 bug 有一个白送的推论:
// 只要**存在某件物品的体积能整除某段空隙**,正序就可能把箱子塞得更满 ——
// 最极端的情形是有一件体积为 1 的物品,那它一定能把箱子塞满、输出 0。
#include <bits/stdc++.h>
using namespace std;
int main() {
int V, n;
if (!(cin >> V >> n)) return 0;
vector<int> v(n);
for (int& x : v) cin >> x;
vector<int> f(V + 1, 0);
for (int i = 0; i < n; i++)
for (int j = v[i]; j <= V; j++) // ⚠ 正序 —— 同一件物品会被反复拿
f[j] = max(f[j], f[j - v[i]] + v[i]);
cout << V - f[V] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

★ 先把它「算了什么」说清楚 —— 它不是随机地错:

随机 300 组:正序的答案 vs 显式写的完全背包 300 / 300 逐组相等

⇒ 每件物品可以被重复装进箱子任意多次。于是箱子只会更满、剩余只会更小。 官方样例上它也输出 0,照样被放过

★★★ 而这一页真正值钱的是下面这张表:同一个旋钮,两个 bug 的抓获率方向相反

V = 40n ≤ 12 固定,只拧体积上限,每档 300 轮:

体积上限 3 5 10 20 39
V / 体积上限 13.3 8.0 4.0 2.0 1.0
贪心「先拿大的」被抓 0 0 27 93 124
「正序 = 完全背包」被抓 300 289 173 114 ——

两行的方向正好相反。 为一个 bug 精心造的档位,正是另一个 bug 的盲区 (P1638 那天撞过一次)—— 而这一次,两个盲区在同一个旋钮的两头

⚠⚠ 更要紧的是:那两个 0 不是同一种 0,而分开它们只要多跑几轮:

那一档加深到两万轮
体积 ≤ 5 31 次(0.16%)—— 概率低而已,300 轮抓 0 次是意料之中
体积 ≤ 3 0 次 —— 而这个 0 是一句乘法算出来的

体积 ≤ 3 那一档:n ≤ 12 件 × 每件 ≤ 3 = 总体积 ≤ 36 < V = 40 —— 箱子装得下全部,贪心和正解都是「全拿」,结构上不可能不同。 ⇒ 又一次「『对拍 0 次』有两种原因」,而这一次两种在同一张表上并排出现。

6和算法无关、但会让你 WA 的那一条

p1049Left.cpp错法三:忘了最后那一下减法
// P1049 错法三:算对了,但**忘了最后那一下减法**
//
// f[V] 是「最多能装进去多少体积」,而题目问的是「箱子还剩多少空间」。
// 这一版把 f[V] 直接打了出来。
//
// ★ 它是这一页四个错法里**唯一一个被官方样例当场挡住**的:
// 样例答案是 0,而它打出 24。
// ⇒ 又一次「[官方样例是个『一测就死』的过滤器](/sol/p1223/)」:
// 它挡住的都是「每一组都错」的错法,放过的都是「偶尔才错」的。
// 而真正让你 WA 在第 7 个点上的,恰恰是后一种。
#include <bits/stdc++.h>
using namespace std;
int main() {
int V, n;
if (!(cin >> V >> n)) return 0;
vector<int> v(n);
for (int& x : v) cin >> x;
vector<int> f(V + 1, 0);
for (int i = 0; i < n; i++)
for (int j = V; j >= v[i]; j--)
f[j] = max(f[j], f[j - v[i]] + v[i]);
cout << f[V] << "\n"; // ⚠ 打的是「装进去多少」,不是「剩下多少」
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

f[V] 是「装进去多少体积」,题目问的是「箱子剩下多少空间」。 少写一个 V -,样例当场打出 24 而不是 0。

★ 这一页第三次验证「官方样例是个『一测就死』的过滤器」

本页三个错法,样例只挡住了这一个 —— 而它恰好是每一组都错的那种。 另外两个(贪心、正序)都是偶尔才错,样例一个都没拦住。

⇒ 这条规律至此连着七道题成立(P1223 起): 样例挡住的都是「每组都错」的,放过的都是「偶尔才错」的 —— 而真正让你 WA 在第 7 个点上的,恰恰是后一种。

7度量程序和生成器

p1049Count.cpp度量程序(本页所有数字都出自它)
p1049Gen.cpp数据生成器

8一页纸

关键的一步 只有体积没有价值 ⇒ 把体积同时当价值;答案是 V - f[V]
哪一版能 AC p1049.cpp —— 一维倒序 O(nV) = 6 × 10⁵2³⁰ 是它的 1789 倍
等价写法 布尔可达(更贴题意)/ bitset 一句 f |= f << v(常数 1/64)
★★ 顶格参照物 折半枚举 O(2^(n/2)·n) —— 暴力不是唯一的参照物(第三种办法)
错法一 贪心先拿大的;样例放过,最小反例 V=10 / 6 5 5
错法二 一维正序恒等于完全背包(300/300 逐组相等);样例也放过
错法三 忘了 V -;★ 唯一被样例挡住的那个(也是唯一「每组都错」的那个)
★★★ 暴力的假象 加一句剪枝后,随机数据 2 万次、体积全 ≤ 3 时 2ⁿ⁺¹−1 次(顶格 21 亿)
「暴力能不能过」的主语是数据形状,不是 n
★★★ 生成器 同一个旋钮(体积上限)把两个 bug 推向相反方向:3 → 贪心 0 / 正序 300;39 → 贪心 124
⚠ 两种 0 体积 ≤ 5 是概率低(两万轮 31 次);体积 ≤ 3 是结构性(12 × 3 = 36 < 40)
⚠ 顶格的坑 体积 ∈ [1,20000] 时 129/300 轮答案就是 0 —— 四成在验一个平凡值