题单 · 习题解析

洛谷 P1064 [NOIP 2006 提高组] 金明的预算方案

★★★ 它是分组背包(主件 + 附件一组四种买法);★★ 而真正抓不到的坑是「附件不一定排在主件后面」—— 主件在前那一档是精确的 0,打乱行序 93/300

原题:洛谷 P1064出自 第 25 章 二维费用与分组背包 的题单题面本地存档:2026-08-29
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

金明今天很开心,家里购置的新房就要领钥匙了,新房里有一间金明自己专用的很宽敞的房间。 更让他高兴的是,妈妈昨天对他说:「你的房间需要购买哪些物品,怎么布置,你说了算, 只要不超过 n 元钱就行」。今天一早,金明就开始做预算了,他把想买的物品分为两类: 主件与附件,附件是从属于某个主件的,下表就是一些主件与附件的例子:

主件 附件
电脑 打印机,扫描仪
书柜 图书
书桌 台灯,文具
工作椅

如果要买归类为附件的物品,必须先买该附件所属的主件。 每个主件可以有 0 个、1 个或 2 个附件。 每个附件对应一个主件,附件不再有从属于自己的附件。 金明想买的东西很多,肯定会超过妈妈限定的 n 元。于是,他把每件物品规定了一个重要度, 分为 5 等:用整数 1 ~ 5 表示,第 5 等最重要。 他还从因特网上查到了每件物品的价格(都是 10 元的整数倍)。 他希望在不超过 n 元的前提下,使每件物品的价格与重要度的乘积的总和最大。

设第 j 件物品的价格为 vⱼ,重要度为 wⱼ,共选中了 k 件物品,编号依次为 j₁, j₂, …, j_k, 则所求的总和为:

v(j₁) × w(j₁) + v(j₂) × w(j₂) + … + v(j_k) × w(j_k)

请你帮助金明设计一个满足要求的购物单。

输入格式

第一行有两个整数,分别表示总钱数 n 和希望购买的物品个数 m

第 2 到第 (m + 1) 行,每行三个整数,第 (i + 1) 行的整数 vᵢwᵢqᵢ 分别表示第 i 件物品的价格、重要度以及它对应的的主件。如果 qᵢ = 0,表示该物品本身是主件。

输出格式

输出一行一个整数表示答案。

说明/提示

对于全部的测试点,保证 1 ≤ n ≤ 3.2 × 10⁴1 ≤ m ≤ 600 ≤ vᵢ ≤ 10⁴1 ≤ wᵢ ≤ 50 ≤ qᵢ ≤ m答案不超过 2 × 10⁵

NOIP 2006 提高组 第二题

输入输出样例

输入

1000 5
800 2 0
400 5 1
300 5 1
400 3 0
500 2 0

输出

2200

1000 元,5 件物品:第 1 件是主件(800 元,重要度 2),第 2、3 件是它的附件;第 4、5 件也是主件。

  • 买第 1 件 + 它的附件?800 + 400 = 1200 元,超了;只买第 1 件是 800 × 2 = 1600
  • 买第 4 件 + 第 5 件:400 + 500 = 900 元,400 × 3 + 500 × 2 = 2200。 ✓

★ 这一组样例挡住了「全当独立物品」(打 3500), ⚠ 而对「一边读一边归组」完全无能为力 —— 因为样例里主件正好都排在附件前面。

1第一版:把「附件」当背景,直接做 01 背包

题面前半段全在讲家具,「价格 × 重要度求和最大」看上去就是一道裸的 01 背包。

p1064Ignore.cpp✗ 第一版:全当独立物品
// ✗ P1064 的第一版:把「附件」这两个字当成背景,全当独立物品做一遍 01 背包。
//
// 这是绝大多数人真实的第一反应 —— 题面前半段全在讲家具,
// 「价格 × 重要度求和最大」看上去就是一道裸的 01 背包。
//
// ⚠ 它丢掉的正是这道题唯一的新东西:**买附件之前必须先买它的主件**。
// ★ 它算出来的答案恒 ≥ 正解(可行集变大了),而且官方样例一测就死(3500 vs 2200)。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m;
if (scanf("%d %d", &n, &m) != 2) return 0;
vector<int> f(n / 10 + 1, 0);
for (int i = 1; i <= m; i++) {
int v, w, q;
if (scanf("%d %d %d", &v, &w, &q) != 3) return 0;
(void)q; // ✗ 主件是谁?不管
for (int j = n / 10; j >= v / 10; j--)
f[j] = max(f[j], f[j - v / 10] + v * w);
}
printf("%d\n", f[n / 10]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

样例上它打出 3500 —— 它买了两个附件却没买主件。样例一测就死。

★ 它「算了什么」:可行集变大了 ⇒ 答案恒 ≥ 正解
300 轮里「它的答案 ≥ 正解」 300 / 300
默认档里它被抓 92 / 300

⚠ 注意后一行:它只在 92 / 300 轮里露馅 —— 剩下那 208 轮, 「不买主件也能买附件」这件事恰好没被最优解用上。 ⇒ 「样例一测就死」和「对拍常常抓不到」在同一个 bug 上并不矛盾。

2★★★ 关键的一步:它是分组背包,只是没写在脸上

★ 一个主件和它的附件,绑成一组,组内互斥
    ① 只买主件
    ② 主件 + 附件1
    ③ 主件 + 附件2
    ④ 主件 + 两个附件

这四种买法互相冲突(一个主件只买一次),而「一组里至多挑一件」正是分组背包。

★ 「组内为什么最多就是 4 种」——题面写死的:「每个主件可以有 0 个、1 个或 2 个附件」。 组内选法数是 2^附件数,附件上限 2 ⇒ 4 种。⚠ 上限如果是 10,组内就是 1024 种。 ⇒ 这句话是第 12 章那三类约束里的情报:它直接告诉你「组内枚举花得起」。

p1064.cpp★ 这一版就能 AC
// P1064 [NOIP 2006 提高组] 金明的预算方案 —— ★ 这一版就能 AC。
//
// ★ 关键的一步:**它是分组背包,只是没写在脸上。**
// 一个主件和它的附件绑成**一组**,组内是四种互斥的买法:
// ① 只买主件 ② 主件 + 附件1 ③ 主件 + 附件2 ④ 主件 + 两个附件
// (题面写死了「每个主件可以有 0 个、1 个或 2 个附件」⇒ 组内最多就是这 4 种。)
// 然后就是第 25 章那三层循环:组 → 容量倒序 → 组内枚举。
//
// ⚠ 读入有一处不能想当然:题面**没有保证附件排在它的主件后面**,
// 所以要先把 m 件全读进来,再分两趟归组(见 p1064OnePass.cpp 那个反例)。
//
// ★ 「价格都是 10 元的整数倍」是题面发的一张许可证:钱数除以 10,规模缩 10 倍。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m;
if (scanf("%d %d", &n, &m) != 2) return 0;
vector<int> v(m + 1), w(m + 1), q(m + 1);
for (int i = 1; i <= m; i++)
if (scanf("%d %d %d", &v[i], &w[i], &q[i]) != 3) return 0;
n /= 10; // ★ 许可证:价格都是 10 的倍数
vector<vector<int>> att(m + 1); // att[主件] = 它的附件编号
for (int i = 1; i <= m; i++) if (q[i] != 0) att[q[i]].push_back(i);
// 每个主件一组,组内枚举「带哪几个附件」(0 ~ 2 个 ⇒ 最多 4 种)
vector<int> f(n + 1, 0);
for (int i = 1; i <= m; i++) {
if (q[i] != 0) continue; // 附件不单独成组
int cnt = (int)att[i].size();
for (int j = n; j >= 0; j--)
for (int s = 0; s < (1 << cnt); s++) { // 组内那层,必须在最里面
int cost = v[i] / 10, val = v[i] * w[i];
for (int t = 0; t < cnt; t++)
if (s >> t & 1) {
int k = att[i][t];
cost += v[k] / 10; val += v[k] * w[k];
}
if (cost <= j) f[j] = max(f[j], f[j - cost] + val);
}
}
printf("%d\n", f[n]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3★★★ 一个和算法无关、样例也抓不到的坑:附件不一定排在主件后面

最顺手的写法是一边读一边归组:读到 q = 0 就新开一组,读到 q > 0 就挂到第 q 组上。

p1064OnePass.cpp✗ 一边读一边归组
// ✗ P1064 的第二版:一边读一边归组 —— 假设了「附件一定排在它的主件后面」。
//
// 读到 `q = 0` 就新开一组,读到 `q > 0` 就直接挂到「第 q 件所在的那一组」上。
// 只要输入是「主件在前」,它就一点毛病都没有 —— 官方样例正好就是这样,所以样例放过它。
//
// ⚠ 而题面**从来没保证过这个顺序**(只说了 `qᵢ` 是它对应的主件,`0 ≤ qᵢ ≤ m`)。
// ★ 页面上量过:把输入的行序打乱,它当场就现形。
// (这一版遇到「主件还没出现」的附件时直接把它丢掉 —— 行为是确定的,
// 不去读没初始化的东西,免得打出一个换台机器就变的数。)
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m;
if (scanf("%d %d", &n, &m) != 2) return 0;
vector<int> v(m + 1), w(m + 1);
vector<vector<int>> att(m + 1);
vector<int> mains;
vector<char> seen(m + 1, 0);
for (int i = 1; i <= m; i++) {
int q;
if (scanf("%d %d %d", &v[i], &w[i], &q) != 3) return 0;
if (q == 0) { mains.push_back(i); seen[i] = 1; }
else if (seen[q]) att[q].push_back(i); // ✗ 主件还没读到?那就当这件不存在
}
int cap = n / 10;
vector<int> f(cap + 1, 0);
for (int i : mains) {
int cnt = (int)att[i].size();
for (int j = cap; j >= 0; j--)
for (int s = 0; s < (1 << cnt); s++) {
int cost = v[i] / 10, val = v[i] * w[i];
for (int t = 0; t < cnt; t++)
if (s >> t & 1) { int k = att[i][t]; cost += v[k] / 10; val += v[k] * w[k]; }
if (cost <= j) f[j] = max(f[j], f[j - cost] + val);
}
}
printf("%d\n", f[cap]);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

样例上它打出 2200,和正解一样。

⚠ 因为官方样例里,主件正好都排在它的附件前面

而题面从来没保证过这个顺序 —— 它只说了「qᵢ 表示它对应的主件」「0 ≤ qᵢ ≤ m」。

★★★ 只拧一个开关:把输入的行序打乱(各 300 轮)
生成器 「一趟读」被抓 而「真有附件排在主件前面」的轮数
主件在前(和官方样例一样) 精确的 0 0
打乱行序 93 / 300 194 / 300

★ 上面那个 0 又是结构性的:那种输入根本造不出来。 ⚠ 而默认值的方向再一次值得记住:写生成器时「先写主件再写它的附件」是最自然的动作 —— 它和官方样例一样,正好把这个 bug 藏起来。 ⇒ 这已经是本章第二次了(P1757 是「组号顺手编成 1..k」): ★★ 生成器最自然的那个默认值,往往正是某个 bug 的藏身处。

★ 顺带又是一次「触发条件 ≠ 抓获数」:194 轮里真被抓 93 轮(差 2.1 倍)—— 附件排在前面只是第一层,那件附件还得真的落在最优解上

4★ 「价格都是 10 元的整数倍」是一张许可证 —— ⚠ 但这一次不用它也过得去

p1064Raw.cpp(不除以 10,它是对的)对照:钱数原样跑
★ 量一遍(顶格 n = 32000、m = 60)
除以 10 不除
DP 内层执行次数 192 060 1 920 060(★ 9.99 倍)
f 数组 12 KB 125 KB
两版答案 相同 随机 300 / 300 轮逐组相等

⇒ ★ 两列都在限制之内(这道题的空间限制是 512 MB、时限 1 秒)—— 所以这张许可证在这道题上是「用了更漂亮」,不是「不用就挂」。

⚠ 别和 P1853 搞混:那道题的许可证是 a 为 1000 的倍数, 不用它就是 172 MB,先 MLE 再 TLE。 ⇒ 同一句话(「所有金额都是某个数的倍数」)在两道题上的分量完全不同,只能自己乘一遍。

5★★ 题面那句「答案不超过 2 × 10⁵」有多紧

本书量过好几句这种「保证」,松紧差得远。这一句可以两行算完:

    买下的总价 ≤ n = 32 000,而每一元最多贡献 5(重要度 ≤ 5)
    ⇒ 答案 ≤ 5 × 32 000 = 160 000
算出来的真实上界 160 000
而它可达吗(造一件 v = 32000, w = 5 可达,实测正好 160 000
题面保证 200 000 ⇒ 只松 1.25 倍
int 的余量 10 737 倍 ⇒ 这道题不需要 long long

⇒ 把三句放一起看:P1164 恰好够P1060 松了 666 倍、 这道题 松 1.25 倍。★ 每一句「保证」都得自己乘一遍。

6度量程序、生成器和参照物

p1064Count.cpp度量程序(本页所有数字都出自它)
p1064Gen.cpp数据生成器
p1064Brute.cpp参照物:2^m 枚举子集(300 轮不一致 0 轮)

7一页纸

★★★ 关键的一步 它是分组背包:主件 + 它的附件绑成一组,组内四种互斥买法
组内为什么只有 4 种 题面写死「每个主件最多 2 个附件」⇒ 2² = 4情报:它告诉你组内枚举花得起)
第一版:全当独立物品 恒 ≥ 正解(300/300);样例一测就死,⚠ 而对拍只抓 92 / 300
★★★ 一趟读的坑 题面没保证附件排在主件后面;主件在前那一档是精确的 0,打乱行序 93 / 300
★★ 生成器的默认值 「先写主件再写附件」和「组号编成 1..k」(P1757)—— 同一章两次,最自然的默认值就是藏身处
★ 「价格是 10 的倍数」 内层 192 060 → 1 920 060(9.99 倍)、f 12 KB → 125 KB ——⚠ 两列都在限制内,不用也能过
★★ 「答案 ≤ 2 × 10⁵」 真实上界 5 × 32000 = 160 000可达)⇒ 只松 1.25 倍int 余量 10 737 倍