0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P2036,日期见页头。两边不一致时信原站。
题目描述
Perket 是一种流行的美食。为了做好 Perket,厨师必须谨慎选择食材。你有 n 种可支配的配料。 对于每一种配料,我们知道它们各自的酸度 s 和苦度 b。当我们添加配料时, 总的酸度为每一种配料的酸度总乘积;总的苦度为每一种配料的苦度的总和。
众所周知,美食应该做到口感适中,所以我们希望选取配料,以使得酸度和苦度的绝对差最小。
另外,我们必须添加至少一种配料,因为没有任何食物是只以水为配料的。
输入格式:第一行一个整数 n。接下来 n 行,每行 2 个整数 si 和 bi。
输出格式:一行一个整数,表示可能的总酸度和总苦度的最小绝对差。
数据范围: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第 ① 版:忘了「至少选一种」
代码写起来太顺了,顺到会直接掠过那句话:
// 洛谷 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;}点「运行 ▶」看结果
一种配料都不选时:
总酸度 = 空乘积 = 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 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;}点「运行 ▶」看结果
有人会想:「那我先照旧算,最后再把空集那种情况排除掉。」——做不到。
因为 best 一旦被那个 1 占住,后面所有更大的候选都不会更新它,
到最后你手里只剩一个 1,根本分不清它是空集给的还是某个真方案给的。
⇒ 一般化一句:过滤要发生在「候选进入比较」的那一刻。 这条在所有「求最优值」的题里都成立,值得记住。
4第 ③ 版:n 这么小,直接二进制枚举
n ≤ 10 ⇒ 子集一共 1024 个,少到可以一个个数过去,连递归都不用:
for (int mask = 1; mask < (1 << n); mask++) { // ★ 从 1 开始,不是 0
// mask 的第 i 位是 1 <=> 选了第 i 种配料
}
// 洛谷 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;}点「运行 ▶」看结果
递归写法 要专门加一句 if (cnt > 0) <- 容易漏
二进制写法 mask 从 1 开始而不是 0 <- 顺手就写对了mask = 0 正好就是「一种都不选」,所以起点写 1,约束就自动满足了。
★ 这一条在第 3 章、第 4 章都出现过: 换个表示法,规则可以被表示法本身吃掉。 (第 4 章用「每行恰好一个皇后」的表示法,一口气消掉了「同行」和「同列」两条规则。)
⚠ 但别倒过来理解成「二进制枚举更好」:它只在 n 很小时能用(一般 n ≤ 20),
而且它不让你站在决策过程的中间,所以一旦需要剪枝就用不了了
(第 3 章第 5 步埋的伏笔,第 4 章兑现)。
5回头看:这道题在教什么
- 算法一眼看穿的题,坑通常在题面的补充句里。 「另外,必须至少选一种」就是这道题的全部难度。
- 样例过了不代表对。 这道题三组样例里有两组挡不住那个 bug —— 而人最容易只拿最长的那一组来试。
- 求最优值时,过滤要发生在「候选进入比较」的那一刻, 而不是最后再想办法排除。