题单 · 习题解析

洛谷 P1120 [CERC 1995] 小木棍

★★★ 几条剪枝合起来值多少,既不是相加也不是相乘 —— 方向还跟着数据走(软数据互相吃掉、硬数据互相搭台);外加「搜索题的耗时是一条长尾」

原题:洛谷 P1120出自 第 16 章 DFS 剪枝:可行性、最优性、搜索顺序 的题单题面本地存档:2026-08-28
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目背景

本题不保证存在可以通过满足本题数据范围的任意数据的做法。 可以通过此题的程序不一定完全正确(算法时间复杂度错误、或不保证正确性)。

本题为搜索题,本题不接受 hack 数据。

题目描述

乔治有一些同样长的小木棍,他把这些木棍随意砍成几段,直到每段的长都不超过 50。

现在,他想把小木棍拼接成原来的样子,但是却忘记了自己开始时有多少根木棍和它们的长度。

给出每段小木棍的长度,编程帮他找出原始木棍的最小可能长度。

输入格式

第一行是一个整数 n,表示小木棍的个数。 第二行有 n 个整数,表示各个木棍的长度 a[i]

输出格式

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

数据规模与约定

对于全部测试点,1 ≤ n ≤ 651 ≤ a[i] ≤ 50

输入输出样例

输入

9
5 2 1 5 2 1 5 2 1

输出

6

九根木棍 5 2 1 各三根,总长 24。原长 6 的话正好拼成四根(5+15+15+12+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 根往后挑。

p1120Slow.cpp第一版:一条剪枝都不加
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
这一版里有两件「几乎没人会漏」的事,先说清楚

它们不算剪枝,是写对的一部分:

  • 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 就一定可行。

可候选的 Lsum全部因数,而它们之间不都是倍数关系 —— sum = 6 时 2 和 3 谁也不是谁的倍数,于是「2 可行」推不出「3 可行」,那个洞就在这儿。 ⇒ 单调性只在倍数链上成立,在整条因数链上不成立。

这是第 7 章 P1873 那条判据的另一面:那道题「把输入打乱,答案不变」⇒ 问的是阈值、该二分; 这道题连「阈值」都不存在 —— 可行的 L 是一个有洞的集合

3★ 四条剪枝:A 排序 / B 跳等长 / C 首段失败 / D 补满失败

A. 从大到小排序。 大木棍能放的位置少、约束强,先放它就早点撞墙。 ⚠ 它还有一个副作用:排完序之后等长的木棍是挨着的,B 要靠这一条才成立。

p1120Sort.cpp第二版:+A 排序
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

B. 跳过和「刚失败的那根」等长的木棍。 刚把一根长度 5 的放进去、往下走失败了 —— 那么再拿另一根长度 5 的放进去,接下来面对的局面一模一样,必然也失败。

p1120Skip.cpp第三版:+B 跳等长
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

C 和 D 是两句反证,也是这道题最狠的两刀:

    C:  cur == 0 时试了一根却失败  ->  整个 L 都不可能,直接 return false
        (这根新木棍的第一段,换谁来当都一样 —— 剩下的木棍总得有人当第一段)

    D:  cur + a[i] == L 时失败      ->  也直接 return false
        (「正好补满」是最理想的收尾;它都不行,用更多零碎去凑更不行)
p1120.cpp★ 公认能过的那一版
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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 会一路污染回上层,让上层跳过本来该试的木棍。

p1120Wrong.cpp错法:剪过头
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 同一个 bug,换一档数据就换一种病:一档是 WA,一档是 TLE

它在样例上输出 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 的断言。

p1120Count.cpp度量程序
// 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; // 上一次撞上限时正在试的 L
static 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p1120Gen.cpp数据生成器
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 度量程序里那个「节点上限」不是装饰

一条剪枝都不加的那几档在硬数据上根本不返回第 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 组一百万都出不来 —— 搜索题的耗时是一条长尾