题单 · 习题解析

洛谷 P2036 PERKET

子集树一眼看穿,难度全在题面那句「必须至少选一种」—— 三组样例有两组挡不住它

原题:洛谷 P2036出自 第 3 章 递归 = 决策树:子集、组合、全排列 的题单题面本地存档:2026-08-25
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

Perket 是一种流行的美食。为了做好 Perket,厨师必须谨慎选择食材。你有 n 种可支配的配料。 对于每一种配料,我们知道它们各自的酸度 s 和苦度 b。当我们添加配料时, 总的酸度为每一种配料的酸度总乘积;总的苦度为每一种配料的苦度的总和

众所周知,美食应该做到口感适中,所以我们希望选取配料,以使得酸度和苦度的绝对差最小

另外,我们必须添加至少一种配料,因为没有任何食物是只以水为配料的。

输入格式:第一行一个整数 n。接下来 n 行,每行 2 个整数 sibi

输出格式:一行一个整数,表示可能的总酸度和总苦度的最小绝对差。

数据范围1 ≤ n ≤ 10,且将所有可用食材全部使用产生的总酸度和总苦度小于 10^9

样例

输入      输出        输入      输出        输入      输出
1         7           2         1           4         1
3 10                  3 8                   1 7
                      5 8                   2 6
                                            3 8
                                            4 9

(第三组:选最后三种,总酸度 2 × 3 × 4 = 24,总苦度 6 + 8 + 9 = 23,差 1。)

1先看清楚:它就是第 3 章那棵子集树

「每种配料选或不选」——一字不差就是第 3 章讲子集时那棵树n ≤ 10,一共 2^10 = 1024 个子集,闭着眼睛都能枚举完。

所以这道题的难点根本不在算法上,而在题面里那一句:

另外,我们必须添加至少一种配料,因为没有任何食物是只以水为配料的。

输入

4
1 7
2 6
3 8
4 9

输出

1

这是第三组样例(n = 4)。上面那段输出是仓库里的 p2036.cpp 真跑出来的。

2第 ① 版:忘了「至少选一种」

代码写起来太顺了,顺到会直接掠过那句话:

p2036Empty.cpp第 ① 版(错的)
第一组样例(n=1,配料 3 10),正确答案是 7。它给 1。
// 洛谷 P2036 PERKET —— 第 ① 版:忘了「至少选一种」(**这一版是错的**)
//
// 输入:第一行 n;接下来 n 行每行两个整数 s b(酸度、苦度)
// 输出:本该是「总酸度和总苦度」的最小绝对差
//
// 这份为什么存在:这道题就是第 3 章那棵**子集树**的原样搬运 ——
// 每种配料「选或不选」,走到底算一次差值。写起来太顺了,顺到会漏掉题面里的一句话:
//
// 「另外,我们必须添加**至少一种**配料,因为没有任何食物是只以水为配料的。」
//
// ⚠ 不选任何配料时:总酸度是**空乘积 = 1**,总苦度是**空和 = 0**,差值 = 1。
// 这个 1 常常比真正的答案还小,于是**每道数据的答案都被它压成 1**。
//
// 样例一(n = 1,配料 3 10)的正确答案是 7,这一版会给 **1**。
//
// ★★ 这一课值得单独记:**题面里那种「另外,…」「注意,…」开头的补充句,
// 往往就是唯一一个会挂人的约束。** 读题时看到它们,立刻在代码里找到对应的那一行。
//
// ⚠ 而且它藏得很好:样例三的正确答案**正好也是 1**,光看那一组根本发现不了。
#include <bits/stdc++.h>
using namespace std;
int n;
int s[15], b[15];
int best = INT_MAX;
// 轮到第 i 种配料,当前总酸度 sour(乘积)、总苦度 bitter(和)
void dfs(int i, long long sour, long long bitter) {
if (i == n) {
best = min(best, (int)llabs(sour - bitter)); // ⚠ 一个都没选也被算进来了
return;
}
dfs(i + 1, sour * s[i], bitter + b[i]); // 选
dfs(i + 1, sour, bitter); // 不选
}
int main() {
if (!(cin >> n)) return 0;
for (int i = 0; i < n; i++) cin >> s[i] >> b[i];
dfs(0, 1, 0); // 酸度是乘积,初值 1;苦度是和,初值 0
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 空集会给出一个「假的最小值」1

一种配料都不选时:

总酸度 = 空乘积 = 1
总苦度 = 空和   = 0
差值   = |1 - 0| = 1

这个 1 常常比真正的答案还小,于是几乎每组数据的答案都被它压成 1。

⚠⚠ 而这个错藏得非常好 —— 三组样例里:

样例 1   正确 7,它给 1     <- 只有这一组能打死它
样例 2   正确 1,它给 1     <- 过
样例 3   正确 1,它给 1     <- 过

三组样例里有两组是过的。 如果你只拿最后一组试(很多人会,因为它最长、看着最全), 你会觉得自己写对了。

★ 一般化的一课:题面里「另外,…」「注意,…」开头的补充句, 往往就是唯一一个会挂人的约束。 读题时看到它们,立刻在代码里找到对应的那一行。

3第 ② 版:数一数选了几种(正解)

差别只有一处:多带一个「已经选了几种」的计数,一种都没选就不参与比较

if (i == n) {
    if (cnt > 0) best = min(best, (int)llabs(sour - bitter));   // ★ 就是这个 cnt > 0
    return;
}
p2036.cpp第 ② 版(正解)
第一组样例给 7。把输入换成第三组(4 / 1 7 / 2 6 / 3 8 / 4 9)应该给 1。
// 洛谷 P2036 PERKET —— 第 ② 版:数一数选了几种(正解)
//
// 输入:第一行 n(1 <= n <= 10);接下来 n 行每行两个整数 s b
// 输出:总酸度和总苦度的最小绝对差
//
// 和第 ① 版的差别只有一处:**多带一个「已经选了几种」的计数,一种都没选就不参与比较**。
//
// if (i == n) {
// if (cnt > 0) best = min(best, ...); // ★ 就是这个 cnt > 0
// return;
// }
//
// 这就是第 3 章那棵子集树,一个字都没变 —— 只是在出口那儿加了一道闸。
//
// ⚠ 为什么不干脆「先算全不选的,再排除掉」:因为 best 的初值一旦被那个 1 占住,
// 后面再也没机会更新回去。**闸门要设在数据进来的地方,不是出去的地方。**
//
// 数值范围:题面保证「全部使用产生的总酸度和总苦度小于 1e9」,int 够用;
// 这里仍然用 long long 存中间量 —— 乘积这种东西,多一层保险不花钱。
#include <bits/stdc++.h>
using namespace std;
int n;
int s[15], b[15];
int best = INT_MAX;
// 轮到第 i 种配料;cnt 是已经选了几种;sour 是乘积,bitter 是和
void dfs(int i, int cnt, long long sour, long long bitter) {
if (i == n) {
if (cnt > 0) best = min(best, (int)llabs(sour - bitter)); // ★ 至少选一种
return;
}
dfs(i + 1, cnt + 1, sour * s[i], bitter + b[i]); // 选
dfs(i + 1, cnt, sour, bitter); // 不选
}
int main() {
if (!(cin >> n)) return 0;
for (int i = 0; i < n; i++) cin >> s[i] >> b[i];
dfs(0, 0, 1, 0);
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 闸门要设在数据进来的地方,不是出去的地方

有人会想:「那我先照旧算,最后再把空集那种情况排除掉。」——做不到

因为 best 一旦被那个 1 占住,后面所有更大的候选都不会更新它, 到最后你手里只剩一个 1,根本分不清它是空集给的还是某个真方案给的

⇒ 一般化一句:过滤要发生在「候选进入比较」的那一刻。 这条在所有「求最优值」的题里都成立,值得记住。

4第 ③ 版:n 这么小,直接二进制枚举

n ≤ 10 ⇒ 子集一共 1024 个,少到可以一个个数过去,连递归都不用:

for (int mask = 1; mask < (1 << n); mask++) {     // ★ 从 1 开始,不是 0
    // mask 的第 i 位是 1  <=>  选了第 i 种配料
}
p2036Bit.cpp第 ③ 版(二进制枚举)
和第 ② 版答案完全一样。注意循环从 1 开始。
// 洛谷 P2036 PERKET —— 第 ③ 版:n <= 10,直接二进制枚举(同样能过)
//
// 输入 / 输出:和第 ② 版完全一样
//
// 这份为什么存在:n 只有 10,子集一共 2^10 = 1024 个 —— **少到可以一个个数过去**。
// 于是连递归都不用:拿一个整数的二进制位表示「哪几种配料被选中」。
//
// mask 的第 i 位是 1 <=> 选了第 i 种配料
//
// ★ 而「至少选一种」这个约束,在这种写法里变成了**循环的起点**:
//
// for (int mask = 1; mask < (1 << n); mask++)
// ~~~
// 从 1 开始,不是从 0 —— mask = 0 正好就是「一种都不选」
//
// ⇒ 同一个约束,在递归写法里是一句 if,在二进制写法里是一个起点。
// **换个表示法,约束可以变得更难忘记** —— 这一条第 3 章、第 4 章都讲过。
//
// ⚠ 但别倒过来理解成「二进制枚举更好」:它只在 n 很小(一般 n <= 20)时能用,
// 而且**它不让你站在决策过程的中间**,所以一旦需要剪枝就用不了了(第 3 章第 5 步讲过)。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
vector<int> s(n), b(n);
for (int i = 0; i < n; i++) cin >> s[i] >> b[i];
int best = INT_MAX;
for (int mask = 1; mask < (1 << n); mask++) { // ★ 从 1 开始 = 至少选一种
long long sour = 1, bitter = 0;
for (int i = 0; i < n; i++)
if (mask >> i & 1) { sour *= s[i]; bitter += b[i]; }
best = min(best, (int)llabs(sour - bitter));
}
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 同一个约束,换个表示法就更难忘记
递归写法      要专门加一句 if (cnt > 0)         <- 容易漏
二进制写法    mask 从 1 开始而不是 0            <- 顺手就写对了

mask = 0 正好就是「一种都不选」,所以起点写 1,约束就自动满足了

★ 这一条在第 3 章、第 4 章都出现过: 换个表示法,规则可以被表示法本身吃掉。 (第 4 章用「每行恰好一个皇后」的表示法,一口气消掉了「同行」和「同列」两条规则。)

⚠ 但别倒过来理解成「二进制枚举更好」:它只在 n 很小时能用(一般 n ≤ 20), 而且它不让你站在决策过程的中间,所以一旦需要剪枝就用不了了 (第 3 章第 5 步埋的伏笔,第 4 章兑现)。

5回头看:这道题在教什么

✓ 三件带得走的东西
  1. 算法一眼看穿的题,坑通常在题面的补充句里。 「另外,必须至少选一种」就是这道题的全部难度。
  2. 样例过了不代表对。 这道题三组样例里有两组挡不住那个 bug —— 而人最容易只拿最长的那一组来试。
  3. 求最优值时,过滤要发生在「候选进入比较」的那一刻, 而不是最后再想办法排除。