0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1120,日期见页头。两边不一致时信原站。
题目背景
本题不保证存在可以通过满足本题数据范围的任意数据的做法。 可以通过此题的程序不一定完全正确(算法时间复杂度错误、或不保证正确性)。
本题为搜索题,本题不接受 hack 数据。
题目描述
乔治有一些同样长的小木棍,他把这些木棍随意砍成几段,直到每段的长都不超过 50。
现在,他想把小木棍拼接成原来的样子,但是却忘记了自己开始时有多少根木棍和它们的长度。
给出每段小木棍的长度,编程帮他找出原始木棍的最小可能长度。
输入格式
第一行是一个整数 n,表示小木棍的个数。
第二行有 n 个整数,表示各个木棍的长度 a[i]。
输出格式
输出一行一个整数表示答案。
数据规模与约定
对于全部测试点,1 ≤ n ≤ 65,1 ≤ a[i] ≤ 50。
输入输出样例
输入
9 5 2 1 5 2 1 5 2 1
输出
6
九根木棍 5 2 1 各三根,总长 24。原长 6 的话正好拼成四根(5+1、5+1、5+1、2+2+2);
比 6 更小的候选只有 5(24 % 5 ≠ 0)—— 所以答案是 6。
「不保证存在可以通过任意数据的做法」不是客套话。 第 ⑥ 步实测:同一份公认写法,200 组顶格随机数据里中位数只走 81 个节点, 可有 4 组跑一百万个节点还出不来。
⇒ 所以这一页不说「哪一版最优」,只说哪一版是公认能过洛谷那组数据的写法, 以及每一条剪枝分别值多少钱 —— 后者才是这道题真正要教的东西。
1第一反应:枚举原长,然后一根一根拼
题目问「最小可能长度」,而可能的长度一眼就能圈出来:
- 原长
L不能比最长的那根小 —— 否则那根放不进去; - 拼出来的总长必须正好是
sum—— 所以sum % L == 0; - 从小到大枚举
L,第一个拼得出来的就是答案。
剩下的是「固定 L,这些木棍能不能恰好拼成 sum / L 根」——
一个标准的 DFS:dfs(done, cur, start) = 已经拼好 done 根、
当前这根拼到 cur 长、只允许再从第 start 根往后挑。
// P1120 的**第一版**:枚举原长 L,DFS 一根一根拼,**一条剪枝都不加**。//// 题目:n 根小木棍(长度 a[i] ≤ 50),它们是若干根**等长**的原始木棍砍出来的。// 求原始木棍的**最小可能长度**。//// 它已经带着两条**几乎没人会漏**的东西:// ① 只枚举 `sum % L == 0` 的 L(拼出来的总长必须正好是 sum);// ② `dfs(done, cur, start)` 里只从第 start 根往后挑 —— 同一根木棍里的几段// 是一个**组合**不是排列,不这么写会把同一种拼法数很多遍。//// ⚠ 这一版**是对的**,它只是跑不完:题面顶格(n = 65)它会卡到天荒地老。// 页面第 ④ 步用一张表量了「再加四条剪枝分别值多少钱」。
#include <bits/stdc++.h>using namespace std;
static int n, a[70], L, need;static bool used_[70];static long long nodes = 0; // dfs 进了多少次 —— 这一页的尺子
static bool dfs(int done, int cur, int start) { nodes++; if (done == need) return true; if (cur == L) return dfs(done + 1, 0, 0); for (int i = start; i < n; i++) { if (used_[i] || cur + a[i] > L) continue; used_[i] = true; if (dfs(done, cur + a[i], i + 1)) return true; used_[i] = false; } return false;}
int main(int argc, char** argv) { if (!(cin >> n)) return 0; int sum = 0, mx = 0; for (int i = 0; i < n; i++) { cin >> a[i]; sum += a[i]; mx = max(mx, a[i]); }
for (L = mx; L <= sum; L++) { if (sum % L) continue; need = sum / L; memset(used_, 0, sizeof used_); if (dfs(0, 0, 0)) break; } cout << L << "\n"; if (argc > 1 && string(argv[1]) == "count") cerr << "nodes=" << nodes << "\n"; return 0;}点「运行 ▶」看结果
它们不算剪枝,是写对的一部分:
sum % L == 0—— 不判这个,L要试sum - mx次,其中绝大多数一开始就不可能;start往后挑 —— 同一根原始木棍里的那几段是一个组合,不是排列。 写成「每次从 0 开始扫」,同一种拼法会被数k!遍。
⇒ 这两条之后再加的四条,才是本章说的「剪枝」。
2⚠ 顺便挡一个念头:这道题不能二分
「从小到大枚举 L」看着很浪费 —— 很多人第一反应是二分。不行。
二分要的是「L 可行 ⇒ 所有更大的 L 都可行」,而这条在这道题上是假的:
三根木棍:2 2 2 (sum = 6,可能的原长只有 2 / 3 / 6)
L = 2 : [2] [2] [2] -> 拼得出
L = 3 : 凑不出 3 -> 拼不出 <- 断在这儿
L = 6 : [2+2+2] -> 拼得出
可行的集合是 {2, 6},中间缺了 3 —— 不单调。
(度量程序第 ⑥ 段把这三个 L 都跑了一遍,钉成了断言。)
「L 可行 ⇒ 更大的可行」这件事,只在倍数上是白送的:
若 L 可行、且 kL 也整除 sum,把 k 组并成一组,kL 就一定可行。
可候选的 L 是 sum 的全部因数,而它们之间不都是倍数关系 ——
sum = 6 时 2 和 3 谁也不是谁的倍数,于是「2 可行」推不出「3 可行」,那个洞就在这儿。
⇒ 单调性只在倍数链上成立,在整条因数链上不成立。
这是第 7 章 P1873 那条判据的另一面:那道题「把输入打乱,答案不变」⇒ 问的是阈值、该二分;
这道题连「阈值」都不存在 —— 可行的 L 是一个有洞的集合。
3★ 四条剪枝:A 排序 / B 跳等长 / C 首段失败 / D 补满失败
A. 从大到小排序。 大木棍能放的位置少、约束强,先放它就早点撞墙。 ⚠ 它还有一个副作用:排完序之后等长的木棍是挨着的,B 要靠这一条才成立。
// P1120 第二版:在第一版之上**只加一条** —— 木棍**从大到小排序**(剪枝 A)。//// 题目:n 根小木棍(长度 a[i] ≤ 50),它们是若干根**等长**的原始木棍砍出来的。// 求原始木棍的**最小可能长度**。//// ★ 为什么先放大的:大木棍能放的位置少、约束强,**早点撞墙就早点回头**。// 而且配合「只从第 start 根往后挑」,排完序之后长度相同的木棍是挨着的 ——// 下一版那条剪枝(跳过和刚失败的等长木棍)**要靠这一条才成立**。// ⇒ 这是这一页第一处「两条剪枝互相搭台」的地方。
#include <bits/stdc++.h>using namespace std;
static int n, a[70], L, need;static bool used_[70];static long long nodes = 0; // dfs 进了多少次 —— 这一页的尺子
static bool dfs(int done, int cur, int start) { nodes++; if (done == need) return true; if (cur == L) return dfs(done + 1, 0, 0); for (int i = start; i < n; i++) { if (used_[i] || cur + a[i] > L) continue; used_[i] = true; if (dfs(done, cur + a[i], i + 1)) return true; used_[i] = false; } return false;}
int main(int argc, char** argv) { if (!(cin >> n)) return 0; int sum = 0, mx = 0; for (int i = 0; i < n; i++) { cin >> a[i]; sum += a[i]; mx = max(mx, a[i]); } sort(a, a + n, greater<int>()); // A:从大到小
for (L = mx; L <= sum; L++) { if (sum % L) continue; need = sum / L; memset(used_, 0, sizeof used_); if (dfs(0, 0, 0)) break; } cout << L << "\n"; if (argc > 1 && string(argv[1]) == "count") cerr << "nodes=" << nodes << "\n"; return 0;}点「运行 ▶」看结果
B. 跳过和「刚失败的那根」等长的木棍。 刚把一根长度 5 的放进去、往下走失败了 —— 那么再拿另一根长度 5 的放进去,接下来面对的局面一模一样,必然也失败。
// P1120 第三版:排序之上**再加一条** —— 跳过和「刚失败的那根」等长的木棍(剪枝 B)。//// 题目:n 根小木棍(长度 a[i] ≤ 50),它们是若干根**等长**的原始木棍砍出来的。// 求原始木棍的**最小可能长度**。//// 道理:刚拿一根长度 5 的放进去、往下走失败了,那么**再拿另一根长度 5 的放进去,// 接下来面对的局面一模一样**(剩下能用的木棍是同一个多重集合),必然也失败。//// ⚠⚠ 而它有一个**很容易写错的写法**:写成「和上一根等长就跳过」(`a[i] == a[i-1]`)——// 那是无条件跳过,会把「这一层第一次用这个长度」也一起跳掉,**剪过头**。// 那一版见 p1120Wrong.cpp,页面第 ⑥ 步专门对拍抓它。// ⇒ 判据是「上一次**失败**的长度」,不是「上一根的长度」。
#include <bits/stdc++.h>using namespace std;
static int n, a[70], L, need;static bool used_[70];static long long nodes = 0; // dfs 进了多少次 —— 这一页的尺子
static bool dfs(int done, int cur, int start) { nodes++; if (done == need) return true; if (cur == L) return dfs(done + 1, 0, 0); int fail = 0; // B:上一次在这一层失败的长度 for (int i = start; i < n; i++) { if (used_[i] || a[i] == fail || cur + a[i] > L) continue; used_[i] = true; if (dfs(done, cur + a[i], i + 1)) return true; used_[i] = false; fail = a[i]; } return false;}
int main(int argc, char** argv) { if (!(cin >> n)) return 0; int sum = 0, mx = 0; for (int i = 0; i < n; i++) { cin >> a[i]; sum += a[i]; mx = max(mx, a[i]); } sort(a, a + n, greater<int>()); // A:从大到小
for (L = mx; L <= sum; L++) { if (sum % L) continue; need = sum / L; memset(used_, 0, sizeof used_); if (dfs(0, 0, 0)) break; } cout << L << "\n"; if (argc > 1 && string(argv[1]) == "count") cerr << "nodes=" << nodes << "\n"; return 0;}点「运行 ▶」看结果
C 和 D 是两句反证,也是这道题最狠的两刀:
C: cur == 0 时试了一根却失败 -> 整个 L 都不可能,直接 return false
(这根新木棍的第一段,换谁来当都一样 —— 剩下的木棍总得有人当第一段)
D: cur + a[i] == L 时失败 -> 也直接 return false
(「正好补满」是最理想的收尾;它都不行,用更多零碎去凑更不行)
// P1120 小木棍 —— 枚举原长 + 四层剪枝的 DFS(★ 这一版就能 AC)//// 题目:n 根小木棍(长度 a[i] ≤ 50),它们是若干根**等长**的原始木棍砍出来的。// 求原始木棍的**最小可能长度**。//// 框架只有两层:// ① 从小到大枚举原长 L —— 第一个拼得出来的 L 就是答案;// ⚠ 只有 `sum % L == 0` 的 L 才可能(拼出来的总长必须正好是 sum)。// ★ 而且**不能二分** —— 可行性对 L 不单调,页面第 ② 步有一组三根木棍的反例。// ② 对固定的 L,DFS 一根一根拼:`dfs(done, cur, start)`// = 已经拼好 done 根、当前这根拼到 cur 长、只允许再从第 start 根往后挑。//// ★★★ 而这道题的分数**全部**来自下面四条剪枝(页面第 ④ 步一条一条量了它们值多少钱):// A 从大到小排序 —— 大的先放,早点撞墙// B 跳过和「刚失败的那根」等长的木棍// C ⚠ `cur == 0` 就失败 ⇒ **整个 L 都不可能**,直接返回// D ⚠ 正好补满(`cur + a[i] == L`)还失败 ⇒ 也直接返回//// C 和 D 是两句反证:这根木棍的第一段随便挑一根都拼不成 ⇒ 换谁当第一段都一样;// 而「正好补满」是最理想的收尾,它都不行 ⇒ 别的收法更不行。
#include <bits/stdc++.h>using namespace std;
static int n, a[70], L, need;static bool used_[70];static long long nodes = 0; // dfs 进了多少次 —— 这一页的尺子
static bool dfs(int done, int cur, int start) { nodes++; if (done == need) return true; if (cur == L) return dfs(done + 1, 0, 0); int fail = 0; // B:上一次在这一层失败的长度 for (int i = start; i < n; i++) { if (used_[i] || a[i] == fail || cur + a[i] > L) continue; used_[i] = true; if (dfs(done, cur + a[i], i + 1)) return true; used_[i] = false; fail = a[i]; if (cur == 0 || cur + a[i] == L) return false; // C / D } return false;}
int main(int argc, char** argv) { if (!(cin >> n)) return 0; int sum = 0, mx = 0; for (int i = 0; i < n; i++) { cin >> a[i]; sum += a[i]; mx = max(mx, a[i]); } sort(a, a + n, greater<int>()); // A
for (L = mx; L <= sum; L++) { if (sum % L) continue; need = sum / L; memset(used_, 0, sizeof used_); if (dfs(0, 0, 0)) break; } cout << L << "\n"; if (argc > 1 && string(argv[1]) == "count") cerr << "nodes=" << nodes << "\n"; return 0;}点「运行 ▶」看结果
4★ 一张表:四条剪枝的 16 种开关组合(软数据)
四条剪枝,一共 16 种开关组合。先在砍碎生成器造出来的 40 组数据上跑一遍
(造 k 根长度 L 的木棍、随机砍碎、打乱 —— 见第 ⑦ 步),数 dfs 走了多少个节点:
| A 排序 | B 跳等长 | C 首段 | D 补满 | 节点数 |
|---|---|---|---|---|
| 46176 | ||||
| ✓ | 1270 | |||
| ✓ | 22645 | |||
| ✓ | 9833 | |||
| ✓ | 39071 | |||
| ✓ | ✓ | 815 | ||
| ✓ | ✓ | 674 | ||
| ✓ | ✓ | 784 | ||
| ✓ | ✓ | ✓ | 666 | |
| ✓ | ✓ | ✓ | 705 | |
| ✓ | ✓ | ✓ | 664 | |
| ✓ | ✓ | ✓ | ✓ | 660 |
看上去结论很清楚:A 排序一条顶天(46176 → 1270,省了 97.2%), 剩下三条加起来只再省一点点(1270 → 660)。
上面那 40 组是砍碎出来的:每一段天生就凑得回整根,局面很软。
换成题面允许的最一般输入(n = 65、长度在 1 ~ 50 之间随便取),下一步同一张表长得完全不同。
5★★★ 换一档数据,同一张表的结论翻过来
40 组顶格随机数据(n = 65、长度 1 ~ 50)。
⚠ 一条剪枝都不加的那几档根本不返回,所以给节点数设了 20 万的上限 ——
于是多出一把更抗上限的尺子:「撞上限」的组数。
| A 排序 | B 跳等长 | C 首段 | D 补满 | 节点数 | 撞上限 |
|---|---|---|---|---|---|
| 3450779 | 17 / 40 | ||||
| ✓ | 2031280 | 10 / 40 | |||
| ✓ | 3427604 | 17 / 40 | |||
| ✓ | 3408389 | 17 / 40 | |||
| ✓ | 3446094 | 17 / 40 | |||
| ✓ | ✓ | 1432104 | 7 / 40 | ||
| ✓ | ✓ | ✓ | 321929 | 1 / 40 | |
| ✓ | ✓ | ✓ | 340779 | 1 / 40 | |
| ✓ | ✓ | ✓ | ✓ | 175926 | 0 / 40 |
B 单独开,几乎一分钱不值(3450779 → 3427604,省了 0.7%)。
可把它加到 A + C + D 上:340779 → 175926,再省一半,撞上限的组数从 1 变成 0。
把每一条单独开的倍数乘起来,和四条一起开的实测值比一比:
| 只开 A | 只开 B | 只开 C | 只开 D | 四个相乘 | 四条一起开(实测) | |
|---|---|---|---|---|---|---|
| 软数据 | 0.0275 | 0.4904 | 0.2129 | 0.8461 | 0.0024 | 0.0143 ← 比相乘差 5.9 倍 |
| 硬数据 | 0.5886 | 0.9933 | 0.9877 | 0.9986 | 0.5767 | 0.0510 ← 比相乘好 11.3 倍 |
(数字是「节点数 ÷ 一条都不开时的节点数」,越小越好。)
- 软数据上它们互相吃掉:局面本来就快解开,A 已经把大部分岔路砍了, 剩下三条再来砍,砍的是同一批已经不存在的分支。
- 硬数据上它们互相搭台:单独看每一条都只值百分之几,
可 C / D 把「整个
L不可能」这件事提早暴露,B 才有大量等长的木棍可跳, A 又让等长的挨在一起 —— 四条咬在一起才把树砍掉 95%。
⇒ 「一条剪枝值多少钱」这句话,缺的不只是「拿哪把尺子量」,还有「和谁一起用」和「在什么数据上」。 本章第 6 步那个反直觉的结论(有一招单独看没用)在这道题上是两次,方向还相反。
6★★★ 这道题真正的样子:中位数 81 个节点,可长尾长到跑不完
把四条剪枝全开的那一版(也就是公认能过的写法)放到 200 组顶格随机数据上, 只数它走了多少个节点(上限 100 万):
| 数据 | 中位数 | 撞上限(100 万节点)的组数 |
|---|---|---|
n = 65、长度 1 ~ 50 |
81 | 4 / 200 |
n = 30、长度 1 ~ 50 |
43 | 0 / 200 |
n = 65、长度 1 ~ 15 |
77 | 0 / 200 |
一半的数据只要 81 个节点,而有 2% 的数据一百万个节点都出不来。
这一行数字值得单独记住:中位数 81、最大跑不完 —— 平均值在这里毫无意义。
⇒ 「我拿顶格数据跑了一遍,很快」在搜索题上是最危险的自我安慰: 你大概率抽到的是那 81 个节点的中位数,而不是那 2%。 ★ 这是第 4 章 P1731(同样挂在本章题单上)那条的升级版 —— 那道题是「数据范围顶格 ≠ 最坏」,这道题连「最坏」都不是一个点,是一条尾巴。
★ 而尾巴的主语能读出来:木棍多(n = 65)+ 长度杂(1 ~ 50) 两个条件缺一不可 ——
砍掉一半木棍(n = 30)或者把长度压窄(1 ~ 15),200 组里一组难的都没有。
⇒ 所以第 ⑦ 步那个「砍碎」生成器造出来的数据全是软的,它压根到不了这条尾巴上。
⚠ 也正因如此,题面开头那句「不保证存在可以通过任意数据的做法」是如实描述, 洛谷那组测试数据能过,不等于这份代码对任何合法输入都跑得完。
7★★★ 剪过头:`fail` 少写了两个字 —— 一档 WA,一档 TLE
本章第 8 步说过:剪枝写错多半不是漏剪,是剪过头 —— 程序照样跑得飞快,只是答案偏大。 ⚠ 而这道题会告诉你,后半句也只对了一半。
最容易犯的那一个,是把 B 的 fail 写在了函数外面:
正解: bool dfs(...) { int fail = 0; ... } <- 每一层各有各的
错法: static int fail = 0; <- 所有层共用一个
bool dfs(...) { ... }
「上一次失败的长度」本来属于这一层;写成全局之后,在深处失败留下的 fail
会一路污染回上层,让上层跳过本来该试的木棍。
// P1120 的**错法:剪过头**。和 p1120.cpp 只差一个字 —— `fail` 忘了写在函数里面。//// 题目:n 根小木棍(长度 a[i] ≤ 50),它们是若干根**等长**的原始木棍砍出来的。// 求原始木棍的**最小可能长度**。//// ★★★ 本章第 8 步说过:剪枝写错**多半不是漏剪,是剪过头** —— 程序照样跑得飞快,// 只是答案偏大(因为它把通向正确解的那条分支剪掉了)。这一版就是那样。//// 差别只有一行:`int fail` 从「dfs 里的局部变量」变成了**全局变量**。// 于是「上一次失败的长度」不再属于**这一层**,而是被所有层共用 ——// 在深处失败留下的 fail,会一路污染回上层,让上层跳过本来该试的木棍。//// ⚠⚠ 而它**照样过官方样例**(页面第 ⑥ 步),只有对拍抓得到。//// 框架只有两层:// ① 从小到大枚举原长 L —— 第一个拼得出来的 L 就是答案;// ⚠ 只有 `sum % L == 0` 的 L 才可能(拼出来的总长必须正好是 sum)。// ★ 而且**不能二分** —— 可行性对 L 不单调,页面第 ② 步有一组三根木棍的反例。// ② 对固定的 L,DFS 一根一根拼:`dfs(done, cur, start)`// = 已经拼好 done 根、当前这根拼到 cur 长、只允许再从第 start 根往后挑。//// ★★★ 而这道题的分数**全部**来自下面四条剪枝(页面第 ④ 步一条一条量了它们值多少钱):// A 从大到小排序 —— 大的先放,早点撞墙// B 跳过和「刚失败的那根」等长的木棍// C ⚠ `cur == 0` 就失败 ⇒ **整个 L 都不可能**,直接返回// D ⚠ 正好补满(`cur + a[i] == L`)还失败 ⇒ 也直接返回//// C 和 D 是两句反证:这根木棍的第一段随便挑一根都拼不成 ⇒ 换谁当第一段都一样;// 而「正好补满」是最理想的收尾,它都不行 ⇒ 别的收法更不行。
#include <bits/stdc++.h>using namespace std;
static int n, a[70], L, need;static bool used_[70];static long long nodes = 0; // dfs 进了多少次 —— 这一页的尺子static int fail = 0; // ★ 错在这里:它该是 dfs 里的局部变量
static bool dfs(int done, int cur, int start) { nodes++; if (done == need) return true; if (cur == L) return dfs(done + 1, 0, 0); for (int i = start; i < n; i++) { if (used_[i] || a[i] == fail || cur + a[i] > L) continue; used_[i] = true; if (dfs(done, cur + a[i], i + 1)) return true; used_[i] = false; fail = a[i]; if (cur == 0 || cur + a[i] == L) return false; // C / D } return false;}
int main(int argc, char** argv) { if (!(cin >> n)) return 0; int sum = 0, mx = 0; for (int i = 0; i < n; i++) { cin >> a[i]; sum += a[i]; mx = max(mx, a[i]); } sort(a, a + n, greater<int>()); // A
for (L = mx; L <= sum; L++) { if (sum % L) continue; need = sum / L; memset(used_, 0, sizeof used_); if (dfs(0, 0, 0)) break; } cout << L << "\n"; if (argc > 1 && string(argv[1]) == "count") cerr << "nodes=" << nodes << "\n"; return 0;}点「运行 ▶」看结果
它在样例上输出 6,和正解一模一样(走的节点数也一样是 14)。只有对拍抓得到 —— 可「对拍」在两档数据上抓到的根本不是同一件事:
| 生成器(每档 300 轮) | 答案和正解不同 | ★ 它自己跑不完(200 万节点上限) | 正解跑不完 |
|---|---|---|---|
| 砍碎,默认档 | 9 | 0 | 0 |
| 砍碎,原长 12 / 3 根 | 22 | 0 | 0 |
| 砍碎,原长 20 / 6 根 | 5 | 0 | 0 |
顶格随机(n = 65、长度 1 ~ 50) |
0 | 176 | 5 |
- 砍碎档上它是 WA:答案偏大,对拍一比就出来。
- 顶格随机档上它一次都没答错,可它自己有 176 轮跑不完(而正解只有 5 轮)。
⇒ ★★★ 对拍只比答案,在那一档是完全瞎的 —— 那 176 轮里被剪掉的分支 根本没机会变成一个错误答案,它把程序变成了一个跑不完的程序。 「谁跑不完」本身就是一条要记下来的结果。
草稿里写的是:「它把正确的 L 剪掉了,于是一路往上试更大的 L,而那些 L 的树大得多。」
实测不是。 度量程序记下了它撞上限时正在试哪个 L:
| 176 轮跑不完的 | 轮数 |
|---|---|
卡在「正确答案那个 L」上 |
166 |
已经跑到比正确答案更大的 L 上 |
5 |
⇒ 真正发生的事是:在正确的那个 L 上,解被剪掉了,它找不到 ——
于是它只能把这个 L 的整棵树穷举完才肯罢休。
而「找到一个解」通常几十个节点就够了,「证明无解」要几百万个。
一次错误的剪枝,把一道 O(找一个解) 的题变成了 O(证明没有解)。
⚠ 顺带说清 9 / 22 / 5 那三档:我本来以为层数深(原长 20 / 6 根)最容易触发跨层污染,
实测它只有 5 / 300,比默认档还低 —— 「我知道这个 bug 靠什么现形」又只知道了一半
(第 47 章那条)。管用的是「同一长度的木棍很多」。
第一次:这张表的第一版把「正解撞上限 = 没答案」和「错法给了个数」也算成一次「抓到」, 于是量出「顶格随机档抓到 171 / 300」—— 全是假的,真值是 0。 ⇒ 对拍比对之前,先确认两边都真的算完了。「不一致」和「一致」一样,也有两种。
第二次:把这两档写进 check:viz 的时候,我让它真跑顶格随机档的对拍 ——
而页面上这份 p1120Wrong.cpp 是没有节点上限的,于是它当场把 check:viz 挂住了,
两分钟一个字都没打出来。⇒ 正是下一步那个 Callout 说的事,而我是先写了那句话才踩的。
⇒ 所以 check:viz 里只真跑砍碎档的对拍(9 / 300),顶格随机那一档的数字
由度量程序给出 —— 它有上限,不会挂住谁。
8度量程序:这一页的每个数字都出自它
两张 16 组合的表、长尾那三行、抓获率那四档、还有第 ② 步那个不单调的反例,
都是这一份跑出来的,并且逐条写进了 scripts/check-viz.mjs 的断言。
// P1120 的度量程序 —— 这一页所有数字都出自这一份。//// `./p1120Count` 人看的版本// `./p1120Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用//// 六段:// ① 官方样例:答案 + dfs 节点数;// ② ★ 软数据(砍碎生成器 40 组)上,四条剪枝 A/B/C/D 的 **16 种开关组合**各走多少节点;// ③ ★★★ 硬数据(顶格随机 40 组,n = 65、长度 1~50)上的同一张表 ——// ⚠ 这里得给节点数设上限,不然一条剪枝都不加的那几档根本不返回;// 于是多出一把尺子:**「撞上限」的组数**(比节点数合计更抗上限的选择)。// ④ ★★★ 「几条剪枝合起来值多少」—— 是相加?是相乘?两档数据给出**相反方向**的答案。// ⑤ ★★★ 长尾:顶格随机 200 组,中位数只有几十个节点,可最难的那几组几百万也出不来。// ⇒ 题面开头那句「不保证存在可以通过任意符合要求的输入数据的程序」不是客套话。// ⑥ ★★★ 「fail 写成全局」那个剪过头的错法 —— 它在两档数据上的**后果完全不同**:// 砍碎档是「答案偏大」,顶格随机档是「**跑不完**」(它把正确的 L 剪掉了,// 于是一路往上试更大的 L,而那些 L 的搜索树大得多)。// + 一组三根木棍的反例(可行性对 L 不单调 ⇒ 不能二分)。
#include <bits/stdc++.h>using namespace std;
static bool CSV = false;static void row(const char* key, const vector<long long>& v) { if (!CSV) return; printf("%s", key); for (long long x : v) printf(",%lld", x); printf("\n");}
/* ─────────── 一份参数化的搜索:A 排序 / B 跳等长 / C 首段失败 / D 补满失败 ─────────── */struct Solver { int n = 0, L = 0, need = 0; vector<int> a; vector<char> used; bool A = true, B = true, C = true, D = true, globalFail = false; long long nodes = 0, cap = 0; int gfail = 0; int lastL = 0; // 撞上限时正在试的那个 L
bool dfs(int done, int cur, int start) { if (cap && nodes >= cap) return false; nodes++; if (done == need) return true; if (cur == L) return dfs(done + 1, 0, 0); int localFail = 0; int& fail = globalFail ? gfail : localFail; for (int i = start; i < n; i++) { if (used[i] || cur + a[i] > L) continue; if (B && a[i] == fail) continue; used[i] = true; if (dfs(done, cur + a[i], i + 1)) return true; used[i] = false; if (cap && nodes >= cap) return false; fail = a[i]; if ((C && cur == 0) || (D && cur + a[i] == L)) return false; } return false; }
/** 返回答案;撞上节点上限返回 -1。 */ int run(const vector<int>& in) { a = in; n = (int)a.size(); nodes = 0; gfail = 0; if (A) sort(a.begin(), a.end(), greater<int>()); int sum = 0, mx = 0; for (int x : a) { sum += x; mx = max(mx, x); } for (L = mx; L <= sum; L++) { if (sum % L) continue; need = sum / L; used.assign(n, 0); if (dfs(0, 0, 0)) return L; if (cap && nodes >= cap) { lastL = L; return -1; } } return sum; }};
static int lastCapL = 0; // 上一次撞上限时正在试的 Lstatic int solveWith(const vector<int>& in, bool A, bool B, bool C, bool D, long long& nodes, long long cap = 0, bool gf = false) { Solver s; s.A = A; s.B = B; s.C = C; s.D = D; s.cap = cap; s.globalFail = gf; int r = s.run(in); nodes = s.nodes; lastCapL = s.lastL; return r;}
/* ─────────── 生成器(和 p1120Gen.cpp 逐字一致) ─────────── */static vector<int> gen(int seed, int mode, int p1, int p2) { mt19937 rng((unsigned)seed * 2654435761u + 12345u); vector<int> a; if (mode == 1) { int n = p1 > 0 ? p1 : 65, hi = p2 > 0 ? p2 : 50; n = max(1, min(65, n)); hi = max(1, min(50, hi)); for (int i = 0; i < n; i++) a.push_back((int)(rng() % hi) + 1); } else { int L = p1 > 0 ? p1 : (int)(rng() % 33 + 8); int k = p2 > 0 ? p2 : (int)(rng() % 4 + 2); L = max(1, min(50, L)); for (int t = 0; t < k; t++) { int rest = L; while (rest > 0) { int piece = (int)(rng() % rest) + 1; a.push_back(piece); rest -= piece; } } while ((int)a.size() > 65) { int cut = 0, sum = 0; for (int i = 0; i < (int)a.size(); i++) { sum += a[i]; if (sum == L) { cut = i + 1; break; } } if (!cut) break; a.erase(a.begin(), a.begin() + cut); } shuffle(a.begin(), a.end(), rng); } return a;}
/** 跑一张 16 组合表;cap = 0 表示不设上限。返回节点数合计和「撞上限」组数。 */static void comboTable(const vector<vector<int>>& data, long long cap, long long tot[16], long long over[16]) { for (int m = 0; m < 16; m++) { tot[m] = over[m] = 0; for (const auto& in : data) { long long nd; int r = solveWith(in, m & 1, m & 2, m & 4, m & 8, nd, cap); tot[m] += nd; if (r < 0) over[m]++; } }}
int main(int argc, char** argv) { CSV = (argc > 1 && string(argv[1]) == "csv"); const vector<int> SAMPLE = {5, 2, 1, 5, 2, 1, 5, 2, 1}; const char* NAME[4] = {"A 排序", "B 跳等长", "C 首段失败", "D 补满失败"};
/* ① 官方样例 */ { long long na, ns; int ans = solveWith(SAMPLE, true, true, true, true, na); int slow = solveWith(SAMPLE, false, false, false, false, ns); if (!CSV) printf("① 官方样例:正解 %d(%lld 个节点)/ 一条剪枝都不加 %d(%lld 个节点)\n", ans, na, slow, ns); row("sample", {ans, na, slow, ns}); }
const int ROUNDS = 40; long long softTot[16], softOver[16], hardTot[16], hardOver[16];
/* ② 软数据:砍碎生成器 */ { vector<vector<int>> data; for (int s = 1; s <= ROUNDS; s++) data.push_back(gen(s, 0, 0, 0)); comboTable(data, 0, softTot, softOver); if (!CSV) { printf("\n② 软数据(砍碎生成器 %d 组):16 种剪枝组合的节点数合计\n", ROUNDS); for (int m = 0; m < 16; m++) printf(" A=%d B=%d C=%d D=%d %lld\n", m & 1, (m >> 1) & 1, (m >> 2) & 1, (m >> 3) & 1, softTot[m]); } row("soft", vector<long long>(softTot, softTot + 16)); }
/* ③ 硬数据:顶格随机(n = 65、长度 1~50),节点上限 20 万 */ const long long CAP = 200000; { vector<vector<int>> data; for (int s = 1; s <= ROUNDS; s++) data.push_back(gen(s, 1, 65, 50)); comboTable(data, CAP, hardTot, hardOver); if (!CSV) { printf("\n③ 硬数据(顶格随机 %d 组,n = 65、长度 1~50;节点上限 %lld)\n", ROUNDS, CAP); for (int m = 0; m < 16; m++) printf(" A=%d B=%d C=%d D=%d 节点 %-10lld 撞上限 %lld 组\n", m & 1, (m >> 1) & 1, (m >> 2) & 1, (m >> 3) & 1, hardTot[m], hardOver[m]); } row("hard", vector<long long>(hardTot, hardTot + 16)); row("hardOver", vector<long long>(hardOver, hardOver + 16)); }
/* ④ ★★★ 相加?相乘?两档数据给出相反的方向 */ if (!CSV) { printf("\n④ 每一条单独开 vs 四条一起开(万分比,越小越好)\n"); for (int t = 0; t < 2; t++) { const long long* T = t ? hardTot : softTot; printf(" %s:\n", t ? "硬数据" : "软数据"); double prod = 1; for (int i = 0; i < 4; i++) { int m = 1 << i; double r = T[m] * 1.0 / T[0]; prod *= r; printf(" 只开 %-10s %lld(%.4f 倍)\n", NAME[i], T[m], r); } printf(" 四条一起开 %lld(%.4f 倍);四个单独倍数**相乘**是 %.4f —— %s\n", T[15], T[15] * 1.0 / T[0], prod, T[15] * 1.0 / T[0] < prod ? "实测比相乘还好(互相搭台)" : "实测比相乘差(互相吃掉)"); } }
/* ⑤ ★★★ 长尾:顶格随机 200 组的分布 */ { const int R = 200; const long long TAIL_CAP = 1000000; if (!CSV) printf("\n⑤ 长尾:顶格随机 %d 组,正解的节点数分布(上限 %lld)\n", R, TAIL_CAP); vector<long long> out; const int HI[3] = {50, 50, 15}; const int NN[3] = {65, 30, 65}; for (int t = 0; t < 3; t++) { vector<long long> all; int over = 0; for (int s = 1; s <= R; s++) { long long nd; if (solveWith(gen(s, 1, NN[t], HI[t]), true, true, true, true, nd, TAIL_CAP) < 0) over++; all.push_back(nd); } sort(all.begin(), all.end()); out.push_back(all[R / 2]); out.push_back(over); if (!CSV) printf(" n = %2d、长度 1~%2d:中位数 %lld 个节点,撞上限的有 %d / %d 组\n", NN[t], HI[t], all[R / 2], over, R); } row("tail", out); }
/* ⑥ 「fail 写成全局」的错法 + 不单调反例 */ { if (!CSV) printf("\n⑥ 「fail 写成全局」那个剪过头的错法(每档 300 轮)\n"); vector<long long> got; // 砍碎档(默认 / 短原长 / 多根) + 顶格随机档 const int P[4][4] = {{0, 0, 0, 0}, {0, 12, 3, 0}, {0, 20, 6, 0}, {1, 65, 50, 0}}; // ⚠⚠ 这一段有一个坑,第一版就踩了:顶格随机那一档里**有一边会跑不完**, // 所以必须给它上限,并且**分清楚是谁跑不完** —— // 第一版把「正解超时 = -1、错法给了个数」也记成一次「抓到」,量出来 171,全是假的。 // ⇒ 三个数分开报:答案真的不同的轮数 / 错法撞上限的轮数 / 正解撞上限的轮数。 for (auto& p : P) { int bad = 0, badSlow = 0, refSlow = 0, past = 0, stuck = 0; for (int s = 1; s <= 300; s++) { vector<int> in = gen(s, p[0], p[1], p[2]); long long n1, n2; int r1 = solveWith(in, true, true, true, true, n1, 2000000); int r2 = solveWith(in, true, true, true, true, n2, 2000000, true); if (r1 < 0) refSlow++; if (r2 < 0) { badSlow++; // ★★★ 机制不能推,要量:它撞上限时正在试的是哪个 L? // 草稿猜的是「它把正确的 L 剪掉了,跑到更大的 L 上白搜」—— 实测**不是**。 // 它绝大多数时候**就卡在正确答案那个 L 上**:解被剪掉了,找不到, // 于是只能把这个 L 的整棵树穷举完才肯罢休 —— 而「证明无解」比「找到一个解」贵得多。 if (r1 >= 0 && lastCapL > r1) past++; if (r1 >= 0 && lastCapL == r1) stuck++; } if (r1 >= 0 && r2 >= 0 && r1 != r2) bad++; } got.push_back(bad); got.push_back(badSlow); got.push_back(refSlow); got.push_back(past); got.push_back(stuck); if (!CSV) printf(" mode %d p=(%d,%d):答案不同 %d 轮|★ 错法自己撞上限 %d 轮" "(卡在正确答案那个 L 上的 %d 轮,已经跑到更大的 L 上的 %d 轮)" "|正解撞上限 %d 轮(共 300)\n", p[0], p[1], p[2], bad, badSlow, stuck, past, refSlow); } row("wrongCatch", got);
vector<int> three = {2, 2, 2}; vector<long long> mono; for (int L : {2, 3, 6}) { Solver s; s.a = three; s.n = 3; s.L = L; s.need = 6 / L; s.used.assign(3, 0); mono.push_back(s.dfs(0, 0, 0) ? 1 : 0); } if (!CSV) printf("\n★ 三根长度 2 的木棍(sum = 6):L = 2 / 3 / 6 拼不拼得出 → %lld / %lld / %lld" "(⇒ 可行性对 L 不单调,不能二分)\n", mono[0], mono[1], mono[2]); row("monotone", mono); } return 0;}点「运行 ▶」看结果
// P1120 对拍生成器:`./p1120Gen <seed> [mode] [p1] [p2]`//// mode 0(默认)**砍碎**:造 p1 长的原始木棍 p2 根,每根随机砍碎,打乱输出。// ⇒ 一定拼得回去,答案 ≤ p1。(p1 / p2 留空则由 seed 决定)// mode 1 **顶格随机**:直接random 出 p1 根长度在 1 ~ p2 之间的木棍。// ⇒ 这就是题面允许的最一般的输入(n ≤ 65、a ≤ 50)。//// ★★★ 这两档必须都跑,而它们量出来的东西完全不同(页面第 ⑤ ⑥ 步):// mode 0 造出来的局面**几乎都是软的** —— 砍碎的段天然凑得回整根,// 一条剪枝都不加也能很快跑完。它适合数「每条剪枝值多少钱」这种干净的倍数。// mode 1 才碰得到这道题真正的样子:**中位数只有几十个节点,可长尾极长** ——// 300 组里有几组跑几百万个节点还出不来。//// ⚠ 而 mode 0 **不能自带答案**:砍碎之后可能存在比 p1 更小的原长// (极端情况全砍成 1,答案就是 1)。⇒ 对拍的参照物只能是另一份程序。
#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1; int mode = argc > 2 ? atoi(argv[2]) : 0; mt19937 rng(seed * 2654435761u + 12345u); vector<int> a;
if (mode == 1) { int n = argc > 3 ? atoi(argv[3]) : 65; int hi = argc > 4 ? atoi(argv[4]) : 50; n = max(1, min(65, n)); hi = max(1, min(50, hi)); for (int i = 0; i < n; i++) a.push_back((int)(rng() % hi) + 1); } else { int L = argc > 3 ? atoi(argv[3]) : 0; int k = argc > 4 ? atoi(argv[4]) : 0; if (L <= 0) L = (int)(rng() % 33 + 8); if (k <= 0) k = (int)(rng() % 4 + 2); L = max(1, min(50, L)); for (int t = 0; t < k; t++) { int rest = L; while (rest > 0) { int piece = (int)(rng() % rest) + 1; a.push_back(piece); rest -= piece; } } // ⚠ 题面保证 n ≤ 65:砍得太碎就整根整根地丢,剩下的仍然拼得回去 while ((int)a.size() > 65) { int cut = 0, sum = 0; for (int i = 0; i < (int)a.size(); i++) { sum += a[i]; if (sum == L) { cut = i + 1; break; } } if (!cut) break; a.erase(a.begin(), a.begin() + cut); } shuffle(a.begin(), a.end(), rng); }
printf("%d\n", (int)a.size()); for (size_t i = 0; i < a.size(); i++) printf("%d%c", a[i], i + 1 == a.size() ? '\n' : ' '); return 0;}点「运行 ▶」看结果
一条剪枝都不加的那几档在硬数据上根本不返回。
第 4 章 P1731 踩过一次:一个不退出的程序会把 check:viz 整个挂住,
而它看起来只是「有点慢」。⇒ 凡是要跑「故意很慢的那一版」的地方,都得先给它一个上限,
并且把「撞上限的组数」也当成一个可报告的结果。
9一页纸
| 关键的一步 | 枚举原长 L(只看 sum % L == 0)+ 四条剪枝的 DFS |
| 哪一版能过 | 四条全开那一版 p1120.cpp(⚠ 是「能过洛谷那组数据」,不是「对任意输入都快」) |
| 最容易写错的一行 | B 的 fail 写成全局 —— 剪过头,官方样例照样输出 6;砍碎档上它是 WA(9 / 300),顶格随机档上它一次都没答错、却有 176 / 300 跑不完 |
| 顺手挡掉的念头 | 不能二分:{2,2,2} 上可行的 L 是 {2, 6},中间缺了 3 |
| 这一页的主线 | 「几条剪枝合起来值多少」既不是相加也不是相乘,方向还跟着数据走: 软数据上互相吃掉(比相乘差 5.9 倍),硬数据上互相搭台(比相乘好 11.3 倍) |
| 最该记住的一个数 | 顶格随机 200 组:中位数 81 个节点,4 组一百万都出不来 —— 搜索题的耗时是一条长尾 |