阶段 3 · 搜索 · 第 16 章普及组 J

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

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

需要先学:第 4 章 回溯与状态恢复:N 皇后、第 13 章 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

第一行是猫的只数 n 和载重上限 W,第二行是每只猫的体重。

[60 40] 和 [50 30 20] 各 100,两辆车正好装满。 ⚠ 程序只输出车数,不输出怎么分。

✓ 这题为什么不能贪心

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

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

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

2搜索的形状

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

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

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

int n;  long long W;                // 猫数 / 每辆车的载重上限
vector<long long> w, load;          // w[i] = 第 i 只猫多重;load[j] = 第 j 辆车已经装了多重
int carCount = 0, best;             // 已经开出去几辆车 / 目前最优

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不加剪枝
// 小猫爬山 —— 朴素 DFS,一个剪枝都不加
//
// 输入:第一行 n W(n 只猫,每辆缆车最大载重 W)
// 第二行 n 个整数,第 i 只猫的重量 w[i](保证 w[i] <= W)
// 输出:最少需要几辆缆车
//
// 每辆车可以载任意多只猫,只要总重不超过 W。
//
// 搜索的形状(第 4 章那个框架):
// 一只一只地安排猫。第 i 只猫有两类选择:
// 1. 塞进某一辆**已经开出去的**车(如果装得下)
// 2. 新开一辆车
// 全部安排完之后,用了几辆车就是一个候选答案。
//
// 这份代码**除了「装不下就不能塞」之外没有任何剪枝**,
// 所以它会把整棵搜索树走完。n 一大就彻底跑不动。
//
// 它是正确的,所以可以当对拍的标准答案 —— 但只能喂小数据。
#include <bits/stdc++.h>
using namespace std;
int n;
long long W;
vector<long long> w;
vector<long long> load; // load[j] = 第 j 辆车已经装了多重
int carCount = 0; // 已经开出去几辆车
int best;
long long nodes = 0; // 统计一下总共进了多少次 dfs
void dfs(int i) {
nodes++;
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]; // 撤销(第 4 章的三段式)
}
}
// 选择二:新开一辆车
load[carCount] = w[i];
carCount++;
dfs(i + 1);
carCount--;
load[carCount] = 0;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> W)) return 0;
w.assign(n, 0);
for (int i = 0; i < n; i++) cin >> w[i];
load.assign(n + 1, 0);
best = n; // 最坏情况:一只猫一辆车
if (n > 0) dfs(0);
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3实测:不剪枝有多惨

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

本机实测:

n 不加剪枝 剪枝全开
17 0.75 秒 0.003 秒
18 3.42 秒 0.003 秒
19 14.9 秒 0.003 秒
20 77.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三板斧全上
// 小猫爬山 —— 同样的搜索,加满剪枝
//
// 输入输出和 brute.cpp 完全一样。**搜索的形状一点没变**,
// 变的只是「哪些分支根本不用走」。
//
// ============ 剪枝三板斧 ============
//
// 【1】搜索顺序:**先安排重的猫**(把 w 从大到小排序)
// 重的猫选择少、最容易失败。把它们放在前面,错误的分支会**很早**就撞墙,
// 而不是等到搜到底才发现不行。
// ⚠ 注意:排序本身**不剪掉任何分支**,单独用几乎没有效果(跑 count.cpp 看③)。
// 它的作用是「放大器」:让搜索很早就摸到一个不错的解,best 迅速变小,
// 于是下面那条最优性剪枝变得凶狠得多。**两者要配合用。**
//
// 【2】最优性剪枝:**当前车数已经 >= 已知最优解,立刻返回**
// 再往下搜,最好的结果也只是追平,不可能更好,那就没必要搜。
// 注意它依赖「已经找到过一个解」—— 所以要先让搜索尽快摸到一个可行解
// (靠【1】的排序,第一条路径通常就相当不错)。
//
// 【3】可行性剪枝:**装不下就不进这个分支**
// 这个 brute.cpp 里就有(`load[j] + w[i] <= W`),
// 它是最基本的一类:这条路走下去必然违反规则,不用走。
//
// 三板斧的通用说法:
// 可行性剪枝 = 「这条路走下去一定违规」
// 最优性剪枝 = 「这条路走下去一定不如已有答案」
// 搜索顺序 = 「先走最容易失败 / 最容易出好结果的分支」
//
// 到底能省多少?跑一下 count.cpp,它把四种组合的搜索节点数并排数给你看。
#include <bits/stdc++.h>
using namespace std;
int n;
long long W;
vector<long long> w, load;
int carCount = 0, best;
void dfs(int i) {
if (carCount >= best) return; // 【2】最优性剪枝
if (i == n) { best = carCount; return; }
for (int j = 0; j < carCount; j++) {
if (load[j] + w[i] <= W) { // 【3】可行性剪枝
load[j] += w[i];
dfs(i + 1);
load[j] -= w[i];
}
}
load[carCount] = w[i];
carCount++;
dfs(i + 1);
carCount--;
load[carCount] = 0;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> W)) return 0;
w.assign(n, 0);
for (int i = 0; i < n; i++) cin >> w[i];
sort(w.begin(), w.end(), greater<long long>()); // 【1】搜索顺序:从重到轻
load.assign(n + 1, 0);
best = n;
if (n > 0) dfs(0);
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

trace.cpp过程演示
✂ 是最优性剪枝,✗ 是可行性剪枝。最后一行会告诉你进了多少次 dfs、剪了多少次。
// 小猫爬山 —— 把搜索过程和每一次剪枝打印出来
//
// 输入:n W / n 个重量(用小数据,n <= 8)
// 输出:每一步「把第 i 只猫放进第几辆车」,以及每一次剪枝的原因
//
// 盯住两种行:
// ✂ 最优性剪枝 —— 车数已经追平当前最优,再往下搜没有意义,整片子树砍掉
// ✗ 装不下 —— 可行性剪枝,这一辆车塞不下这只猫
//
// 把输入的猫按不同顺序给(比如手动改成从轻到重),
// 再对比一下「进入 dfs 的次数」—— 顺序真的会差很多。
#include <bits/stdc++.h>
using namespace std;
int n;
long long W;
vector<long long> w, load;
int carCount = 0, best;
long long nodes = 0, cuts = 0;
void indent(int d) { for (int i = 0; i < d; i++) cout << "| "; }
void dfs(int i) {
nodes++;
if (carCount >= best) {
cuts++;
indent(i);
cout << "✂ 已经用了 " << carCount << " 辆车,不比当前最优(" << best
<< " 辆)更好 —— 这一整片分支不用看了\n";
return;
}
if (i == n) {
indent(i);
cout << "★ 全部安排完,用了 " << carCount << " 辆车";
if (carCount < best) { cout << " —— 刷新最优!"; best = carCount; }
cout << "\n";
return;
}
for (int j = 0; j < carCount; j++) {
indent(i);
cout << "猫 " << i << "(重 " << w[i] << ")试第 " << j << " 号车(已装 " << load[j] << "):";
if (load[j] + w[i] <= W) {
cout << "装得下 ✓\n";
load[j] += w[i];
dfs(i + 1);
load[j] -= w[i];
} else {
cuts++;
cout << "✗ 装不下(" << load[j] << " + " << w[i] << " > " << W << ")\n";
}
}
indent(i);
cout << "猫 " << i << " 新开一辆车(第 " << carCount << " 号)\n";
load[carCount] = w[i];
carCount++;
dfs(i + 1);
carCount--;
load[carCount] = 0;
}
int main() {
if (!(cin >> n >> W)) return 0;
if (n <= 0 || n > 8) { cout << "这份是用来看过程的,请用 1 <= n <= 8\n"; return 0; }
w.assign(n, 0);
for (int i = 0; i < n; i++) cin >> w[i];
sort(w.begin(), w.end(), greater<long long>());
cout << "从重到轻排序后:";
for (int i = 0; i < n; i++) cout << w[i] << " ";
cout << " 载重上限 " << W << "\n\n";
load.assign(n + 1, 0);
best = n;
dfs(0);
cout << "\n答案:" << best << " 辆车\n";
cout << "进入 dfs 的次数:" << nodes << " 被剪掉的分支:" << cuts << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

count.cpp四种组合并排数
四种剪枝组合各跑一遍,把搜索节点数和耗时并排列出来。跑一次就够,很快。
// 剪枝到底省了多少 —— 四种组合并排数给你看
//
// 输入:n W / n 个重量(用中等规模,n = 14 ~ 17 最有观感)
// 输出:一张表,四种剪枝组合各自访问了多少个搜索节点、用了多久
//
// ① 什么都不加
// ② 只加最优性剪枝
// ③ 只加搜索顺序(从重到轻)
// ④ 两个都加
//
// 这份代码不解新题,它只是把「剪枝值多少钱」变成具体数字。
//
// ★ 请特别对比 ③ 和 ④ 这两行,它们说的是一件很反直觉的事:
//
// ③ 只排序、不加最优性剪枝 —— 几乎没用(整棵树照样走完)。
// 因为排序本身**不剪掉任何分支**,它只是换了个走的顺序。
// ④ 排序 + 最优性剪枝 —— 比只加最优性剪枝(②)又快了十倍以上。
//
// 结论:**搜索顺序不是剪枝,它是剪枝的放大器。**
// 先安排重的猫,能让搜索很早就摸到一个不错的解,
// 于是 best 很快变小,最优性剪枝随之变得凶狠。
// 两者单独都不够,配合起来才厉害。
#include <bits/stdc++.h>
using namespace std;
int n;
long long W;
vector<long long> w0, w, load;
int carCount, best;
long long nodes;
bool useBestCut;
void dfs(int i) {
nodes++;
if (useBestCut && carCount >= best) return;
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];
carCount++;
dfs(i + 1);
carCount--;
load[carCount] = 0;
}
/** 跑一遍,返回 {答案, 节点数, 毫秒} */
void runOnce(bool sortDesc, bool bestCut, int& ansOut, long long& nodesOut, double& msOut) {
w = w0;
if (sortDesc) sort(w.begin(), w.end(), greater<long long>());
load.assign(n + 1, 0);
carCount = 0;
best = n;
nodes = 0;
useBestCut = bestCut;
auto t0 = chrono::steady_clock::now();
if (n > 0) dfs(0);
auto t1 = chrono::steady_clock::now();
ansOut = best;
nodesOut = nodes;
msOut = chrono::duration<double, milli>(t1 - t0).count();
}
int main() {
if (!(cin >> n >> W)) return 0;
if (n <= 0 || n > 20) { cout << "请用 1 <= n <= 20(不加剪枝那一栏会跑不完)\n"; return 0; }
w0.assign(n, 0);
for (int i = 0; i < n; i++) cin >> w0[i];
struct Row { const char* name; bool sortDesc, bestCut; };
Row rows[4] = {
{"① 什么都不加 ", false, false},
{"② 只加最优性剪枝 ", false, true},
{"③ 只把猫从重到轻排序", true, false},
{"④ 两个都加 ", true, true},
};
cout << "n = " << n << ",载重上限 W = " << W << "\n\n";
cout << "组合 答案 搜索节点数 耗时(ms)\n";
cout << "---------------------- ------ -------------- --------------\n";
long long baseNodes = -1;
for (auto& r : rows) {
int ans; long long nd; double ms;
runOnce(r.sortDesc, r.bestCut, ans, nd, ms);
if (baseNodes < 0) baseNodes = nd;
cout << r.name << setw(8) << ans << setw(16) << nd
<< setw(16) << fixed << setprecision(2) << ms << "\n";
}
int ans; long long nd; double ms;
runOnce(true, true, ans, nd, ms);
cout << "\n全开之后,搜索节点数是原来的 1/" << (nd > 0 ? baseNodes / nd : 0) << " 左右。\n";
cout << "注意:四种组合的**答案完全一样** —— 剪枝只砍掉了不可能更优的分支,\n";
cout << " 它不会改变答案,只会让你更快地拿到它。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

组合 答案 搜索节点数 耗时
① 什么都不加 7 203 232 788 1084 毫秒
② 只加最优性剪枝 7 64 226 0.40 毫秒
③ 只把猫从重到轻排序 7 172 139 308 552 毫秒
④ 两个都加 7 1 799 0.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,因为不剪枝那份是指数级的。
// 小猫爬山 —— 同样的搜索,加满剪枝
//
// 输入输出和 brute.cpp 完全一样。**搜索的形状一点没变**,
// 变的只是「哪些分支根本不用走」。
//
// ============ 剪枝三板斧 ============
//
// 【1】搜索顺序:**先安排重的猫**(把 w 从大到小排序)
// 重的猫选择少、最容易失败。把它们放在前面,错误的分支会**很早**就撞墙,
// 而不是等到搜到底才发现不行。
// ⚠ 注意:排序本身**不剪掉任何分支**,单独用几乎没有效果(跑 count.cpp 看③)。
// 它的作用是「放大器」:让搜索很早就摸到一个不错的解,best 迅速变小,
// 于是下面那条最优性剪枝变得凶狠得多。**两者要配合用。**
//
// 【2】最优性剪枝:**当前车数已经 >= 已知最优解,立刻返回**
// 再往下搜,最好的结果也只是追平,不可能更好,那就没必要搜。
// 注意它依赖「已经找到过一个解」—— 所以要先让搜索尽快摸到一个可行解
// (靠【1】的排序,第一条路径通常就相当不错)。
//
// 【3】可行性剪枝:**装不下就不进这个分支**
// 这个 brute.cpp 里就有(`load[j] + w[i] <= W`),
// 它是最基本的一类:这条路走下去必然违反规则,不用走。
//
// 三板斧的通用说法:
// 可行性剪枝 = 「这条路走下去一定违规」
// 最优性剪枝 = 「这条路走下去一定不如已有答案」
// 搜索顺序 = 「先走最容易失败 / 最容易出好结果的分支」
//
// 到底能省多少?跑一下 count.cpp,它把四种组合的搜索节点数并排数给你看。
#include <bits/stdc++.h>
using namespace std;
int n;
long long W;
vector<long long> w, load;
int carCount = 0, best;
void dfs(int i) {
if (carCount >= best) return; // 【2】最优性剪枝
if (i == n) { best = carCount; return; }
for (int j = 0; j < carCount; j++) {
if (load[j] + w[i] <= W) { // 【3】可行性剪枝
load[j] += w[i];
dfs(i + 1);
load[j] -= w[i];
}
}
load[carCount] = w[i];
carCount++;
dfs(i + 1);
carCount--;
load[carCount] = 0;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> W)) return 0;
w.assign(n, 0);
for (int i = 0; i < n; i++) cin >> w[i];
sort(w.begin(), w.end(), greater<long long>()); // 【1】搜索顺序:从重到轻
load.assign(n + 1, 0);
best = n;
if (n > 0) dfs(0);
cout << best << "\n";
return 0;
}
点一下即可编辑

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

  • if (carCount >= best) return; 写成 > → 剪得不够,慢但答案对(这个反而无害)
  • if (carCount >= best) return; 改成 if (carCount + 1 >= best) return; → 剪过头了,答案偏大 —— 对拍立刻抓住 (⚠ 两条的出发点都是同一句 >=,就是 fast.cpp 和上面第 4 步里的那一句; 别拿上一条改坏的 > 当基准接着改)
  • 加一个「看起来对」的剪枝:如果剩下的猫总重 > 剩余车的空余容量就返回 → 想清楚「剩余车」指什么,很容易写错
  • 排序写成从轻到重 → 答案对,但慢得多(对拍抓不住,只有 count.cpp 能看出来)

9剪枝的通用思考方式

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

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

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

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

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

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

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

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

10自测

本章的原题是「小猫爬山」(AcWing 165 / 《算法竞赛进阶指南》)。

⚠ 这道题没法直接给你一个能交的链接:AcWing 要登录才看得到题面(实测点进去会跳登录页), 洛谷上也没有完全对应的题号。所以本章例题的「交题」这一步,就在这一页上完成 —— 上面的运行按钮和对拍器就是干这个的:把你默写的那份贴进去,400 轮随机数据比对, 比交一次「Accepted」告诉你的多得多。 下面这几道是同类的剪枝题,用来练手,它们都能在洛谷上交。

自测清单0 / 8
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
下一章预告

本章三板斧剪掉的都是注定不可能更优的分支。第 17 章记忆化搜索补上另外半边: 如果两条不同的路径走到了同一个状态呢?那个分支不是不可能,是已经算过一遍了。

它的例题是数字三角形:整个问题一共才几百个格子,暴力却要跑到秒级 —— 时间全花在把同一个格子重算无数遍上。那一章会把「几百」和「无数」这两个数都数出来给你看。 你可以把记忆化当成第四种剪枝:把「算过的状态」整个剪掉。