第 1 章教你信任函数,第 2 章教你分解,第 3 章告诉你递归就是在决策树上做深度优先遍历。
这一章把三样东西合起来用,并且第一次动那件第 3 章欠着的事 —— 二进制枚举做不到、而递归能做到的那件事:剪枝。
学完这一章,阶段 3 的搜索你已经会一大半了。DFS 就是这个,只是换了张地图。
1 一句话问题
在 n × n 的棋盘上放 n 个皇后,要求谁也攻击不到谁,问有多少种放法。
皇后的攻击范围是:同一行、同一列、同一条对角线(两个方向都算)。
输入 4
输出 2
这两种:
. Q . . . . Q .
. . . Q Q . . .
Q . . . . . . Q
. . Q . . Q . .
2 先用纸笔手算一遍
在纸上画个 4×4 的格子,自己试着摆一遍。摆的过程中你几乎一定会做这两件事:
- **一行一行地放。**因为两个皇后同行就直接违规了,所以每行恰好一个,一行都不能多。
- 放不下去了就退回上一行,把上一个皇后往右挪一格。
这两件事说出来平平无奇,但它们就是这一章的全部内容: 第 1 条决定了搜索树长什么样,第 2 条就是回溯。
从第 1 条还能立刻推出一个关键的表示法:
既然每行恰好一个皇后,一种摆法就可以写成 col[0], col[1], ..., col[n-1]——
「第 0 行放第几列、第 1 行放第几列……」。
又因为不能同列,这 n 个数互不相同,也就是说:
任何一种合法摆法都是 0..n-1 的一个排列。
于是「同行」和「同列」这两条规则被表示法本身吃掉了,只剩对角线要操心。 换个表示法就消掉两条规则 —— 这种事在竞赛里非常值钱,值得专门留意。
3 暴力:把所有排列生成出来,再一个个检查
有了上面那句话,暴力就是现成的:第 3 章刚学过全排列,直接枚举 0..n-1 的每个排列,
每生成一个完整摆法,就检查一遍对角线。
对角线怎么判?两个皇后 (r1, c1) 和 (r2, c2) 在同一条对角线上,
当且仅当 行差的绝对值 == 列差的绝对值。在纸上画两个点验证一下,立刻就信了。
点「运行 ▶」看结果
思路完全正确,n = 8 秒出。它将成为我们的标准答案。
4 实测:它有多慢
本机实测:
| n | 全排列暴力 | 回溯 + 剪枝 | 快了多少 |
|---|---|---|---|
| 10 | 0.03 秒 | 0.003 秒 | 10 倍 |
| 11 | 0.28 秒 | 0.01 秒 | 28 倍 |
| 12 | 3.51 秒 | 0.04 秒 | 88 倍 |
| 13 | 45 秒 | 0.25 秒 | 180 倍 |
倍数那一列在持续变大 —— 这说明两者的差别不是「常数快几倍」, 而是增长速度本身不一样。这类差距才是竞赛里真正决定生死的东西。
5 慢在哪:暴力把功夫全花在哪儿了
想清楚这件事:假设 n = 12,第 0 行放了 0 列,第 1 行放了 1 列 ——
这两个皇后已经在同一条对角线上,这个开局彻底废了。
但全排列暴力会怎么做?它会老老实实地把剩下 10 行的所有 10! = 3 628 800 种摆法
全部生成一遍,每一种都完整检查一次,然后每一次都得出「不行」。
暴力必须把 n 个皇后全部摆完,才肯回头检查第 1、2 个皇后是不是早就打起来了。
它把「检查」这件事推迟到了最后一刻。而在决策树上, 越早的一个错误决定,底下挂着的废物子树就越大。
数字比感觉可靠。下面这份把两种做法的工作量并排数出来:
点「运行 ▶」看结果
n n! (暴力) 回溯节点数 省了几倍 解的个数
--- -------------- ------------ ---------- ----------
4 24 17 1.4 2
6 720 153 4.7 4
8 40320 2057 19.6 92
10 3628800 35539 102.1 724
12 479001600 856189 559.5 14200
注意 n = 4 那一行:剪枝只省了 1.4 倍,几乎没用。
剪枝在小数据上看不出价值,在大数据上决定生死 —— 所以千万别拿 n=4 去判断一个剪枝值不值。
6 ★ 关键的一步
把检查从「最后」提前到「每一步」。
放下每一个皇后的当下就检查:它和已经放好的皇后冲突吗? 一冲突,立刻掉头,它底下那一整棵子树连碰都不碰。
这就是剪枝(pruning)。名字很形象:决策树上一整根枝条,咔嚓剪掉。
为什么二进制枚举做不到(第 3 章第 5 步埋的那个伏笔)? 因为剪枝的前提是你必须能站在决策过程的中间 —— 得有「已经放了 2 个皇后、剩下 10 行还没决定」这样一个时刻。 而一次性生成的完整排列里,根本没有这个时刻。
**递归天然站在中间。**这就是它比枚举强的地方,也是搜索题全部分数的来源。
现在要解决一个实现问题:怎么快速判断「(r, c) 和已放的皇后冲突吗」?
每次都跟前面所有皇后比一遍当然可以,但有更利索的办法 —— 开三个标记数组, 占用了就打勾,撤销时把勾擦掉:
↘ 方向(左上到右下):同一条上的格子,r - c 相同
↙ 方向(右上到左下):同一条上的格子,r + c 相同
r-c 的值 r+c 的值
0 1 2 3 0 1 2 3
-1 0 1 2 1 2 3 4
-2 -1 0 1 2 3 4 5
-3 -2 -1 0 3 4 5 6r + c 的范围是 [0, 2n-2],可以直接当下标。
r - c 会是负数,加上 n - 1 挪成 [0, 2n-2] 就行。
不确定的时候,就在纸上画个 4×4 把两组数字填一遍 —— 三十秒的事,比背公式牢。
7 回溯写法
点「运行 ▶」看结果
核心就是这个三段式,请把它背成肌肉记忆:
for (int c = 0; c < n; c++) {
if (col[c] || d1[r-c+n-1] || d2[r+c]) continue; // ★ 剪枝:冲突就根本不往下走
col[c] = d1[r-c+n-1] = d2[r+c] = true; // 进入:占用
dfs(r + 1); // 递归:交给下一行
col[c] = d1[r-c+n-1] = d2[r+c] = false; // 撤销:还回去
}
从 dfs(r+1) 回来之后,我们要在同一行接着试下一个列。
试之前必须把上一次的占用全部还回去,否则棋盘上会残留一个已经被拿走、 但标记还在的幻影皇后,后面所有的判断都基于一个错误的棋盘。
它不会报错,不会崩溃,只会让答案悄悄变小。这种 bug 最难查 ——
所以不要靠「记得写」,要靠结构:
写完 dfs(r+1) 的那一秒,立刻把撤销那行补上,再回头去想别的。
进入 → 递归 → 撤销,三行必须一起写。
如果把状态当参数传下去(比如 dfs(r, vector<int> placed)),
每一层拿到的都是自己的副本,天然不需要恢复。
代价是每层都要复制一份状态,慢且费内存。 竞赛里绝大多数时候用的是共享状态 + 手动撤销 —— 快,但要自己负责还原。
记住这个权衡:不用撤销的写法不是不存在,是太贵。
8 把每一步打印出来
点「运行 ▶」看结果
对着输出,专门找这两种行:
✗ 冲突—— 每出现一次,就意味着底下一整棵子树被跳过了。这些是省下来的白工。← 撤回—— 从递归回来,把占用还回去。数一数它出现的次数,和✓ 放下是一一对应的。
n = 4 的最后统计是:真正进入的节点 17 个,被当场剪掉 44 次,而暴力要检查 24 种完整摆法。
9 单步看回溯
播放一遍,盯住这几件事:
- 淡红色的格子是被现有皇后攻击到的地方。放下一个皇后的瞬间,
一整片格子变红 —— 那就是
col、d1、d2三个数组在起作用。 - 撤销的那一帧:皇后消失,红色也跟着退回去。如果代码里漏了撤销, 这片红色就会永远留在棋盘上 —— 想象一下那个画面,你就再也不会忘记写撤销了。
- 右边的节点数和剪掉的分支在一路涨,而最底下那个「暴力要检查的摆法数」是死的。 两者的比例就是剪枝的价值。
- 把 n 改成 6、7 各看一遍。
n = 6只有 4 个解 —— 中间那一大段全是白忙活, 但每一次白忙活都被剪枝提前掐断了。
10 ★ 对拍验证
把「回溯版」那一栏整个换成你自己默写的,再点开始对拍。
这几个错误一定要亲手试一次,它们是回溯的经典翻车现场:
- 把撤销那一行删掉 → 答案变小(
n=8会从 92 变成 4)。这是头号错误。 - 只撤销
col[c],忘了撤对角线 → 更隐蔽,小 n 时甚至可能碰巧对 d1的下标写成r - c(忘了+ n - 1)→ 负数下标,vector越界, 运气好当场崩,运气不好静默地读到别的内存d2写成r - c + n - 1(两条对角线复制粘贴没改)→ 答案偏大- 出口写成
if (r == n - 1) { ans++; return; }→ 最后一行没放就开始数了
上面第三条那种越界,对拍不一定能抓住 —— 程序可能没崩,只是读到了垃圾值, 而垃圾值碰巧让答案对了。
小数据尤其容易蒙混过关。所以除了对拍,本地调试时把 vector 的 [] 换成 .at(),
或者编译时加上 -fsanitize=address,undefined,让越界当场炸给你看。
对拍是查逻辑错的,不是查内存错的 —— 两种工具,别混着用。
11 回头看:这一章其实在讲搜索
把 N 皇后的代码抽象一层,就是所有搜索题的骨架:
void dfs(int 第几步) {
if (走完了) { 记录答案; return; }
for (每一个可能的选择) {
if (这个选择不合法) continue; // ← 剪枝,全部的分数都在这儿
做出选择; // 进入
dfs(下一步); // 递归
撤销选择; // 撤销
}
}阶段 3 的搜索章节,本质上都是在这个框架里换东西:
- 第 13 章 DFS 网格:「选择」变成「往四个方向走」
- 第 16 章 DFS 剪枝:专门研究怎么把那个
continue写得更狠 - 第 18 章迭代加深:给「第几步」加一个上限
**框架就这一个。**你现在已经拥有它了 —— 后面学的都是往里面填不同的东西。
N 皇后还能再快很多倍:把 col、d1、d2 三个数组换成三个整数的二进制位,
用位运算一次性算出「这一行所有能放的位置」。这就是状压(第 28 章)。
但请注意:位运算版和这一章的代码,剪的是同一批枝,省的是同一批工。 它快在常数上(省下数组访问),不是快在算法上。 先把这一章的写法练到闭着眼睛能写,再去追那个常数。
12 自测
- 洛谷 P1219 八皇后 Checker Challenge —— USACO。本章原题加了输出前三个解,必须一次写对
- 洛谷 P1025 数的划分 —— NOIP2001。搜索 + 剪枝,关键是想清楚「怎么避免数出重复的划分」
- 洛谷 P1123 取数游戏 —— 标准的「选或不选 + 冲突检查 + 撤销」,和本章几乎同构
- 洛谷 P1731 生日蛋糕 —— NOI1999。剪枝的天花板,不剪必超时。现在做不出很正常,学完第 16 章再来
四章走完,你手上有了这些东西:信任函数(第 1 章)、分解问题(第 2 章)、 决策树 + 深度优先(第 3 章)、回溯 + 剪枝(第 4 章)。
这就是后面所有搜索题和一半 DP 题的地基。
接下来按顺序应该走阶段 1(基础技巧)。如果你实在按捺不住想写搜索, 可以先跳到第 13 章 DFS 网格 —— 你会发现那一章的代码, 和这一章的框架是同一个东西,只是把「放皇后」换成了「往四个方向走」。