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 只猫有两类选择:
- 塞进某一辆已经开出去的车(如果装得下)
- 新开一辆车
全部安排完,用了几辆车就是一个候选答案。这就是第 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;
}
// 小猫爬山 —— 朴素 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;}点「运行 ▶」看结果
3实测:不剪枝有多惨
本机实测:
| 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正解
// 小猫爬山 —— 同样的搜索,加满剪枝//// 输入输出和 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; 和一行排序。
// 小猫爬山 —— 把搜索过程和每一次剪枝打印出来//// 输入: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;}点「运行 ▶」看结果
6★ 每一招到底值多少钱(这里有个反直觉的结论)
// 剪枝到底省了多少 —— 四种组合并排数给你看//// 输入: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;}点「运行 ▶」看结果
本机实测(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单步玩剪枝开关
| ① 什么都不加 | 158 |
| ② 只加最优性剪枝 | 24 |
| ③ 只从重到轻排序 | 156 |
| ④ 两个都加 | 21 |
上面那两个按钮就是开关。同一组数据,四种组合的节点数实时列在右边。
建议这样玩一遍:
- 两个都关 → 记下节点数
- 只开「从重到轻」→ 几乎没变(就是上一步说的那个反直觉结论)
- 只开「最优性剪枝」→ 断崖式下跌
- 两个都开 → 再降一个数量级
然后把猫的重量改成全都很接近 W(比如 90 85 95 88 92),
看看节点数变化 —— 那时几乎一猫一车,搜索树反而很浅。
8★ 对拍:专门抓「剪过头」
把「剪枝全开」那一栏换成你自己写的(包括你自己想出来的额外剪枝),再点开始。
这个对拍的意义和前面几章不太一样:它不是在验证「你写得快不快」, 而是在验证「你有没有剪掉本来该要的答案」。
剪枝写错了不会报错、不会超时,只会安安静静地给出一个偏大的答案。 不对拍根本发现不了。
// 小猫爬山 —— 同样的搜索,加满剪枝//// 输入输出和 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剪枝的通用思考方式
-
这条分支会不会违规?(可行性剪枝) —— 最容易想到,通常写代码时自然就带上了。
-
这条分支还有可能比现有答案更好吗?(最优性剪枝) —— 需要一个「当前最优」和一个「乐观估计」。 比如「现在已经用了 5 辆车,而最优是 5 辆」→ 不可能更好。 更强的版本会算「就算后面全都完美,最少还要几辆」—— 这叫估价函数,是 A* 和 IDA* 的核心。
-
先搜哪个分支,能让上面两条更早生效?(搜索顺序) —— 通常是「选择最少的」「限制最紧的」优先。
-
有没有重复的状态?(记忆化 / 判重) —— 如果两条不同的路径到达了完全相同的局面,就没必要搜两遍。 这就是第 17 章记忆化搜索。
剪枝改变的是搜索树的大小,不是每一步的速度。
所以不要指望「把 vector 换成数组」「少写一次函数调用」能救回一个不剪枝的搜索 ——
那是在 2 亿个节点上省常数,而剪枝直接把它变成不到 2 千个。
先想清楚剪什么,再考虑常数。 顺序反了就是白费力气。
10自测
本章的原题是「小猫爬山」(AcWing 165 / 《算法竞赛进阶指南》)。
⚠ 这道题没法直接给你一个能交的链接:AcWing 要登录才看得到题面(实测点进去会跳登录页), 洛谷上也没有完全对应的题号。所以本章例题的「交题」这一步,就在这一页上完成 —— 上面的运行按钮和对拍器就是干这个的:把你默写的那份贴进去,400 轮随机数据比对, 比交一次「Accepted」告诉你的多得多。 下面这几道是同类的剪枝题,用来练手,它们都能在洛谷上交。
- 洛谷 P1074 靶形数独解析 → —— NOIP2009。搜索顺序剪枝的教科书 —— 先填候选最少的格子,和本章「先安排重的猫」是同一个道理
- 洛谷 P1120 小木棍解析 → —— 剪枝的经典硬骨头,要用到五六种剪枝。做不出来很正常,把题解里每个剪枝都想明白就是收获
- 洛谷 P1731 生日蛋糕解析 → —— NOI1999。最优性剪枝的教科书,需要预处理「最小体积/面积」当估价函数
- 洛谷 P1518 两只塔姆沃斯牛解析 → —— 换换脑子:状态是「两头牛的位置和朝向」,用第 15 章的状态图思路
本章三板斧剪掉的都是注定不可能更优的分支。第 17 章记忆化搜索补上另外半边: 如果两条不同的路径走到了同一个状态呢?那个分支不是不可能,是已经算过一遍了。
它的例题是数字三角形:整个问题一共才几百个格子,暴力却要跑到秒级 —— 时间全花在把同一个格子重算无数遍上。那一章会把「几百」和「无数」这两个数都数出来给你看。 你可以把记忆化当成第四种剪枝:把「算过的状态」整个剪掉。