每台钟都需要一个坏夜晚
城里有一座钟楼。
钟楼的规矩很苛刻:无论哪一天,钟声都必须在一息之内落完。晴天如此,雨天如此,北风穿过齿轮缝隙、把铜针吹得像在犹豫人生的时候,也如此。
可钟楼有个毛病。它不是每天慢。
大多数日子,太阳刚露头,钟声已经响完。几个学徒随机挑日子看,挑来挑去,都是好日子。于是他们开始打磨最大的齿轮。最大的齿轮总在转,浑身油污,特别像罪犯。
打磨了一夜。
钟,快了半根,头发丝。
第二夜,学徒又去打磨弹簧。第三夜,换了润滑油。第四夜,他们规定,齿轮转满三十万次,强行停下。不再拖到天亮。
它干脆不报时了。
后来,守门的老人搬来一排沙漏。他把一年里所有可能让钟楼工作的夜晚写在纸上,一个一个放进去测。最慢的那张纸,钉到墙上。
从那以后,学徒每磨一刀,先看它。
他们仍然不知道下一把锉刀该用在哪里。
但至少,钟,终于有了,一个会反对他们的夜晚。
简化题面
┌─ [P1763] 埃及分数
├─ 平台: 洛谷
├─ 题意: 给定真分数 a/b,把它表示成若干个单位分数 1/x 之和。
├─ 输入: 一对整数 a、b。
├─ 输出: 一组严格递增的分母。先要求项数最少;项数相同时,末项分母尽量大。
├─ 数据范围: 时限 1 秒,内存 128 MiB。
├─ 关键限制: 分母严格递增,且输出须满足两层最优规则。
└─ 完整题面: https://www.luogu.com.cn/problem/P1763
P1763 不会告诉你哪一个分数最难拆。
最开始,我写的是一份很普通的逐层搜索。先试一项,试不到再试两项。每一步选下一个单位分数的分母,减掉它,继续处理剩下的真分数。
这套东西在 3/997 上很快。它给出:
3/997 = 1/345 + 1/14955 + 1/22931
于是 3/997 很容易被误会成一个可靠的朋友。
它不是。
先找那个会让你难堪的分数
不同分数会长出不同形状的搜索树。有些在浅层就找到答案,有些必须证明前几层根本无解;有些分母范围很窄,有些每一层都像给递归又开了一扇门。
如果随手挑一个输入计时,得到的不是程序性能。
运气。
写了一个扫描器。它让程序依次处理:
a = 2..100
b = 200..500
一共 17935 个组合。脚本不说原因,但记下当前最慢者。
原扫描器没有留在仓库。旧记录留下了它启动子进程、计时、刷新最慢输入的行为;下面是按这些可核实行为重建的核心控制流。候选集 cases 就是那 17935 个待测分数,生成时的具体筛选条件已经遗失,不在这里假装记得。
worst_time, worst_case = -1.0, None
for a, b in cases:
started = time.perf_counter()
subprocess.run(
["tmp.exe"],
input=f"{a} {b}\n",
text=True,
capture_output=True,
timeout=30,
)
elapsed = time.perf_counter() - started
if elapsed > worst_time:
worst_time, worst_case = elapsed, (a, b)
print(f"NEW MAX: {a}/{b} {elapsed:.3f}s")
它测的是一次完整启动、读入、运行、退出的墙钟时间,不是剥离进程启动开销后的精密微基准。这里不需要那种精度。所有候选都在同一台机器、同一种调用方式下接受同一把尺子;扫描器的任务不是为 gcd 判 0.3% 的生死,而是找出能把搜索树整个摊在桌上的输入。
历史记录是这样爬上去的:
57/229 0.632s
8/241 1.173s
17/421 6.135s
98/499 9.981s
98/499。
一张没有题面特征、没有样例光环的纸。它只是把搜索树拉到了第五层:前四层都得搜完才知道没戏,第五层又要在大量候选之间挑出符合优先级的解。
从这以后,优化才有了基线。
“我觉得这段递归很慢”, NO。
“它在 98/499 上要十秒,而时限是一秒”, YES。
事实(狮史)胜于雄辩(熊遍)。
高明的想法也得跑一次
最后两项都是单位分数时,可以把方程变形,再枚举因子对。 这个想法看起来很体面:少下一层递归,直接用数学收尾。
写完跑扫描器。
3/997 从大约 0.08 秒变成了 5.304 秒。
因子 is not free… 把递归尾部换成枚举因子,从一个地狱换到下一个地狱。对某些分数,后一个地狱待遇还要更差。
这次失败有点丢脸,但得到一个信息。不是“这个思路不够优雅”,不是“也许评测机不同”。它在固定输入上慢了六十多倍。
可以停了。
随后是 gcd。
递归里每走一步都约分,谁看都会觉得它碍眼。两版程序跑同一个坏例,历史记录里的差异约为 3%。目标还差十倍,这 3% 不是突破口。
可以停了。
这里的“停”很重要。优化最危险的地方,不是想不到点子。是已经为一个点子花了两小时以后,它会开始增加你的沉默成本。
固定坏例很有用。
它让一个想法失去继续占用你的权利。
总深度不能替剩余的人花钱
真正有效的一处错误,藏在搜索分母的上界里。
假设还剩 rem 项,当前剩余分数是 a/b。如果下一项分母为 x,剩余每一项最多贡献 1/x,因此必须有:
rem / x >= a / b
x <= rem * b / a
上界里的 rem 是剩余项数。
旧代码却用了总深度 d。
// 错误的历史版本
up = b * d / a;
// 应当使用
up = b * rem / a;
走到第三层时,前两项已经确定。它们不该继续替剩下的搜索扩大预算。总深度为 5、剩余只有 3 项,却按 5 项允许更大的分母范围,每层都多打开一些本来不存在的门。
历史记录里,修这一行后 95/499 从约 5.7 秒降到约 2.1 秒。
一行代码没有因为短而可疑。
它可疑,是因为它改写了搜索空间。它可信,是因为上界推导和同一坏例上的结果指向同一件事。
公式,说明,为什么能剪。
坏例,说明,这次剪到了哪里。
空输出不等于方向错误
后来又尝试了双重迭代:除了逐步增加分解项数,再逐步增加允许的末项分母界。 这个方向第一次跑出来,什么都没有。
空输出很有诱惑力。
它像一句完整的判词:看吧,这个思路不行。
实际的 bug 是,程序找到了一个超过当前界的候选解,也把外层循环标成成功,于是提前退出。
这也是为什么“跑一下”不等于反馈回路。
一份像样的反馈至少分开两件事:
- 它是否比基线快;
- 它是否仍然在解同一道题。
P1763 还有一层麻烦。只检查输出的分数和是否正确,只能证明它分解出了原分数;不能自动证明项数最少,也不能证明末项分母已经按规则最大。 性能计时、合法检查、最优性检查,是三不同的沙漏。
一句“过了测试”,很容易再造一座钟楼。
当前保存的源码是较简洁的 IDDFS 版本,不是历史记录中达到约 0.31 秒的最终组合版本。那份最终源码和原始计时日志没有留存,所以 0.31 秒只能作为当时的记录,不该被今天的文章伪装成复现结果。
这不是遗憾的注脚。
它正好说明了那座钟楼后来还缺一件东西:不仅要有最慢的夜晚,还要留下那一夜的测量方式。
下一次遇到 TLE,先别问“哪一行最慢”。
先问:哪一个输入,能让我在改错时立刻难堪?
找到它以后,程序才开始真正参与讨论。
它不会给建议。
它只会在你自信的时候,响得格外慢。

