题单 · 习题解析

洛谷 P1219 八皇后 Checker Challenge

★★ 三种写法走的是同一棵搜索树、节点数一个都不差,秒表却差 18.7 倍 —— 本书惯用的「换尺子数次数」在这儿失效了

原题:洛谷 P1219出自 第 4 章 回溯与状态恢复:N 皇后 的题单题面本地存档:2026-08-26
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1219,日期见页头。两边不一致时信原站。

题目描述

一个如下的 6 × 6 的跳棋棋盘,有六个棋子被放置在棋盘上,使得每行、每列有且只有一个, 每条对角线(包括两条主对角线的所有平行线)上至多有一个棋子。

原题里那张 6×6 的棋盘图

上面的布局可以用序列 2 4 6 1 3 5 来描述,第 i 个数字表示在第 i 行的相应位置有一个棋子,如下:

行号  1 2 3 4 5 6
列号  2 4 6 1 3 5

这只是棋子放置的一个解。请编一个程序找出所有棋子放置的解。 并把它们以上面的序列方法输出,解按字典顺序排列。 请输出前 3 个解。最后一行是解的总个数。

输入格式:一行一个正整数 n,表示棋盘是 n × n 大小的。

输出格式:前三行为前三个解,每个解的两个数字之间用一个空格隔开。 第四行只有一个数字,表示解的总数。

数据范围:对于 100% 的数据,6 ≤ n ≤ 13

来源:USACO Training Section 1.5(题目翻译来自 NOCOW)。

输入输出样例

输入

6

输出

2 4 6 1 3 5
3 6 2 5 1 4
4 1 5 2 6 3
4

n = 6 一共有 4 个解,所以前三行是前三个解、第四行是 4上面那段输出是仓库里的 p1219.cpp 真跑出来的。

1先看清楚:它和第 4 章那份差在哪

第 4 章已经把 N 皇后从头写到尾了, 所以这道题不是「怎么搜」的问题。真正要还的账只有两笔:

一、输出格式变了:只要前三个解,第四行是总数     <- ★ 样例就能挡住
二、n 从 8 变到 13:搜索树大了 2273 倍           <- 秒表才挡得住

★ 而这道题最值得带走的一条,藏在第二笔账里 —— 它和这本书一贯的做法正好相反, 见第 ④ 步。

2第 ① 版:原样搬过来(样例就挂了)

p1219Naive.cpp第 ① 版:把第 4 章那份搬过来
样例要求 4 行(3 个解 + 总数),它打了 5 行 —— 因为 n = 6 恰好有 4 个解,它一个不落全打了。
// 第 ① 版:把第 4 章那份 N 皇后原样搬过来
//
// 两处和第 4 章不一样,而这道题的分全在这两处:
// ⚠ 一、**它输出了所有解** —— 而题目只要**前三个**,第四行是解的总数。
// ★ 这一处**样例就能挡住**:n = 6 有 4 个解,它会打 5 行(4 个解 + 总数),
// 标准答案是 4 行(3 个解 + 总数)。
// ⚠ 二、判「这一格能不能放」用的是「和前面每个皇后逐个比一遍」,
// 也就是每放一格要花 O(行号) 的时间。n = 13 时这笔账不小 —— 见第 ③ 版。
//
// 输入:一行一个整数 n(6 ≤ n ≤ 13)。
#include <bits/stdc++.h>
using namespace std;
int n;
int pos_[20]; // pos_[r] = 第 r 行的皇后放在第几列
long long total_ = 0;
/** 第 r 行放在第 c 列合不合法:和前面每一个皇后比一遍 */
bool ok(int r, int c) {
for (int i = 1; i < r; i++) {
if (pos_[i] == c) return false; // 同列
if (abs(pos_[i] - c) == r - i) return false; // 同对角线(行差 == 列差)
}
return true;
}
void dfs(int r) {
if (r > n) {
total_++;
for (int i = 1; i <= n; i++) // ⚠ 有多少解就打多少行
cout << pos_[i] << (i == n ? '\n' : ' ');
return;
}
for (int c = 1; c <= n; c++) {
if (!ok(r, c)) continue;
pos_[r] = c;
dfs(r + 1); // ★ 这一版连「撤销」都不用写:
// pos_[r] 下一轮会被直接盖掉
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
dfs(1);
cout << total_ << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 这一处的价值:样例挡得住,而且只挡住这一处

n = 64 个解。题目要「前 3 个」,它打了 4 个 —— 样例的 4 行变成 5 行,一眼就能看出来。

⇒ 这是整个习题解析系列反复念叨的那句话的反面: 前面十一页里「样例挡不住」出现了四次, 而这道题是样例正好挡得住的那种 —— 所以第一件事永远是:拿样例跑一遍,逐字节比。 ⚠ 注意「一眼就能看出来」有个前提:你真的跑了、并且真的比了。 n = 6 时它打 4 个解,看着挺像那么回事,不比对行数是看不出来的。

3第 ② 版:格式改对 —— ★ 它就已经能 AC 了

改动只有一行半:加个 printed 计数器,够三个就不再往外打。

p1219Cut.cpp第 ② 版:只打前三个(能 AC)
换成 n = 8 试试:92 个解,前三行是字典序最小的三个。
// 第 ② 版:格式改对了 —— 只打前三个解
//
// 改动只有一行半:加一个「已经打了几个」的计数器,够三个就不打了。
// ⚠ **不能「找够三个就 return」** —— 第四行还要输出解的**总数**,
// 所以搜索必须走完,只是不再往外打。
//
// ★ 这一版**已经能 AC 了**(本机 n = 13 实测 0.71 秒,题目限时 1 秒)——
// 但这个余量薄得吓人,正文第 ④ 步把它拆开看。
#include <bits/stdc++.h>
using namespace std;
int n;
int pos_[20];
long long total_ = 0;
int printed = 0;
bool ok(int r, int c) {
for (int i = 1; i < r; i++) {
if (pos_[i] == c) return false;
if (abs(pos_[i] - c) == r - i) return false;
}
return true;
}
void dfs(int r) {
if (r > n) {
total_++;
if (printed < 3) { // ★ 只打前三个,但**不能提前收工**
printed++;
for (int i = 1; i <= n; i++)
cout << pos_[i] << (i == n ? '\n' : ' ');
}
return;
}
for (int c = 1; c <= n; c++) {
if (!ok(r, c)) continue;
pos_[r] = c;
dfs(r + 1);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
dfs(1);
cout << total_ << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 「够三个就 return」是这道题最典型的错法

很多人的下一步是「找够三个解就直接收工」—— 那样第四行的总数就错了 (n = 6 会打出 3 而不是 4)。

搜索必须走完,只是不再往外打。 这两件事在代码里离得很近, 在脑子里却是两个完全不同的决定。

本机实测(B 机:原生 Ubuntu / i5-10210U 8 线程 / 18 GB,2026-08-26,独占):

n 解的个数 第 ② 版用时
6 4 0.00 秒
8 92 0.00 秒
13 73 712 0.56 秒

它已经能过了(题目限时 1 秒)。⚠ 但 0.56 / 1.00 这个余量薄得吓人 —— 换一台慢一点的评测机就悬了。下面两版把它拉开。

4★★ 这道题最反直觉的一件事:换尺子失效了

这本书从第 3 章起反复用同一个手法:秒表在小数据上量不动,那就换一把尺子 —— 数「调用了多少次」「碰了多少格」。 这道题正好是它失效的那次

p1219Count.cpp换一把尺子:三种判法各走了多少个节点
三种写法各自跑一遍,数的是同一个动作:dfs 被调用了多少次。
// 换一把尺子:三种判法各自走了多少个节点
//
// ★★ 这一章最反直觉的一件事,就是靠它量出来的:
// **三种写法走的是同一棵搜索树,节点数一个都不差** —— 差的只是「每个节点多贵」。
// ⇒ 本书一贯的做法是「秒表量不动就换尺子数次数」,
// 而这道题**正好反过来:数次数完全看不出差别,只有秒表分得出**。
//
// 用法:./p1219Count <n> 打三种判法的节点数和解的个数
// ./p1219Count <n> csv 只打 `键,值`,给 check:viz 用
#include <bits/stdc++.h>
using namespace std;
int n;
long long nodesA = 0, nodesB = 0, nodesC = 0;
long long totA = 0, totB = 0, totC = 0;
int pos_[20];
bool col[20], d1[40], d2[40];
int full_;
bool ok(int r, int c) {
for (int i = 1; i < r; i++) {
if (pos_[i] == c) return false;
if (abs(pos_[i] - c) == r - i) return false;
}
return true;
}
void dfsA(int r) {
nodesA++;
if (r > n) { totA++; return; }
for (int c = 1; c <= n; c++)
if (ok(r, c)) { pos_[r] = c; dfsA(r + 1); }
}
void dfsB(int r) {
nodesB++;
if (r > n) { totB++; return; }
for (int c = 1; c <= n; c++) {
if (col[c] || d1[r + c] || d2[r - c + n]) continue;
col[c] = d1[r + c] = d2[r - c + n] = true;
dfsB(r + 1);
col[c] = d1[r + c] = d2[r - c + n] = false;
}
}
void dfsC(int r, int c, int l, int rr) {
nodesC++;
if (r > n) { totC++; return; }
int avail = full_ & ~(c | l | rr);
while (avail) {
int p = avail & -avail;
avail -= p;
dfsC(r + 1, c | p, (l | p) << 1, (rr | p) >> 1);
}
}
int main(int argc, char** argv) {
n = (argc > 1) ? atoi(argv[1]) : 8;
bool csv = (argc > 2 && string(argv[2]) == "csv");
full_ = (1 << n) - 1;
dfsA(1);
dfsB(1);
dfsC(1, 0, 0, 0);
if (csv) {
cout << "n," << n << '\n';
cout << "solutions," << totA << '\n';
cout << "nodesA," << nodesA << '\n';
cout << "nodesB," << nodesB << '\n';
cout << "nodesC," << nodesC << '\n';
cout << "same," << ((nodesA == nodesB && nodesB == nodesC) ? 1 : 0) << '\n';
} else {
cout << "n = " << n << ",解的个数 = " << totA << '\n';
cout << "① 逐个比 走了 " << nodesA << " 个节点\n";
cout << "③ 标记数组 走了 " << nodesB << " 个节点\n";
cout << "④ 位运算 走了 " << nodesC << " 个节点\n";
cout << (nodesA == nodesB && nodesB == nodesC
? "⇒ ★★ 三种写法走的是同一棵树,节点数一个都不差 —— 差的只是每个节点多贵\n"
: "⇒ 节点数不一样(那说明剪枝真的不同)\n");
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测:

n 解的个数 ① 逐个比 ③ 标记数组 ④ 位运算
6 4 153 153 153
8 92 2 057 2 057 2 057
10 724 35 539 35 539 35 539
12 14 200 856 189 856 189 856 189
13 73 712 4 674 890 4 674 890 4 674 890
★★★ 节点数一个都不差,秒表却差 18.7 倍

三种写法剪掉的是同一批分支,走的是同一棵树 —— 数次数根本分不出它们。 可秒表分得出(n = 13,同机同日独占):

版本 判「这一格能不能放」花多少 n = 13 用时
① / ② 逐个比 O(行号) 0.56 秒
③ 标记数组 O(1),三次数组访问 0.26 秒
④ 位运算 O(1),三次位运算 0.03 秒

「快」有两种来源:走得少,和每一步便宜。 换尺子数次数只看得见第一种;这道题三个版本的差距全在第二种上。 ★ 反过来说也成立:下次看到「次数一样但秒表不一样」,别怀疑秒表 —— 去看看每一步到底在干什么(第 45 章量过:同样是一次内存访问,顺序和随机差 80 多倍)。

5第 ③ 版:对角线的两个不变量

判合法要问三件事:这一列有人吗?两条对角线上有人吗? 前两个好办(一个 col[] 数组),对角线的诀窍是同一条对角线上,有一个和是不变的

   ↙ 右上到左下:行 + 列 相同        ->  d1[r + c]
   ↘ 左上到右下:行 − 列 相同        ->  d2[r - c + n]     <- 加 n 是为了不出现负下标
p1219.cpp第 ③ 版:三个标记数组(推荐写法)
// 第 ③ 版(推荐写法):三个标记数组,判合法从 O(行号) 变成 O(1)
//
// ★ 关键的一步只有一句话:**同一条对角线上的格子,有一个和是不变的**。
//
// ↘ 方向(左上到右下):行 − 列 相同 ⇒ 用 d2[r - c + n] 标记(加 n 是为了不出现负下标)
// ↙ 方向(右上到左下):行 + 列 相同 ⇒ 用 d1[r + c] 标记
//
// 于是「这一格能不能放」= 查三个布尔值,不用回头看前面的皇后。
// ⚠ 而这一版必须**老老实实撤销**(第 4 章那一课):标记是全局的,进去改了,出来要还原。
//
// ★★ 注意它和第 ② 版**搜索的是同一棵树** —— 走过的节点数一个都不差(正文第 ④ 步实测)。
// 快的不是「少走了路」,是「每一步便宜了」。
#include <bits/stdc++.h>
using namespace std;
int n;
int pos_[20];
bool col[20], d1[40], d2[40];
long long total_ = 0;
int printed = 0;
void dfs(int r) {
if (r > n) {
total_++;
if (printed < 3) {
printed++;
for (int i = 1; i <= n; i++)
cout << pos_[i] << (i == n ? '\n' : ' ');
}
return;
}
for (int c = 1; c <= n; c++) {
if (col[c] || d1[r + c] || d2[r - c + n]) continue;
col[c] = d1[r + c] = d2[r - c + n] = true; // 进入
pos_[r] = c;
dfs(r + 1);
col[c] = d1[r + c] = d2[r - c + n] = false; // ⚠ 撤销
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
dfs(1);
cout << total_ << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

⚠ 这一版必须老老实实撤销 —— 三个标记是全局的,进去改了、出来要还原。 这正是第 4 章那一课:回溯 = 进入 + 递归 + 撤销, 而第 ① 版之所以不用写撤销,是因为它压根没留下任何痕迹(pos_[r] 下一轮会被直接盖掉)。

6第 ④ 版:三个数组换成三个整数(位运算)

p1219Bit.cpp第 ④ 版:位运算,0.03 秒

两句话是全部:

int avail = full & ~(c | l | r);     // 这一行还能放哪几列,一次算出来
int p = avail & -avail;              // ★ lowbit:取出最低位的那个 1(第 38、46 章)
dfs(r + 1, c | p, (l | p) << 1, (r | p) >> 1);   // ★ 两条对角线各平移一格

★ 最后那一句就是「对角线」在位运算里的全部内容:往下走一行, ↙ 那条挡住的列整体左移一位,↘ 那条整体右移一位

这道题不需要它(第 ③ 版就能过)。放在这儿是因为它把「换个表示法能有多便宜」 摆得最清楚 —— 而 n 再大两格(14、15)时,它就是唯一跑得动的那个。

7四个版本并排

版本 改了什么 n = 6 样例 n = 13 用时 能过吗
p1219Naive 第 4 章那份原样搬 多打一行 0.63 秒 ✗ 格式
p1219Cut 只打前三个解 0.56 秒
p1219 对角线换成标记数组 0.26 秒
p1219Bit 标记数组换成三个 int 0.03 秒

★ 后三版逐字节相同check:vizn = 6..13 上逐个对过)。

这一页记住三句话
  1. 先拿样例逐字节比一遍 —— 这道题的第一个坑(多打一行)样例就挡得住, 而前面十一页里有四道题是样例挡不住的。两种都要防,办法是同一个:真跑、真比。
  2. 「只输出前三个」不等于「找够三个就收工」 —— 第四行的总数还要搜完才知道。
  3. ★★★ 快有两种来源:走得少,和每一步便宜。 这道题三个版本的节点数 一个都不差n = 13 全是 4 674 890),秒表却差 18.7 倍 —— 本书惯用的「换尺子数次数」在这儿看不见任何东西。