第 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 条还能立刻推出一个关键的表示法:
既然每行恰好一个皇后,一种摆法就可以写成 col[0], col[1], ..., col[n-1]——
「第 0 行放第几列、第 1 行放第几列……」。
又因为不能同列,这 n 个数互不相同,也就是说:
任何一种合法摆法都是 0..n-1 的一个排列。
于是「同行」和「同列」这两条规则被表示法本身吃掉了,只剩对角线要操心。 换个表示法就消掉两条规则 —— 这种事在竞赛里非常值钱,值得专门留意。
3暴力:把所有排列生成出来,再一个个检查
有了上面那句话,暴力就是现成的:第 3 章刚学过全排列,直接枚举 0..n-1 的每个排列,
每生成一个完整摆法,就检查一遍对角线。
对角线怎么判?两个皇后 (r1, c1) 和 (r2, c2) 在同一条对角线上,
当且仅当 行差的绝对值 == 列差的绝对值。在纸上画两个点验证一下,立刻就信了。
// 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;}点「运行 ▶」看结果
思路完全正确,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(建议 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;}点「运行 ▶」看结果
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 步那个骨架里,一份完整可交的回溯就成了:
// 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;}点「运行 ▶」看结果
初学的人很容易把「回溯」和「三个标记数组」当成同一件事。不是。
- 回溯是骨架:一步一步做决定,走不通就退回来换一个。
- 判断冲突只是骨架里的一句话,怎么写都行。
这份 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从「问所有人」变成「问那样东西」。 人可以有很多个,但「那样东西」只有一个。
- 「冲突」必须是「共用了同一样东西」 —— 而不是别的什么弯弯绕绕的关系。 (「同一天生日」是共用一个日子 ✓;「年龄差不超过 3 岁」就不是 ✗, 那种关系没法归到某一样具体的东西头上。)
- 那样东西的种类数得清,而且能编成
0, 1, 2, ...的号。 日子有 365 种,编号 0 到 364。 - 号的范围印得起一本册子。 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 是图省事,多一格不碍事。)
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() 换成查三本册子,就是这一章要背下来的那份代码:
// 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;}点「运行 ▶」看结果
核心就是这个三段式,请把它背成肌肉记忆:
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 步那两份差着几百倍才量得动)。所以换一把尺子:数次数。 次数换台机器也不会变,而秒数会。
// 两种冲突判断,各要做多少次「基本操作」—— 换一把可复现的尺子//// 输入: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;}点「运行 ▶」看结果
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把每一步打印出来
// 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;}点「运行 ▶」看结果
对着输出,专门找这两种行:
✗ 冲突—— 每出现一次,就意味着底下一整棵子树被跳过了。这些是省下来的白工。 而且它还会告诉你是三本册子里的哪一本挡住了 —— 对照第 9 步那个手动例子看。← 撤回—— 从递归回来,把占用还回去。数一数它出现的次数,和✓ 放下是一一对应的。
n = 4 的最后统计是:真正进入的节点 17 个,被当场剪掉 44 次,而暴力要检查 24 种完整摆法。
13单步看回溯
播放一遍,盯住这几件事:
- 淡红色的格子是被现有皇后攻击到的地方。放下一个皇后的瞬间,
一整片格子变红 —— 那就是
col、d1、d2三本册子在起作用。 一整条竖线是col,两条斜线就是d1和d2。 - 撤销的那一帧:皇后消失,红色也跟着退回去。如果代码里漏了撤销, 这片红色就会永远留在棋盘上 —— 想象一下那个画面,你就再也不会忘记写撤销了。
- 右边的节点数和剪掉的分支在一路涨,而最底下那个「暴力要检查的摆法数」是死的。 两者的比例就是剪枝的价值。
- 把 n 改成 6、7 各看一遍。
n = 6只有 4 个解 —— 中间那一大段全是白忙活, 但每一次白忙活都被剪枝提前掐断了。
14★ 对拍验证
把「回溯版」那一栏整个换成你自己默写的,再点开始对拍。
★ 建议默写两遍:先默 scan.cpp 那种逐个比较的(好写、不容易错),
再默 fast.cpp 那种三本册子的。两份都该跑出一模一样的答案。
// 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 章迭代加深:给「第几步」加一个上限
框架就这一个。你现在已经拥有它了 —— 后面学的都是往里面填不同的东西。
- 回溯的三段式(进入 → 递归 → 撤销),以及「要不要撤销看别人读不读得到」。
- 「打勾册子」这个判重套路(第 8 步):把「和所有人比一遍」换成「查一次表」。
第 2 样和皇后一点关系都没有,它是独立的一件工具 —— 以后你遇到「有没有重复的」「这个状态走过没有」「这个位置占了没有」, 第一反应都该是:能不能给它编个号,开个数组记着?
N 皇后还能再快很多倍:把 col、d1、d2 三本册子各压成一个整数的二进制位,
用位运算一次性算出「这一行所有能放的位置」。这就是状压(第 28 章)。
但请注意:位运算版和这一章的代码,剪的是同一批枝,省的是同一批工。 它快在常数上(正是第 11 步那张表在量的那种快),不是快在算法上。 先把这一章的写法练到闭着眼睛能写,再去追那个常数。
16自测
- 洛谷 P1219 八皇后 Checker Challenge解析 → —— USACO。本章原题加了输出前三个解,必须一次写对
- 洛谷 P1025 数的划分解析 → —— NOIP2001。搜索 + 剪枝,关键是想清楚「怎么避免数出重复的划分」
- 洛谷 P1123 取数游戏解析 → —— 标准的「选或不选 + 冲突检查 + 撤销」,和本章几乎同构
- 洛谷 P1731 生日蛋糕解析 → —— NOI1999。剪枝的天花板,不剪必超时。现在做不出很正常,学完第 16 章再来
四章走完,你手上有了这些东西:信任函数(第 1 章)、分解问题(第 2 章)、 决策树 + 深度优先(第 3 章)、回溯 + 剪枝 + 判重册子(第 4 章)。
这就是后面所有搜索题和一半 DP 题的地基。
接下来按顺序应该走阶段 1(基础技巧)。如果你实在按捺不住想写搜索, 可以先跳到第 13 章 DFS 网格 —— 你会发现那一章的代码, 和这一章的框架是同一个东西,只是把「放皇后」换成了「往四个方向走」。