阶段 0 · 递归思维 · 第 4 章

回溯与状态恢复:N 皇后

暴力要把 n 个皇后全摆完才肯检查;剪枝在第二个皇后打起来的那一刻就掉头。

例题:N 皇后 建议用时:120 分钟
阶段 0 的最后一章,也是收网的一章

第 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 条决定了搜索树长什么样,第 2 条就是回溯

从第 1 条还能立刻推出一个关键的表示法:

✓ 一种摆法 = 一个排列

既然每行恰好一个皇后,一种摆法就可以写成 col[0], col[1], ..., col[n-1]—— 「第 0 行放第几列、第 1 行放第几列……」。

又因为不能同列,这 n 个数互不相同,也就是说: 任何一种合法摆法都是 0..n-1 的一个排列。

于是「同行」和「同列」这两条规则被表示法本身吃掉了,只剩对角线要操心。 换个表示法就消掉两条规则 —— 这种事在竞赛里非常值钱,值得专门留意。

3 暴力:把所有排列生成出来,再一个个检查

有了上面那句话,暴力就是现成的:第 3 章刚学过全排列,直接枚举 0..n-1 的每个排列, 每生成一个完整摆法,就检查一遍对角线。

对角线怎么判?两个皇后 (r1, c1)(r2, c2) 在同一条对角线上, 当且仅当 行差的绝对值 == 列差的绝对值。在纸上画两个点验证一下,立刻就信了。

brute.cpp暴力
试试 4(答案 2)、5(10)、6(4)、8(92)。注意 n=6 的答案比 n=5 还少 —— 这个数列一点都不规律。
输入(stdin)
输出
点「运行 ▶」看结果

思路完全正确,n = 8 秒出。它将成为我们的标准答案。

4 实测:它有多慢

同题对比:全排列暴力 vs 回溯 + 剪枝
先跑 12。跑完把它改成 13 再来一次 —— 暴力会被 15 秒时限掐断,而回溯版连眼都不眨。
全排列暴力
回溯 + 剪枝

本机实测:

n全排列暴力回溯 + 剪枝快了多少
100.03 秒0.003 秒10 倍
110.28 秒0.01 秒28 倍
123.51 秒0.04 秒88 倍
1345 秒0.25 秒180 倍

倍数那一列在持续变大 —— 这说明两者的差别不是「常数快几倍」, 而是增长速度本身不一样。这类差距才是竞赛里真正决定生死的东西。

5 慢在哪:暴力把功夫全花在哪儿了

想清楚这件事:假设 n = 12,第 0 行放了 0 列,第 1 行放了 1 列 —— 这两个皇后已经在同一条对角线上,这个开局彻底废了

但全排列暴力会怎么做?它会老老实实地把剩下 10 行的所有 10! = 3 628 800 种摆法 全部生成一遍,每一种都完整检查一次,然后每一次都得出「不行」。

⚠ 病灶就是这一句

暴力必须把 n 个皇后全部摆完,才肯回头检查第 1、2 个皇后是不是早就打起来了。

它把「检查」这件事推迟到了最后一刻。而在决策树上, 越早的一个错误决定,底下挂着的废物子树就越大。

数字比感觉可靠。下面这份把两种做法的工作量并排数出来:

nodes.cpp过程演示
输入 12,看那张表。重点看「省了几倍」这一列是怎么一路涨上去的。
输入(stdin)
输出
点「运行 ▶」看结果
  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  6

r + c 的范围是 [0, 2n-2],可以直接当下标。 r - c 会是负数,加上 n - 1 挪成 [0, 2n-2] 就行。

不确定的时候,就在纸上画个 4×4 把两组数字填一遍 —— 三十秒的事,比背公式牢。

7 回溯写法

fast.cpp正解
n=8 应该输出 92。把它改成 12、13 试试,依然很快。
输入(stdin)
输出
点「运行 ▶」看结果

核心就是这个三段式,请把它背成肌肉记忆:

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 把每一步打印出来

trace.cpp过程演示
用 n = 4 跑(91 行输出,正好能读完)。n = 5 就有几百行了。
输入(stdin)
输出
点「运行 ▶」看结果

对着输出,专门找这两种行:

  • ✗ 冲突 —— 每出现一次,就意味着底下一整棵子树被跳过了。这些是省下来的白工。
  • ← 撤回 —— 从递归回来,把占用还回去。数一数它出现的次数,和 ✓ 放下 是一一对应的。

n = 4 的最后统计是:真正进入的节点 17 个,被当场剪掉 44 次,而暴力要检查 24 种完整摆法。

9 单步看回溯

N 皇后:进入 → 递归 → 撤销
共 372 帧
第 1 / 372 步
0123401234
淡红 = 已经被现有皇后攻击到的格子(放不了)。黑框 = 这一帧正在试的格子。
已找到的解
0
真正进入的节点数
1
当场剪掉的分支
0
暴力要检查的摆法数
120
当前这一步
进入 / 离开某一层
每行皇后所在的列
[·, ·, ·, ·, ·]
进入 dfs(0):前 0 行已经摆好了,这一层只负责第 0 行该放哪一列。 已找到 0 个解

播放一遍,盯住这几件事:

  • 淡红色的格子是被现有皇后攻击到的地方。放下一个皇后的瞬间, 一整片格子变红 —— 那就是 cold1d2 三个数组在起作用。
  • 撤销的那一帧:皇后消失,红色也跟着退回去。如果代码里漏了撤销, 这片红色就会永远留在棋盘上 —— 想象一下那个画面,你就再也不会忘记写撤销了。
  • 右边的节点数剪掉的分支在一路涨,而最底下那个「暴力要检查的摆法数」是死的。 两者的比例就是剪枝的价值。
  • 把 n 改成 6、7 各看一遍。n = 6 只有 4 个解 —— 中间那一大段全是白忙活, 但每一次白忙活都被剪枝提前掐断了。

10 ★ 对拍验证

★ 正确的用法

把「回溯版」那一栏整个换成你自己默写的,再点开始对拍。

对拍器
生成器造 n ≤ 9 的棋盘(暴力是 n! 的,再大对拍就等不起了),并且有 1/3 的概率专门造 n ≤ 4 —— n=2、n=3 是「一个解都没有」的边界,最容易写挂。

这几个错误一定要亲手试一次,它们是回溯的经典翻车现场:

  • 把撤销那一行删掉 → 答案变小(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 皇后还能再快很多倍:把 cold1d2 三个数组换成三个整数的二进制位, 用位运算一次性算出「这一行所有能放的位置」。这就是状压(第 28 章)。

但请注意:位运算版和这一章的代码,剪的是同一批枝,省的是同一批工。 它快在常数上(省下数组访问),不是快在算法上。 先把这一章的写法练到闭着眼睛能写,再去追那个常数。

12 自测

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

四章走完,你手上有了这些东西:信任函数(第 1 章)、分解问题(第 2 章)、 决策树 + 深度优先(第 3 章)、回溯 + 剪枝(第 4 章)。

这就是后面所有搜索题和一半 DP 题的地基。

接下来按顺序应该走阶段 1(基础技巧)。如果你实在按捺不住想写搜索, 可以先跳到第 13 章 DFS 网格 —— 你会发现那一章的代码, 和这一章的框架是同一个东西,只是把「放皇后」换成了「往四个方向走」。