阶段 0 · 递归思维 · 第 4 章普及组 J

回溯与状态恢复:N 皇后

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

需要先学:第 3 章 递归 = 决策树:子集、组合、全排列例题:N 皇后建议用时:135 分钟
阶段 0 的最后一章,也是收网的一章

第 1 章教你信任函数,第 2 章教你分解,第 3 章告诉你递归就是在决策树上做深度优先遍历。

这一章把三样东西合起来用,并且第一次动那件第 3 章欠着的事 —— 二进制枚举做不到、而递归能做到的那件事:剪枝。

学完这一章,阶段 3 的搜索你已经会一大半了。DFS 就是这个,只是换了张地图。

1一句话问题

在 n × n 的棋盘上放 n 个皇后,要求谁也攻击不到谁,问有多少种放法。

皇后的攻击范围是:同一行、同一列、同一条对角线(两个方向都算)。

输入

4

输出

2

4 × 4 只有这两种放法(Q 是皇后):

. 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 还少 —— 这个数列一点都不规律。
// N 皇后 —— 暴力:先把所有摆法生成出来,再一个个检查
//
// 输入:n
// 输出:n×n 棋盘上放 n 个皇后、互不攻击的方案数
//
// 皇后互相攻击的条件:同行、同列、同一条对角线。
//
// 第一步观察(这一步很关键,不然连暴力都写不出来):
// n 个皇后放进 n 行,每行必须恰好一个 —— 两个皇后同行就直接完蛋了。
// 所以一种摆法 = 「第 0 行放第几列,第 1 行放第几列,……」这样一个序列。
// 又因为不能同列,这个序列必须是 0..n-1 的一个**排列**。
//
// 于是暴力就成了:枚举 0..n-1 的全排列(第 3 章刚学的),
// 每生成一个完整排列,就检查一遍对角线冲突。
//
// 这份代码思路完全正确,n = 8 秒出。但它有个致命的浪费,正文第 5 步会把它揪出来:
// **它必须把 n 个皇后全部摆完,才肯回头检查第 1、2 个皇后是不是早就打起来了。**
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
if (n <= 0) { cout << 0 << "\n"; return 0; }
vector<int> col(n);
for (int i = 0; i < n; i++) col[i] = i; // col[r] = 第 r 行的皇后放在第几列
long long ans = 0;
do {
// 排列本身已经保证了「不同行、不同列」,只剩对角线要查
bool ok = true;
for (int r1 = 0; r1 < n && ok; r1++)
for (int r2 = r1 + 1; r2 < n && ok; r2++)
if (abs(r1 - r2) == abs(col[r1] - col[r2])) // 行差 == 列差,就是在同一条对角线上
ok = false;
if (ok) ans++;
} while (next_permutation(col.begin(), col.end()));
cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

4实测:它有多慢

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

本机实测:

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 个皇后是不是早就打起来了。

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

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

nodes.cpp过程演示
输入 12,看那张表。重点看「省了几倍」这一列是怎么一路涨上去的。
// 剪枝到底省了多少 —— 把两种做法的工作量并排数出来
//
// 输入:n(建议 10 ~ 12)
// 输出:一张表,对每个 k <= n 列出
// k! 暴力要检查的完整摆法数
// 回溯访问的节点数 加了剪枝之后真正走过的地方
// 两者的倍数
// 解的个数
//
// 这份代码不解题,它只是把「剪枝值多少钱」变成具体数字。
// 注意看倍数那一列:它不是稳定的几倍,而是**随着 n 越来越大**。
// 这说明剪枝省掉的不是一个常数,而是改变了增长的速度本身。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<bool> col, d1, d2;
long long solutions = 0, nodes = 0;
void dfs(int r) {
nodes++;
if (r == n) { solutions++; return; }
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;
}
}
int main() {
int maxN;
if (!(cin >> maxN)) return 0;
if (maxN < 1 || maxN > 13) { cout << "请输入 1 到 13 之间的 n(再大这份统计自己就跑慢了)\n"; return 0; }
cout << " n n! (暴力) 回溯节点数 省了几倍 解的个数\n";
cout << "--- -------------- ------------ ---------- ----------\n";
long long fact = 1;
for (int k = 1; k <= maxN; k++) {
fact *= k;
n = k;
col.assign(k, false);
d1.assign(2 * k, false);
d2.assign(2 * k, false);
solutions = 0;
nodes = 0;
dfs(0);
cout << setw(3) << k
<< setw(17) << fact
<< setw(15) << nodes
<< setw(13) << fixed << setprecision(1) << (double)fact / (double)nodes
<< setw(13) << solutions << "\n";
}
return 0;
}
点一下即可编辑
输入(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 行还没决定」这样一个时刻。 而一次性生成的完整排列里,根本没有这个时刻。

递归天然站在中间。这就是它比枚举强的地方,也是搜索题全部分数的来源。

骨架于是长这样(先别管「冲突吗」怎么写,下一步专门解决它):

void dfs(int r) {                 // 现在轮到第 r 行了
    if (r == n) { ans++; return; }

    for (int c = 0; c < n; c++) {
        if (和已放的皇后冲突) continue;   // ★ 剪枝:这一整片分支根本不看

        在 (r, c) 放下皇后;
        dfs(r + 1);
        把 (r, c) 的皇后拿走;
    }
}

7先用最笨的办法判断冲突 —— 而它已经是满分做法了

「放下之前先检查」听起来简单,可真要写出来,得回答一个很具体的问题:

第 0 到 r-1 行已经放好了 r 个皇后,现在想把第 r+1 个放在 (r, c) —— 它和前面那些皇后冲突吗?

最笨、也最直接的办法:把前面每一个皇后都问一遍。 只要拿一个数组记住每行的皇后在第几列(pos[0] 到 pos[r-1]),一个循环就写完了:

// (r, c) 和已经放好的第 0..r-1 行的皇后冲突吗?
bool conflict(int r, int c) {
    for (int i = 0; i < r; i++) {
        if (pos[i] == c) return true;                    // 同列
        if (r - i == abs(c - pos[i])) return true;       // 同对角线:行差 == 列差
    }
    return false;
}

第二个条件就是第 3 步那句「行差的绝对值 == 列差的绝对值」。 这里 r 一定大于 i(i 是更早的行),所以行差直接写 r - i,不用套 abs; 列差没这个保证,必须套。

把 conflict() 塞进第 6 步那个骨架里,一份完整可交的回溯就成了:

scan.cpp回溯(笨判断)
n=8 输出 92,和 brute.cpp 一模一样。改成 12、13 也很快 —— 它和后面那份「聪明版」剪的是同一批枝。
// N 皇后 —— 回溯,但冲突判断用最朴素的办法:和前面每一个皇后比一遍
//
// 输入:n
// 输出:方案数(和 brute.cpp / fast.cpp 完全一样)
//
// 这份为什么存在:正文第 7 步要先有它,才轮得到三个标记数组。
//
// 很多人把「回溯」和「三个标记数组」当成同一件事 —— 不是。
// **回溯是骨架,标记数组只是骨架里那句「冲突吗?」的一种写法。**
// 这份和 fast.cpp 的关系:
// · 走的是同一棵树,访问的节点数一个不差(nodes.cpp 数出来的那一列对它同样成立);
// · 剪掉的是同一批枝;
// · 答案逐字节相同(check:viz 里 n = 1..10 钉着这一条)。
// 唯一的差别是**判断一次冲突要花多少功夫**:这份要和已放的 r 个皇后逐个比,
// fast.cpp 只查三个数组。count.cpp 把这两个数并排数出来。
//
// ⚠ 所以「先写这份、跑通、再换成标记数组」是一条安全的路 ——
// 考场上一时想不起对角线的下标怎么算,这份照样是满分做法,只是常数大一点。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<int> pos; // pos[r] = 第 r 行的皇后放在第几列(只有 0..r-1 这一段是有效的)
long long ans = 0;
// (r, c) 和已经放好的第 0..r-1 行的皇后冲突吗?
bool conflict(int r, int c) {
for (int i = 0; i < r; i++) {
if (pos[i] == c) return true; // 同列
if (r - i == abs(c - pos[i])) return true; // 同对角线:行差 == 列差
}
return false;
}
void dfs(int r) {
if (r == n) { ans++; return; } // 出口:n 行全放满了
for (int c = 0; c < n; c++) {
if (conflict(r, c)) continue; // ★ 剪枝:和 fast.cpp 剪的是同一批枝
pos[r] = c; // 进入
dfs(r + 1); // 递归
// 撤销:这一份什么都不用写 —— 因为 conflict() 只读 pos[0..r-1],
// 也就是「只看比自己早的行」。写在 pos[r] 上的那个数,兄弟分支下一轮就覆盖掉了,
// 谁也看不见它。⇒ 需不需要撤销,取决于**别人会不会读到你留下的痕迹**。
// fast.cpp 的三个标记数组是整块棋盘共用的,所以那边非撤不可。正文第 7 步讲这件事。
}
}
int main() {
if (!(cin >> n)) return 0;
if (n <= 0) { cout << 0 << "\n"; return 0; }
pos.assign(n, -1);
dfs(0);
cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 这份不是「中间产物」,它就是正解

初学的人很容易把「回溯」和「三个标记数组」当成同一件事。不是。

  • 回溯是骨架:一步一步做决定,走不通就退回来换一个。
  • 判断冲突只是骨架里的一句话,怎么写都行。

这份 scan.cpp 和下面那份 fast.cpp:走同一棵树、访问的节点数一个不差、 剪掉的是同一批枝、答案逐字节相同(n = 1..10 这一条是写成断言钉住的)。

⇒ 考场上一时想不起对角线的下标怎么算,就写这一份,照样满分。 先写笨的、跑通、再换快的 —— 这是一条很稳的路,比「一步到位然后调一小时」强得多。

✓ 顺手回答一个问题:这份为什么不用写撤销?

第 6 步的骨架里明明有一句「把皇后拿走」,可 scan.cpp 里那一行是空的(只有注释)。为什么?

因为 conflict() 只读 pos[0..r-1] —— 只看比自己早的行。 第 r 行写进 pos[r] 的那个数,没有任何人会在「它已经过期」的时候读到它:

  • 更深的行(r+1、r+2……)读到它时,它是当前正确的值;
  • 同一行的下一个兄弟分支,第一件事就是把 pos[r] 重新写掉。

⇒ 撤销的本质不是「有个仪式要做」,而是「别让后面的人看见你留下的痕迹」。 这一份靠「只读前面」天然做到了;下面那份不行,那三个数组是整块棋盘共用的。

⚠ 千万别把这条当成「回溯可以不撤销」的通行证。它只在状态按层隔离时成立, 而竞赛里绝大多数回溯都是共享一份状态的 —— 那就必须自己还回去。

那为什么还要往下学?看一眼代价:conflict(r, c) 最多要比 r 次, 所以每试一个格子就是 O(n) 的功夫。n = 12 跑一趟,光这个循环就要转四千五百多万次 (第 11 步会把这个数真的量出来)。

有没有办法一眼就看出冲突,而不是挨个问?有 —— 但要先换一个问法。

8换个问法:一屋子人里,有没有两个是同一天生日的?

先把棋盘放一边。下面这个套路你以后会用上几十次,值得单独学一遍。

班上 40 个人,问有没有两个人生日是同一天。

办法一(就是上一步那个):两两比一遍。 第 1 个人和后面 39 个比,第 2 个和后面 38 个比…… 一共比 780 次。

办法二:准备一本册子,365 页,一天一页。 每来一个人,翻到他生日那一页看一眼:

  • 那一格是空的 → 打个勾,下一位;
  • 那一格已经有勾了 → 抓到了,有人和他同一天。

40 个人,翻 40 次,每次只看一格。

★ 关键不是「快了多少」,是问问题的方向反过来了
两两比较:  我拿着自己的生日,挨个去问别人 ——「你也是 3 月 5 日吗?」
            要问的人数 = 已经来了多少人

打勾册子:  日子自己记着「我这天有没有人」—— 我只要去问那一天
            要问的格数 = 1

从「问所有人」变成「问那样东西」。 人可以有很多个,但「那样东西」只有一个。

✓ 这个套路什么时候能用(三个条件)
  1. 「冲突」必须是「共用了同一样东西」 —— 而不是别的什么弯弯绕绕的关系。 (「同一天生日」是共用一个日子 ✓;「年龄差不超过 3 岁」就不是 ✗, 那种关系没法归到某一样具体的东西头上。)
  2. 那样东西的种类数得清,而且能编成 0, 1, 2, ... 的号。 日子有 365 种,编号 0 到 364。
  3. 号的范围印得起一本册子。 365 页印得起;如果编号能到 10 的 18 次方, 那就没法开数组了,得换别的记法(map、哈希表),那是另一个话题。

这三条一满足,「两两比较」就能换成「查一次表」。

这本册子在竞赛里有一堆名字 —— 标记数组、桶、vis[]、状态数组 —— 但它永远是同一件事: 给「可能被占用的东西」编上号,开一个数组记住每个号被占了没有。

你会一再见到它:第 13 章网格 DFS 的 vis[][](占的是「格子」)、 第 17 章记忆化的那张表(占的是「子问题」)、第 28 章状压(把整本册子压进一个整数的二进制位里)。

9皇后的「生日」有三个

回到棋盘。照上一步那三个条件,一条条对。

★ ① 冲突是不是「共用了同一样东西」?是。

两个皇后打起来,只可能是因为这四件事之一:

  • 同一行 —— 已经被「每行恰好一个」这个表示法吃掉了(第 2 步),根本不会发生;
  • 同一列;
  • 同一条 ↘ 对角线(左上到右下);
  • 同一条 ↙ 对角线(右上到左下)。

⇒ 放下一个皇后 = 同时占用三样东西:一列、一条 ↘、一条 ↙。

⇒ 「(r, c) 冲突吗」= 「这三样东西里,有没有哪一样已经被人占了」。

三样东西,三本册子。这就是「三个标记数组」的全部来历 —— 它不是什么技巧,它就是上一步那本生日册子,抄了三份。

② 数得清、编得了号吗? 列本来就有号(0 到 n-1),直接用。 对角线要自己编 —— 这是这一章唯一需要动点脑筋的地方,但只要动一次,以后就是白送的。

先看 ↘ 方向(左上往右下)。沿着它走一步,行加一,列也加一:

(r, c)  ->  (r+1, c+1)  ->  (r+2, c+2)  ->  ...

行和列一起变大,那么 r - c 呢?(r+1) - (c+1) = r - c,一点没变。 反过来也对:r - c 相同的格子,必然在同一条 ↘ 上。

⇒ r - c 就是这条 ↘ 对角线的号。

↙ 方向(右上往左下)同理,走一步是行加一,列减一:

(r, c)  ->  (r+1, c-1)  ->  (r+2, c-2)  ->  ...

这回不变的是 r + c:(r+1) + (c-1) = r + c。

⇒ r + c 就是这条 ↙ 对角线的号。

在 4×4 上把这两组号填出来。别背,抄一遍,三十秒的事,比公式牢得多:

r - c 的值(这就是每个格子所在 ↘ 对角线的号)

        c=0 c=1 c=2 c=3
 r=0      0  -1  -2  -3
 r=1      1   0  -1  -2
 r=2      2   1   0  -1
 r=3      3   2   1   0

r + c 的值(这就是每个格子所在 ↙ 对角线的号)

        c=0 c=1 c=2 c=3
 r=0      0   1   2   3
 r=1      1   2   3   4
 r=2      2   3   4   5
 r=3      3   4   5   6

对着上面两张表,从左上到右下扫一眼:第一张表里数字相同的格子连成的正是 \ 方向的斜线, 第二张表里数字相同的格子连成的是 / 方向的斜线。这就是「编号」这件事的全部内容。

⚠ ③ 号的范围:一个必须处理的小麻烦

r + c 的范围是 0 到 2n-2,非负,可以直接当下标。

r - c 就不行了:它的范围是 -(n-1) 到 n-1,有负数。 而 vector 的下标是无符号的 —— 写 d1[-1] 不会报错, 它会被当成一个天文数字的下标,读到别的内存里去(运气好当场崩,运气不好静默地读到垃圾)。

办法很土也很有效:整本册子往后挪 n - 1 页。

本来的号:  -3  -2  -1   0   1   2   3        (n = 4,共 7 条 ↘ 对角线)
加上 n-1:   0   1   2   3   4   5   6        <- 这个才拿去当下标

册子不可能有「第 -3 页」,那就在前面加 3 页封面,原来的第 -3 页变成第 0 页。 仅此而已,没有任何魔法。

⇒ 下标写成 r - c + n - 1,范围 0 到 2n-2,和 r + c 一样宽。

于是三本册子就是这三个数组:

vector<bool> col(n);        // col[c]              第 c 列被占了吗
vector<bool> d1(2 * n);     // d1[r - c + n - 1]   这条 ↘ 被占了吗
vector<bool> d2(2 * n);     // d2[r + c]           这条 ↙ 被占了吗

(下标最大是 2n-2,其实开 2n-1 格就够;代码里写 2 * n 是图省事,多一格不碍事。)

✓ 在 4×4 上手动走三步 —— 跟着填一遍,这一节就通了

n = 4,所以 ↘ 的下标是 r - c + 3。三本册子一开始全是空的(- 表示空,X 表示有勾)。

第 0 行放在第 1 列。 它占掉:col[1]、d1[0-1+3] = d1[2]、d2[0+1] = d2[1]。

      c=0 c=1 c=2 c=3
r=0    .   Q   .   .
r=1    .   .   .   .
r=2    .   .   .   .
r=3    .   .   .   .

下标:  0   1   2   3   4   5   6
col :  -   X   -   -
d1  :  -   -   X   -   -   -   -
d2  :  -   X   -   -   -   -   -

第 1 行放在第 3 列。 它占掉:col[3]、d1[1-3+3] = d1[1]、d2[1+3] = d2[4]。

      c=0 c=1 c=2 c=3
r=0    .   Q   .   .
r=1    .   .   .   Q
r=2    .   .   .   .
r=3    .   .   .   .

下标:  0   1   2   3   4   5   6
col :  -   X   -   X
d1  :  -   X   X   -   -   -   -
d2  :  -   X   -   -   X   -   -

第 2 行,先试第 2 列。 查三本册子:

  • col[2] —— 空的 ✓
  • d1[2-2+3] = d1[3] —— 空的 ✓
  • d2[2+2] = d2[4] —— 有勾! ✗

所以 (2, 2) 放不下。回头看棋盘:(1, 3) 和 (2, 2) 确实在同一条 ↙ 上。

★ 但注意我们根本没去看 (1, 3) 那个皇后是谁、在哪 —— 只查了 d2[4] 这一格。 这就是册子的全部意义:不问人,问那条线。

第 2 行改试第 0 列。 col[0] 空 ✓、d1[2-0+3] = d1[5] 空 ✓、d2[2+0] = d2[2] 空 ✓ —— 放得下。

接着第 3 行:col[0]、col[1]、col[3] 都被占了,只剩第 2 列可试,而它三本册子全空 —— 于是凑出第 1 步里给的那两个解中的第一个。你刚刚用手跑了一遍这一章的算法。

10回溯写法

把第 7 步那个 conflict() 换成查三本册子,就是这一章要背下来的那份代码:

fast.cpp正解
n=8 应该输出 92。把它改成 12、13 试试,依然很快。
// N 皇后 —— 回溯 + 剪枝
//
// 输入:n
// 输出:方案数(和 brute.cpp 完全一样)
//
// 和暴力的差别只有一句话:**每放下一个皇后就立刻检查,一冲突就掉头。**
//
// 三段式(这一章要练成肌肉记忆的东西):
// col[c] = d1[...] = d2[...] = true; // 进入:占用这一列和两条对角线
// dfs(r + 1); // 递归:交给下一行
// col[c] = d1[...] = d2[...] = false; // 撤销:恢复现场,让兄弟分支能用
//
// 「撤销」为什么必须有:从 dfs(r+1) 回来之后,我们要在**同一行**试下一个列。
// 试下一个列之前,必须把上一次的占用全部还回去,否则棋盘上会残留一个不存在的皇后。
// 忘写撤销是初学回溯的头号错误 —— 而且它不报错,只是答案偷偷变小。
//
// 两条对角线的编号(想清楚这个就不用每次现推):
// ↘ 方向(左上到右下):同一条上的格子 r - c 相同。加 n-1 是为了让下标非负 → r - c + n - 1
// ↙ 方向(右上到左下):同一条上的格子 r + c 相同 → r + c
#include <bits/stdc++.h>
using namespace std;
int n;
vector<bool> col, d1, d2; // 列 / ↘ 对角线 / ↙ 对角线 是否已经被占
long long ans = 0;
void dfs(int r) {
if (r == n) { ans++; return; } // 出口:n 行全放满了,凑出一个方案
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; // 撤销
}
}
int main() {
if (!(cin >> n)) return 0;
if (n <= 0) { cout << 0 << "\n"; return 0; }
col.assign(n, false);
d1.assign(2 * n, false);
d2.assign(2 * n, false);
dfs(0);
cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(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;   // 撤销:还回去
}

那句 if 读出来就是:「这一列被占了吗?这条 ↘ 被占了吗?这条 ↙ 被占了吗? 只要有一样被占,这个格子就不能放。」 三次数组访问,和 n 多大毫无关系。

★ 「撤销」为什么在这一份里必须有

从 dfs(r+1) 回来之后,我们要在同一行接着试下一个列。

试之前必须把上一次的占用全部还回去,否则棋盘上会残留一个已经被拿走、 但标记还在的幻影皇后,后面所有的判断都基于一个错误的棋盘。

★ 对照第 7 步那个问题就很清楚了:scan.cpp 的 pos[] 只被更早的行读, 所以留不下痕迹;而这三本册子是整块棋盘共用的,谁都能读到 —— 你不擦,别人就当真。「要不要撤销」永远看这一条:别人会不会读到你留下的痕迹。

它不会报错,不会崩溃,只会让答案悄悄变小。这种 bug 最难查 —— 所以不要靠「记得写」,要靠结构: 写完 dfs(r+1) 的那一秒,立刻把撤销那行补上,再回头去想别的。

进入 → 递归 → 撤销,三行必须一起写。

✓ 顺带一提:有一种写法不需要撤销

如果把状态当参数传下去(比如 dfs(r, vector<int> placed)), 每一层拿到的都是自己的副本,天然不需要恢复。

代价是每层都要复制一份状态,慢且费内存。 竞赛里绝大多数时候用的是共享状态 + 手动撤销 —— 快,但要自己负责还原。

记住这个权衡:不用撤销的写法不是不存在,是太贵。

11两种判断差多少 —— 换一把可复现的尺子

第 7 步那份和第 10 步这份,走的是同一棵树、剪的是同一批枝、答案一个字节都不差。 差别只有一个:判断一次冲突要花多少功夫。

  • 逐个比较:已经放了 r 个皇后就最多比 r 次 —— O(n)
  • 查三本册子:最多查 3 次,和 n 没关系 —— O(1)

这个差别用秒表是量不出来的:两份都在毫秒级,秒表量到的大半是起进程的开销 (第 4 步那两份差着几百倍才量得动)。所以换一把尺子:数次数。 次数换台机器也不会变,而秒数会。

count.cpp过程演示
输入 12。它一趟 DFS 里把两种判断都做一遍,顺便验一件事:两种判断对每个格子给的结论必须完全一样。
// 两种冲突判断,各要做多少次「基本操作」—— 换一把可复现的尺子
//
// 输入:n(1 ~ 13)
// 输出:一张表,对每个 k <= n 列出
// 试过的格子数 两种写法完全相同(它们走的是同一棵树)
// 逐个比较的次数 scan.cpp:每试一个格子,就和已放的皇后逐个比
// 查标记数组的次数 fast.cpp:每试一个格子,最多查 col / d1 / d2 三下
// 倍数
//
// 为什么用「次数」而不是秒表:秒数换台机器就变,次数不变 —— 这本书一贯的做法
//(第 21、36、38 章都是这么干的)。何况这两份程序都很快,秒表量到的大半是起进程的开销。
//
// ★ 这张表真正要说的是:**剪枝剪掉的枝一模一样** —— 所以「试过的格子数」只有一列,
// 两种写法共用。差别全在「每试一个格子要花多少功夫」上。
//
// 两边都按 C++ 的短路求值数,数出来才和真跑的一致:
// 逐个比较:一旦发现冲突就 return,后面的皇后不比了;
// 查标记数组:col[c] 为真就不查 d1,d1 为真就不查 d2。
//
// 顺带做了一件自检的事:两种判断对每一个格子给出的结论必须一模一样,
// 不一样就当场报错退出 —— 这份代码自己就是「新写法和老写法等价」的一个证人。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<int> pos; // 逐个比较那一份要用的
vector<bool> col, d1, d2; // 三个标记数组那一份要用的
long long cells = 0, cmps = 0, looks = 0, solutions = 0;
bool disagreed = false;
void dfs(int r) {
if (r == n) { solutions++; return; }
for (int c = 0; c < n; c++) {
cells++;
// ① scan.cpp 的判断:和前面每一个皇后比(发现冲突就不比了)
bool scanBad = false;
for (int i = 0; i < r; i++) {
cmps++;
if (pos[i] == c || r - i == abs(c - pos[i])) { scanBad = true; break; }
}
// ② fast.cpp 的判断:查三个标记数组(短路,所以最少 1 次、最多 3 次)
looks++;
bool markBad = col[c];
if (!markBad) { looks++; markBad = d1[r - c + n - 1]; }
if (!markBad) { looks++; markBad = d2[r + c]; }
if (scanBad != markBad) disagreed = true; // 两种判断必须给同一个结论
if (markBad) continue;
pos[r] = c;
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;
}
}
int main() {
int maxN;
if (!(cin >> maxN)) return 0;
if (maxN < 1 || maxN > 13) { cout << "请输入 1 到 13 之间的 n(再大这份统计自己就跑慢了)\n"; return 0; }
cout << " n 试过的格子数 逐个比较:比了几次 查标记数组:查了几次 倍数\n";
cout << "--- ------------- ------------------ -------------------- --------\n";
for (int k = 1; k <= maxN; k++) {
n = k;
pos.assign(k, -1);
col.assign(k, false);
d1.assign(2 * k, false);
d2.assign(2 * k, false);
cells = cmps = looks = solutions = 0;
dfs(0);
cout << setw(3) << k
<< setw(16) << cells
<< setw(21) << cmps
<< setw(23) << looks
<< setw(11) << fixed << setprecision(2) << (double)cmps / (double)looks << "\n";
}
if (disagreed) cout << "\n✗ 两种判断给出了不同的结论 —— 这不该发生,去查代码\n";
else cout << "\n✓ 每一个格子上,两种判断给出的结论都一样\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
  n    试过的格子数   逐个比较:比了几次   查标记数组:查了几次       倍数
---   -------------   ------------------   --------------------   --------
  4              60                   84                    115       0.73
  6             894                 2016                   1485       1.36
  8           15720                46752                  24648       1.90
 10          348150              1297558                 523217       2.48
 12        10103868             45396914               14684728       3.09

三件事值得盯着看:

  • 「试过的格子数」只有一列,两种写法共用。 这是「剪的是同一批枝」的实锤 —— 换判断方式并不会让搜索树变一个样子。
  • 倍数那一列涨得很慢:n = 12 才 3.09 倍。回头看第 5 步那张表: 剪枝在 n = 12 时省了 559.5 倍。
  • n = 4 那一行的倍数小于 1 —— 格子少、前面的皇后也少, 逐个比反而比查三次表还省。又是第 5 步那句话:小数据上看不出价值。
★ 这两种「快」不是一回事,顺序也不能反
剪枝      改的是「要走多少个节点」            n! -> 856189 (第 5 步那张表)
三本册子  改的是「每个节点上花多少功夫」      O(n) -> O(1) (这一步这张表)

前者能把「跑不完」变成「跑得完」,后者只是把常数(准确说是一个 n 的因子)压下去。

⇒ 先剪枝,再优化常数。 顺序反了的话,你是在给一棵根本不该走的树擦地板。

★ 这条以后会反复用到:一道题先想清楚「怎么少走一些」,再去抠「每一步怎么快一点」。

12把每一步打印出来

trace.cpp过程演示
用 n = 4 跑(91 行输出,正好能读完)。n = 5 就有几百行了。
// N 皇后 —— 把回溯的每一步打印出来
//
// 输入:n(用 4 或 5,再大就刷屏了)
// 输出:每一次「尝试 / 冲突 / 放下 / 撤销」,缩进表示当前在第几行
//
// 请特别盯住两种行:
// ✗ 冲突 —— 这一步之后**整棵子树都没走**。这就是剪枝,省下来的全是白工。
// ← 撤回 —— 从递归回来后把占用还回去。没有它,后面的兄弟分支会看到一个幻影皇后。
//
// 最后会打印统计:真正进入了多少个节点、剪掉了多少次。
// 把它和「暴力要检查 n! 种摆法」比一比,就知道剪枝到底值多少钱。
#include <bits/stdc++.h>
using namespace std;
int n;
vector<bool> col, d1, d2;
vector<int> pos; // pos[r] = 第 r 行皇后所在的列
long long ans = 0, nodes = 0, cuts = 0;
void indent(int r) {
for (int i = 0; i < r; i++) cout << "| ";
}
void printBoard() {
for (int r = 0; r < n; r++) {
cout << " ";
for (int c = 0; c < n; c++) cout << (pos[r] == c ? "Q " : ". ");
cout << "\n";
}
}
void dfs(int r) {
nodes++;
if (r == n) {
ans++;
indent(r);
cout << "★ 第 " << ans << " 个解!\n";
printBoard();
return;
}
for (int c = 0; c < n; c++) {
indent(r);
cout << "第 " << r << " 行试第 " << c << " 列:";
if (col[c] || d1[r - c + n - 1] || d2[r + c]) {
cuts++;
cout << "✗ 冲突(";
if (col[c]) cout << "列 " << c << " 已有皇后";
else if (d1[r - c + n - 1]) cout << "↘ 对角线上已有皇后";
else cout << "↙ 对角线上已有皇后";
cout << ")—— 这一整片分支直接不看了\n";
continue; // ★ 剪枝
}
cout << "✓ 放下\n";
col[c] = d1[r - c + n - 1] = d2[r + c] = true; // 进入
pos[r] = c;
dfs(r + 1); // 递归
col[c] = d1[r - c + n - 1] = d2[r + c] = false; // 撤销
pos[r] = -1;
indent(r);
cout << "← 撤回第 " << r << " 行第 " << c << " 列的皇后,换下一列试\n";
}
}
int main() {
if (!(cin >> n)) return 0;
if (n <= 0 || n > 6) { cout << "这份是用来看过程的,请输入 1 到 6 之间的 n\n"; return 0; }
col.assign(n, false);
d1.assign(2 * n, false);
d2.assign(2 * n, false);
pos.assign(n, -1);
dfs(0);
long long fact = 1;
for (int i = 2; i <= n; i++) fact *= i;
cout << "\n解的个数:" << ans << "\n";
cout << "回溯真正进入的节点数:" << nodes << "\n";
cout << "被剪掉的分支次数:" << cuts << "\n";
cout << "而暴力要老老实实检查 " << n << "! = " << fact << " 种完整摆法。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

  • ✗ 冲突 —— 每出现一次,就意味着底下一整棵子树被跳过了。这些是省下来的白工。 而且它还会告诉你是三本册子里的哪一本挡住了 —— 对照第 9 步那个手动例子看。
  • ← 撤回 —— 从递归回来,把占用还回去。数一数它出现的次数,和 ✓ 放下 是一一对应的。

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

13单步看回溯

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

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

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

14★ 对拍验证

★ 正确的用法

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

★ 建议默写两遍:先默 scan.cpp 那种逐个比较的(好写、不容易错), 再默 fast.cpp 那种三本册子的。两份都该跑出一模一样的答案。

对拍器
生成器造 n ≤ 9 的棋盘(暴力是 n! 的,再大对拍就等不起了),并且有 1/3 的概率专门造 n ≤ 4 —— n=2、n=3 是「一个解都没有」的边界,最容易写挂。
// N 皇后 —— 回溯 + 剪枝
//
// 输入:n
// 输出:方案数(和 brute.cpp 完全一样)
//
// 和暴力的差别只有一句话:**每放下一个皇后就立刻检查,一冲突就掉头。**
//
// 三段式(这一章要练成肌肉记忆的东西):
// col[c] = d1[...] = d2[...] = true; // 进入:占用这一列和两条对角线
// dfs(r + 1); // 递归:交给下一行
// col[c] = d1[...] = d2[...] = false; // 撤销:恢复现场,让兄弟分支能用
//
// 「撤销」为什么必须有:从 dfs(r+1) 回来之后,我们要在**同一行**试下一个列。
// 试下一个列之前,必须把上一次的占用全部还回去,否则棋盘上会残留一个不存在的皇后。
// 忘写撤销是初学回溯的头号错误 —— 而且它不报错,只是答案偷偷变小。
//
// 两条对角线的编号(想清楚这个就不用每次现推):
// ↘ 方向(左上到右下):同一条上的格子 r - c 相同。加 n-1 是为了让下标非负 → r - c + n - 1
// ↙ 方向(右上到左下):同一条上的格子 r + c 相同 → r + c
#include <bits/stdc++.h>
using namespace std;
int n;
vector<bool> col, d1, d2; // 列 / ↘ 对角线 / ↙ 对角线 是否已经被占
long long ans = 0;
void dfs(int r) {
if (r == n) { ans++; return; } // 出口:n 行全放满了,凑出一个方案
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; // 撤销
}
}
int main() {
if (!(cin >> n)) return 0;
if (n <= 0) { cout << 0 << "\n"; return 0; }
col.assign(n, false);
d1.assign(2 * n, false);
d2.assign(2 * n, false);
dfs(0);
cout << ans << "\n";
return 0;
}
点一下即可编辑

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

  • 把撤销那一行删掉 → 答案变小(n=8 会从 92 变成 4)。这是头号错误。
  • 只撤销 col[c],忘了撤对角线 → 更隐蔽,小 n 时甚至可能碰巧对
  • d1 的下标写成 r - c(忘了 + n - 1)→ 负数下标,vector 越界, 运气好当场崩,运气不好静默地读到别的内存
  • d2 写成 r - c + n - 1(两条对角线复制粘贴没改)→ 答案偏大
  • 出口写成 if (r == n - 1) { ans++; return; } → 最后一行没放就开始数了
  • 逐个比较那份里忘了 abs(写成 r - i == c - pos[i])→ 只查了 ↘ 一个方向, ↙ 上的冲突全漏,答案偏大。★ 这一条正好说明第 9 步为什么要有两本对角线册子。
⚠ 对拍能抓住越界吗

上面第三条那种越界,对拍不一定能抓住 —— 程序可能没崩,只是读到了垃圾值, 而垃圾值碰巧让答案对了。

小数据尤其容易蒙混过关。所以除了对拍,本地调试时把 vector 的 [] 换成 .at(), 或者编译时加上 -fsanitize=address,undefined,让越界当场炸给你看。 对拍是查逻辑错的,不是查内存错的 —— 两种工具,别混着用。

15回头看:这一章其实在讲搜索

★ 一个通用的框架

把 N 皇后的代码抽象一层,就是所有搜索题的骨架:

void dfs(int 第几步) {
    if (走完了) { 记录答案; return; }

    for (每一个可能的选择) {
        if (这个选择不合法) continue;      // ← 剪枝,全部的分数都在这儿

        做出选择;                          // 进入
        dfs(下一步);                       // 递归
        撤销选择;                          // 撤销
    }
}

阶段 3 的搜索章节,本质上都是在这个框架里换东西:

  • 第 13 章 DFS 网格:「选择」变成「往四个方向走」
  • 第 16 章 DFS 剪枝:专门研究怎么把那个 continue 写得更狠
  • 第 18 章迭代加深:给「第几步」加一个上限

框架就这一个。你现在已经拥有它了 —— 后面学的都是往里面填不同的东西。

✓ 这一章其实给了你两样东西,别只带走一样
  1. 回溯的三段式(进入 → 递归 → 撤销),以及「要不要撤销看别人读不读得到」。
  2. 「打勾册子」这个判重套路(第 8 步):把「和所有人比一遍」换成「查一次表」。

第 2 样和皇后一点关系都没有,它是独立的一件工具 —— 以后你遇到「有没有重复的」「这个状态走过没有」「这个位置占了没有」, 第一反应都该是:能不能给它编个号,开个数组记着?

想更快?(知道就行,不用现在写)

N 皇后还能再快很多倍:把 col、d1、d2 三本册子各压成一个整数的二进制位, 用位运算一次性算出「这一行所有能放的位置」。这就是状压(第 28 章)。

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

16自测

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

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

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

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