0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1219,日期见页头。两边不一致时信原站。
题目描述
一个如下的 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第 ① 版:原样搬过来(样例就挂了)
// 第 ① 版:把第 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;}点「运行 ▶」看结果
n = 6 有 4 个解。题目要「前 3 个」,它打了 4 个 ——
样例的 4 行变成 5 行,一眼就能看出来。
⇒ 这是整个习题解析系列反复念叨的那句话的反面: 前面十一页里「样例挡不住」出现了四次, 而这道题是样例正好挡得住的那种 —— 所以第一件事永远是:拿样例跑一遍,逐字节比。 ⚠ 注意「一眼就能看出来」有个前提:你真的跑了、并且真的比了。 n = 6 时它打 4 个解,看着挺像那么回事,不比对行数是看不出来的。
3第 ② 版:格式改对 —— ★ 它就已经能 AC 了
改动只有一行半:加个 printed 计数器,够三个就不再往外打。
// 第 ② 版:格式改对了 —— 只打前三个解//// 改动只有一行半:加一个「已经打了几个」的计数器,够三个就不打了。// ⚠ **不能「找够三个就 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;}点「运行 ▶」看结果
很多人的下一步是「找够三个解就直接收工」—— 那样第四行的总数就错了
(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 <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 |
三种写法剪掉的是同一批分支,走的是同一棵树 —— 数次数根本分不出它们。
可秒表分得出(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 是为了不出现负下标
// 第 ③ 版(推荐写法):三个标记数组,判合法从 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;}点「运行 ▶」看结果
⚠ 这一版必须老老实实撤销 —— 三个标记是全局的,进去改了、出来要还原。
这正是第 4 章那一课:回溯 = 进入 + 递归 + 撤销,
而第 ① 版之所以不用写撤销,是因为它压根没留下任何痕迹(pos_[r] 下一轮会被直接盖掉)。
6第 ④ 版:三个数组换成三个整数(位运算)
两句话是全部:
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:viz 在 n = 6..13 上逐个对过)。
- ★ 先拿样例逐字节比一遍 —— 这道题的第一个坑(多打一行)样例就挡得住, 而前面十一页里有四道题是样例挡不住的。两种都要防,办法是同一个:真跑、真比。
- ⚠ 「只输出前三个」不等于「找够三个就收工」 —— 第四行的总数还要搜完才知道。
- ★★★ 快有两种来源:走得少,和每一步便宜。 这道题三个版本的节点数
一个都不差(
n = 13全是 4 674 890),秒表却差 18.7 倍 —— 本书惯用的「换尺子数次数」在这儿看不见任何东西。