阶段 3 · 搜索 · 第 16 章

DFS 剪枝:可行性、最优性、搜索顺序

搜索题的分数几乎全部来自剪枝。而剪枝是有套路的 —— 三板斧,一次讲完。

例题:小猫爬山 建议用时:110 分钟
第 4 章埋的伏笔,这一章收回来

N 皇后里那句 if (col[c] || d1[...] || d2[...]) continue; 就是最基础的剪枝。

这一章把剪枝系统化成三板斧,并且用一张实测表告诉你每一招值多少钱 —— 其中有一条结论相当反直觉,第 6 步会看到。

搜索题在竞赛里的地位很特殊:不会做的题,写个搜索至少能骗到部分分; 而会不会剪枝,决定你骗到 30 分还是 100 分。

1 一句话问题

n 只猫要坐缆车下山,第 i 只重 w[i]。每辆缆车载重上限 W, 一辆车可以载任意多只猫,只要总重不超过 W最少要几辆车?

输入 5 100
     60 40 50 30 20
输出 2

     [60 40] 和 [50 30 20],两辆车正好
✓ 这题为什么不能贪心

「从大到小,能塞就塞」听起来很合理,但它会错。

反例:W = 10,猫重 6 5 5 4。 贪心:6 装一车(放不下 5),5+5 一车,4 一车 → 3 辆。 最优:6+45+52 辆

这是经典的装箱问题,已知没有多项式时间的精确算法 —— 所以只能搜索。而搜索能不能过,就全看剪枝了。

2 搜索的形状

一只一只地安排猫。第 i 只猫有两类选择:

  1. 塞进某一辆已经开出去的车(如果装得下)
  2. 新开一辆车

全部安排完,用了几辆车就是一个候选答案。这就是第 4 章那个框架:

void dfs(int i) {
    if (i == n) { best = min(best, carCount); return; }

    for (int j = 0; j < carCount; j++)         // 选择一:塞进已有的车
        if (load[j] + w[i] <= W) {
            load[j] += w[i];  dfs(i + 1);  load[j] -= w[i];    // 进入→递归→撤销
        }

    load[carCount++] = w[i];                   // 选择二:新开一辆
    dfs(i + 1);
    load[--carCount] = 0;
}
brute.cpp不加剪枝
输入(stdin)
输出
点「运行 ▶」看结果

3 实测:不剪枝有多惨

同题对比:不加剪枝 vs 剪枝全开
载重 100,每只猫 20~60 —— 一辆车装 2~4 只,搜索树最茂密。跑完把 n 改成 19、20 试试(不剪枝那份会分别要 15 秒和 78 秒)。
不加剪枝
剪枝全开

本机实测:

n不加剪枝剪枝全开
170.75 秒0.003 秒
183.42 秒0.003 秒
1914.9 秒0.003 秒
2077.9 秒0.004 秒

n 每加 1,不剪枝的版本就慢四五倍;而剪枝版本几乎没有反应。

4 ★ 剪枝三板斧

★ 关键的一步

【1】可行性剪枝 —— 「这条路走下去一定违规」

if (load[j] + w[i] <= W)     // 装不下就根本不进这个分支

最基本的一类,brute.cpp 里就有。凡是「走下去必然不合法」的分支,立刻掐掉。

【2】最优性剪枝 —— 「这条路走下去一定不如已有答案」

if (carCount >= best) return;    // 已经追平最优了,再搜也不可能更好

注意它有个前提:必须先有一个答案。所以要让搜索尽快摸到一个像样的解 —— 这就引出第三板斧。

【3】搜索顺序 —— 「先走最容易失败 / 最容易出好结果的分支」

sort(w.begin(), w.end(), greater<long long>());   // 先安排重的猫

重的猫选择少、最容易失败,也最容易把车「填实」。把它们放在前面, 搜索的第一条路径通常就相当不错,best 立刻变小。

5 正解

fast.cpp三板斧全上
输入(stdin)
输出
点「运行 ▶」看结果

搜索的形状一点没变,只多了一句 if (carCount >= best) return; 和一行排序。

trace.cpp过程演示
✂ 是最优性剪枝,✗ 是可行性剪枝。最后一行会告诉你进了多少次 dfs、剪了多少次。
输入(stdin)
输出
点「运行 ▶」看结果

6 ★ 每一招到底值多少钱(这里有个反直觉的结论)

count.cpp四种组合并排数
四种剪枝组合各跑一遍,把搜索节点数和耗时并排列出来。跑一次就够,很快。
输入(stdin)
输出
点「运行 ▶」看结果

本机实测(n = 17,W = 100):

组合答案搜索节点数耗时
① 什么都不加7203 232 7881084 毫秒
② 只加最优性剪枝764 2260.40 毫秒
③ 只把猫从重到轻排序7172 139 308552 毫秒
④ 两个都加71 7990.01 毫秒
★ 对比 ③ 和 ④:搜索顺序不是剪枝,是剪枝的放大器

先看 ③:只排序、不加最优性剪枝,几乎没用(2.03 亿 → 1.72 亿)。

为什么?因为排序本身不剪掉任何分支,它只是换了个走的顺序。 整棵搜索树该多大还是多大。

再看 ④:排序 + 最优性剪枝 = 1 799 个节点,比只加最优性剪枝的 ② 又快了三十多倍

原因:先安排重的猫,搜索的第一条路径就已经接近最优,best 立刻降到很小的值; 而 best 越小,if (carCount >= best) return; 这一刀砍得越狠。

结论:搜索顺序单独用没意义,它是用来让别的剪枝提前生效的。 这是很多人学剪枝时想不通的一点 —— 记住它,会省你很多调试时间。

✓ 还有一个必须注意的事实

四种组合的答案完全一样,都是 7。

剪枝不会改变答案,它只砍掉「不可能更优」和「必然违规」的分支。 如果你加了个剪枝之后答案变了,那不是剪枝生效了,是你剪错了 —— 剪掉了本来可能是最优解的分支。

这也是为什么这一章的对拍如此重要:它专门用来抓「剪过头」。

7 单步玩剪枝开关

剪枝三板斧:把开关关掉看看差多少
第 1 / 42 步
猫 55 新开一辆车(第 0 号)
进入 dfs 次数
1
剪掉
0
当前最优
6
四种组合的搜索节点数(同一组数据)
① 什么都不加158
② 只加最优性剪枝24
③ 只从重到轻排序156
④ 两个都加21
对比 ③ 和 ④:光排序几乎没用,排序 + 最优性剪枝才断崖式下跌。搜索顺序不是剪枝,是剪枝的放大器。
前面的车都试过了,给这只猫新开一辆。

上面那两个按钮就是开关。同一组数据,四种组合的节点数实时列在右边。

建议这样玩一遍:

  1. 两个都关 → 记下节点数
  2. 只开「从重到轻」→ 几乎没变(就是上一步说的那个反直觉结论)
  3. 只开「最优性剪枝」→ 断崖式下跌
  4. 两个都开 → 再降一个数量级

然后把猫的重量改成全都很接近 W(比如 90 85 95 88 92), 看看节点数变化 —— 那时几乎一猫一车,搜索树反而很浅。

8 ★ 对拍:专门抓「剪过头」

★ 正确的用法

把「剪枝全开」那一栏换成你自己写的(包括你自己想出来的额外剪枝),再点开始。

这个对拍的意义和前面几章不太一样:它不是在验证「你写得快不快」, 而是在验证「你有没有剪掉本来该要的答案」。

剪枝写错了不会报错、不会超时,只会安安静静地给出一个偏大的答案。 不对拍根本发现不了。

对拍器
生成器造三种分布:猫都很重(几乎一猫一车)、猫都很轻(一车装很多)、轻重混合(最考验搭配)。n 只到 11,因为不剪枝那份是指数级的。

值得故意写错的,每一个都是「剪过头」的典型:

  • if (carCount >= best) return; 写成 > → 剪得不够,慢但答案对(这个反而无害)
  • if (carCount > best) return; 改成 if (carCount + 1 >= best) return; → 剪过头了,答案偏大 —— 对拍立刻抓住
  • 加一个「看起来对」的剪枝:如果剩下的猫总重 > 剩余车的空余容量就返回 → 想清楚「剩余车」指什么,很容易写错
  • 排序写成从轻到重 → 答案对,但慢得多(对拍抓不住,只有 count.cpp 能看出来)

9 剪枝的通用思考方式

★ 拿到一道搜索题,按顺序问自己这四句
  1. 这条分支会不会违规?(可行性剪枝) —— 最容易想到,通常写代码时自然就带上了。

  2. 这条分支还有可能比现有答案更好吗?(最优性剪枝) —— 需要一个「当前最优」和一个「乐观估计」。 比如「现在已经用了 5 辆车,而最优是 5 辆」→ 不可能更好。 更强的版本会算「就算后面全都完美,最少还要几辆」—— 这叫估价函数,是 A* 和 IDA* 的核心。

  3. 先搜哪个分支,能让上面两条更早生效?(搜索顺序) —— 通常是「选择最少的」「限制最紧的」优先。

  4. 有没有重复的状态?(记忆化 / 判重) —— 如果两条不同的路径到达了完全相同的局面,就没必要搜两遍。 这就是第 17 章记忆化搜索。

⚠ 一个常见误区:剪枝不是优化常数

剪枝改变的是搜索树的大小,不是每一步的速度。

所以不要指望「把 vector 换成数组」「少写一次函数调用」能救回一个不剪枝的搜索 —— 那是在 2 亿个节点上省常数,而剪枝直接把它变成不到 2 千个。

先想清楚剪什么,再考虑常数。 顺序反了就是白费力气。

10 自测

本章的原题是「小猫爬山」(AcWing 165 / 《算法竞赛进阶指南》), 洛谷上没有完全对应的题号,所以下面换了几道同类的剪枝题。

自测清单0 / 8
配套练习
  • 洛谷 P1074 靶形数独 —— NOIP2009。搜索顺序剪枝的教科书 —— 先填候选最少的格子,和本章「先安排重的猫」是同一个道理
  • 洛谷 P1120 小木棍 —— 剪枝的经典硬骨头,要用到五六种剪枝。做不出来很正常,把题解里每个剪枝都想明白就是收获
  • 洛谷 P1731 生日蛋糕 —— NOI1999。最优性剪枝的教科书,需要预处理「最小体积/面积」当估价函数
  • 洛谷 P1518 两只塔姆沃斯牛 —— 换换脑子:状态是「两头牛的位置和朝向」,用第 15 章的状态图思路
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

第 17 章记忆化搜索,正好回答上面第 4 问:如果两条路径走到了同一个状态呢?

那一章你已经学过了(它是阶段 3 的枢纽)。如果当时是跳着看的, 现在带着「剪枝」的眼光回去重读一遍 —— 你会发现记忆化其实就是第四种剪枝:把「算过的状态」整个剪掉