题单 · 习题解析

洛谷 P2347 [NOIP 1996 提高组] 砝码称重

★★ 题单说它「正好拿来验证拆完答案不变」—— 真验了(三种写法 300 组不一致 0 组);★★★ 而「六个数各自随机」只有 6.3% 满足题面那句「总重 ≤ 1000」

原题:洛谷 P2347出自 第 24 章 完全背包与多重背包 的题单题面本地存档:2026-08-29
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

设有 1g、2g、3g、5g、10g、20g 的砝码各若干枚(其总重 ≤ 1000),可以表示成多少种重量?

输入格式

输入方式:a₁ a₂ a₃ a₄ a₅ a₆

(表示 1g 砝码有 a₁ 个,2g 砝码有 a₂ 个,…,20g 砝码有 a₆ 个)

输出格式

输出方式:Total=N

N 表示用这些砝码能称出的不同重量的个数,但不包括一个砝码也不用的情况

说明/提示

【题目来源】NOIP 1996 提高组第四题

输入输出样例

输入

1 1 0 0 0 0

输出

Total=3

一枚 1g、一枚 2g ⇒ 能称出 1、2、3 三种重量,输出 Total=3

★ 这一组样例把本页三个错法全挡住了Total=1000 / Total=4 / Total = 3)。

1★ 它是布尔版的多重背包:max 换成「或」

    状态:f[j] = 「重量 j 称不称得出来」
    转移:f[j] |= f[j − 这一堆的重量]        (倒序,每堆只用一次)
    边界:f[0] = true                        (一枚不用,重量 0)
    答案:Σ f[j](j 从 1 数起 —— ⚠ 把「一枚不用」那一种去掉)
p2347.cpp★ 这一版就能 AC
// P2347 [NOIP 1996 提高组] 砝码称重 —— ★ 这一版就能 AC
//
// 题意:1g / 2g / 3g / 5g / 10g / 20g 六种砝码各若干枚(**总重 ≤ 1000**),
// 问能称出多少种不同的重量(**不包括一枚也不用的情况**)。
//
// ★ 它是**布尔版的多重背包**:状态不是「最大价值」而是「能不能凑出」。
// f[j] |= f[j - 单堆重量]
// 转移里的 max 换成了「或」,其余和[第 24 章](/ch/24-knapsack-multi/)一模一样。
//
// ★ 而这道题在题单里的位置是「**拿来验证拆完答案不变**」:
// 总重 ≤ 1000 ⇒ 摊开也只有 10^6 次,拆不拆都能过。
// ⇒ 所以它是个安全的地方,可以把「二进制拆分到底改没改答案」这件事**真验一遍**
// (解析页第 ② 步:三种写法 300 组逐字节相同)。
//
// ⚠ 两个和算法无关、但一定要看清的地方:
// ① 输出是 `Total=N`,**等号两边没有空格**;
// ② 「不包括一个砝码也不用的情况」⇒ 答案是「可达重量数 **减 1**」(把 0 去掉)。
#include <bits/stdc++.h>
using namespace std;
int main() {
const int W[6] = { 1, 2, 3, 5, 10, 20 };
const int MAXW = 1000;
vector<char> f(MAXW + 1, 0);
f[0] = 1; // 一枚不用,重量 0
for (int i = 0; i < 6; i++) {
int a; if (!(cin >> a)) return 0;
for (int k = 1; a > 0; k <<= 1) { // ★ 二进制拆分
int take = min(k, a);
a -= take;
int cw = W[i] * take;
if (cw > MAXW) continue;
for (int j = MAXW; j >= cw; j--) // 倒序:这一堆只用一次
if (f[j - cw]) f[j] = 1;
}
}
int cnt = 0;
for (int j = 1; j <= MAXW; j++) cnt += f[j]; // ⚠ 从 1 开始 —— 去掉「一枚不用」
printf("Total=%d\n", cnt); // ⚠ 等号两边没有空格
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2★★ 题单说这道题「正好拿来验证拆完答案不变」—— 那就真验一遍

第 24 章的题单给这道题写的提示是:

数据小,拆不拆都能过 —— 正好拿来验证「拆完答案不变」

第 24 章第 ⑬ 步证过二进制拆分能凑出 0 .. m 的每一个数, 但证明和实测是两件事。这道题规模小,正好可以把三种独立的实现摆在一起对:

p2347Flat.cpp没拆:一枚一枚摊开
p2347Bitset.cpp第三种:bitset
随机 300 组:摊开 ≡ 二进制拆分 ≡ bitset 不一致 0 组
那 300 组里最大的答案 995(上界是 1000)
★ 顺带把「拆不拆都能过」也量了:差 110 倍,而两边都够快

顶格的一种:1000 枚 1g 砝码(总重正好 1000)。

内层执行次数
摊开 1 000 000
二进制拆分(10 堆) 9 010
倍数 110 倍

⇒ 题单那句话是对的:10⁶ 次在 1 秒时限下随便过 —— 所以这道题是个安全的练手场。 ★ 而同样一招在 P1776 上就是生死线(那道题摊开要 39.5 亿次)。

3★★ 两个错法,各配一条精确的等式

p2347Full.cpp错法一:内层正序(当成无限枚)
p2347NoMinus.cpp错法二:忘了减 1
// P2347 错法二:把「一枚砝码也不用」也数进去了
//
// 题面在输出格式那行明写着「**但不包括一个砝码也不用的情况**」。
// 这一版从 j = 0 开始数。
//
// ★ 它有一条白送的等式:**它的答案恒等于正解 + 1**(解析页第 ④ 步,300 / 300 逐组相等)。
// ⇒ 说清楚之后,它的所有表现都是推论:任何一组数据它都错,而且永远只差 1。
// ⇒ 于是它必然**被官方样例挡住**(Total=4 vs Total=3)——
// 又一次「[样例挡住的都是「每组都错」的](/sol/p1223/)」。
#include <bits/stdc++.h>
using namespace std;
int main() {
const int W[6] = { 1, 2, 3, 5, 10, 20 };
const int MAXW = 1000;
vector<char> f(MAXW + 1, 0);
f[0] = 1;
for (int i = 0; i < 6; i++) {
int a; if (!(cin >> a)) return 0;
for (int k = 1; a > 0; k <<= 1) {
int take = min(k, a);
a -= take;
int cw = W[i] * take;
if (cw > MAXW) continue;
for (int j = MAXW; j >= cw; j--)
if (f[j - cw]) f[j] = 1;
}
}
int cnt = 0;
for (int j = 0; j <= MAXW; j++) cnt += f[j]; // ⚠ 从 0 开始
printf("Total=%d\n", cnt);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
精确的等式 300 轮里成立 被抓
内层正序 ≡ 「六种砝码都无限枚」时的答案 300 / 300 300 / 300
忘了减 1 ★ ≡ 正解 + 1 300 / 300 300 / 300
★ 说清楚之后,「它什么时候能蒙对」就是白送的推论

· 忘了减 1:它恒等于正解 + 1 ⇒ 任何一组数据都错,永远只差 1。 ⇒ 于是它必然被官方样例挡住(Total=4 vs Total=3)—— 又一次「样例挡住的都是『每组都错』的」。

· 内层正序:它一次都没逃掉,而这也能一句话说清 —— 只要至少有一枚砝码,把最小的那种当成无限枚就能一路加到 1000, 于是它必然多称出一大片重量。 ⇒ 它唯一的逃法是「一枚砝码都没有」:实测输入 0 0 0 0 0 0 时两版都是 Total=0。 ★ 而那正好也是「忘了减 1」露得最明显的一组(它打出 Total=1 —— 凭空多出一种重量)。

4★ 第三个错法根本不在算法里:`Total=N` 的等号两边没有空格

p2347Fmt.cpp错法三:输出写成 Total = N
// P2347 错法三:算得全对,输出格式写成了 `Total = N`
//
// 题面写的是 `Total=N` —— **等号两边没有空格**。
// 而「Total = 3」看着更顺眼,很多人就顺手加了两个空格。
//
// ★★ 这一版的意义是提醒一件事:**对拍比对的时候不要 strip、不要按空白切词** ——
// [第 10 章 P1068 那天踩过](/sol/p1068/):自己那一轮 300 轮全绿,
// 而逐字节比报了 307 轮不一致。
// ⇒ 这一类错**只有逐字节比才抓得到**,而它在评测机上是实打实的 0 分。
#include <bits/stdc++.h>
using namespace std;
int main() {
const int W[6] = { 1, 2, 3, 5, 10, 20 };
const int MAXW = 1000;
vector<char> f(MAXW + 1, 0);
f[0] = 1;
for (int i = 0; i < 6; i++) {
int a; if (!(cin >> a)) return 0;
for (int k = 1; a > 0; k <<= 1) {
int take = min(k, a);
a -= take;
int cw = W[i] * take;
if (cw > MAXW) continue;
for (int j = MAXW; j >= cw; j--)
if (f[j - cw]) f[j] = 1;
}
}
int cnt = 0;
for (int j = 1; j <= MAXW; j++) cnt += f[j];
printf("Total = %d\n", cnt); // ⚠ 多了两个空格
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

算得一个字都不差,输出 Total = 3 —— 在评测机上是实打实的 0 分。

⚠ 这一类错只有「逐字节比」才抓得到

第 10 章 P1068 那天踩过:自己那一轮对拍 300 轮全绿, 而 check:viz 的逐字节比报了 307 轮不一致 —— 差别就在这种空白字符上。

对拍比对不要 .strip()、不要按空白切词。 本页的对拍表里就挂着这一版,靠的正是逐字节比。

5★★★ 题面那句「其总重 ≤ 1000」—— 顺手写的生成器只有 6% 满足它

题面在第一句话里就写着「其总重 ≤ 1000」。这是一条对输入的保证, 而生成器最自然的写法是「六个数各自随机」:

    a[i] = rand() % 101        // 各自在 [0, 100] 里随机

算一下这样造出来的期望总重:50 × (1 + 2 + 3 + 5 + 10 + 20) = 50 × 41 = 2050

「六个数各自随机 [0,100]」,300 轮
平均总重(实测) 2047(算出来的期望 2050 —— 对上了)
满足「总重 ≤ 1000」的轮数 19 / 300(6.3%)
而照题面造的默认档 300 / 300
★★★ 看到「保证……」,先问一句:我随手造的数据有多大概率满足它

这和第 13 章 P1162 是同一件事 —— 那道题随机 01 方阵只有 10.8% 是合法输入,照题面顶格随机更是精确的 0

⇒ 不照着保证造,测的就是题目根本不会给的输入: 一方面白跑(那些轮次的结论对评测没意义), 另一方面还会把真正该测的形状挤掉(这道题里就是「总重接近 1000」那一档)。

★ 所以这一页的生成器是「先抽一个目标总重,再一枚一枚地随机加砝码」—— 它天生满足题面,而且能均匀地覆盖 0 到 1000 的总重。

6度量程序和生成器

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

7一页纸

关键的一步 布尔版多重背包max 换成「或」,f[j] |= f[j − cw]
哪一版能 AC p2347.cpp —— 二进制拆分;⚠ 而这道题拆不拆都能过
★★ 题单那句话验了 摊开 ≡ 拆分 ≡ bitset,300 组不一致 0 组(拆完答案真的没变)
拆分省了多少 顶格(1000 枚 1g):100 万次 → 9010 次110 倍 —— 但两边都够快
错法一 内层正序 ⇒ ≡「六种都无限枚」(300/300);300/300 被抓,唯一逃法是输入全 0
错法二 忘了减 1 ⇒ ★ ≡ 正解 + 1(300/300);每组都错 ⇒ 必被样例挡住
错法三 输出写成 Total = N —— ⚠ 只有逐字节比才抓得到(别 strip)
★★★ 生成器 「六个数各自随机」只有 19/300(6.3%) 满足题面的「总重 ≤ 1000」
(平均总重 2047,算出来的期望 2050)⇒ 看到「保证……」先算一遍命中率