按列回溯放皇后,行与两条对角线各压成一个 8 位掩码,靠 avail&-avail 取最低可行行同时定出输出顺序,得到全部 92 个解。
启发记录: 进制优化8换后,是学习状态压缩的入门题目
OJ: roj
题目 ID: 1213
难度:普及-
标签:搜索回溯位运算python
创建: 2026-09-29 23:53
更新: 2026-10-07 11:22
形式化题目
在
因为共有 8 列、8 个皇后,且不同列是硬性约束,"每列恰好一个皇后"自动成立。 于是可以把一个解压缩成一个长度为 8 的序列
它必须满足
第一个条件等价于"不同行"("不同列"由下标 No. k(1 当且仅当
以数据中的第一个解为例,
1 0 0 0 0 0 0 0
0 0 0 0 0 0 1 0
0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 1
0 1 0 0 0 0 0 0
0 0 0 1 0 0 0 0
0 0 0 0 0 1 0 0
0 0 1 0 0 0 0 0正解
思路
朴素做法。 逐列放置皇后,用二维布尔数组记录"被攻击的格子":vis[0][r] 表示第 vis[1][r+c]、vis[2][r-c+7] 表示两条对角线被占。
每列枚举 0…7 行,三个 vis 都没标记就落子、递归,返回时清掉标记。
完整代码结构就是标准回溯,规模上界
瓶颈在哪? 慢并不是主要问题,真正的问题是每层状态被摊成了很多个格子:
- 判断"能放哪些行"要
for r in range(8)扫满 8 次,哪怕只有 1 行可用; - 对角线下标要现算(
r+c、r-c+7),+7这种偏移量纯粹是为了防越界; - 回溯时必须反向清零三个标记,写错了就不对称。
这三点的根源是同一句话:每层的状态被拆成了 8 个独立的布尔值,而不是一个整体。
优化一:把"哪些行被占"压成一个 8 位整数。 用 cols 的第
其中
优化二:两条对角线各用一个掩码,跨列时整体移位。 棋盘内的两条对角线各由一个恒定值刻画。
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |
|---|---|---|---|---|---|---|---|---|
| 0 | 7 / 0 | 6 / 1 | 5 / 2 | 4 / 3 | 3 / 4 | 2 / 5 | 1 / 6 | 0 / 7 |
| 1 | 8 / 1 | 7 / 2 | 6 / 3 | 5 / 4 | 4 / 5 | 3 / 6 | 2 / 7 | 1 / 8 |
| 2 | 9 / 2 | 8 / 3 | 7 / 4 | 6 / 5 | 5 / 6 | 4 / 7 | 3 / 8 | 2 / 9 |
| 3 | 10 / 3 | 9 / 4 | 8 / 5 | 7 / 6 | 6 / 7 | 5 / 8 | 4 / 9 | 3 / 10 |
| 4 | 11 / 4 | 10 / 5 | 9 / 6 | 8 / 7 | 7 / 8 | 6 / 9 | 5 / 10 | 4 / 11 |
| 5 | 12 / 5 | 11 / 6 | 10 / 7 | 9 / 8 | 8 / 9 | 7 / 10 | 6 / 11 | 5 / 12 |
| 6 | 13 / 6 | 12 / 7 | 11 / 8 | 10 / 9 | 9 / 10 | 8 / 11 | 7 / 12 | 6 / 13 |
| 7 | 14 / 7 | 13 / 8 | 12 / 9 | 11 / 10 | 10 / 11 | 9 / 12 | 8 / 13 | 7 / 14 |
这张表给出每个格子
关键在于跨列时的变化:从第
其中 bit 是本列选中行的单比特。右移时超出棋盘的高位会被下一层的
& 11111111 自动丢掉,不需要额外处理。
优化三:avail & -avail 取最低可行行。 反复取 avail 的最低位,枚举完就把它从
avail 里异或掉:
while avail:
bit = avail & -avail
avail ^= bit这样每层只枚举真正可用的行(空集直接跳过),而且低位 = 行号小, 恰好给出了数据要求的"行号升序"顺序。
优化四:回溯不再需要撤销代码。 三个掩码是函数的参数,每层拿到的都是一份新的值,
递归返回后上一层的 cols / d1 / d2 原封不动,因此一行清理代码都不用写。
递归函数用生成器产出每个完整解,list(queens(0, 0, 0)) 一次拿齐 92 个解。
下面对数据里第一个解 cols / d1 / d2 是从高位到低位的 8 位二进制(高位 = 行号 1),
avail 是本列还能落在哪些行,最后一列记录本列在选中行之前试过、
但深入下去无解而回退的行号。
| 列 |
cols 行占用 |
d1 副对角线 |
d2 主对角线 |
avail 可用行 |
本列之前回退掉的行 | 本列选中行 |
|---|---|---|---|---|---|---|
| 0 | 00000000 |
00000000 |
00000000 |
11111111 |
无 | 1 |
| 1 | 00000001 |
00000010 |
00000000 |
11111100 |
3, 4 | 5 |
| 2 | 00010001 |
00100100 |
00001000 |
11000010 |
2, 7 | 8 |
| 3 | 10010001 |
01001000 |
01000100 |
00100010 |
2 | 6 |
| 4 | 10110001 |
11010000 |
00110010 |
00001100 |
无 | 3 |
| 5 | 10110101 |
10101000 |
00011011 |
01000000 |
无 | 7 |
| 6 | 11110101 |
11010000 |
00101101 |
00000010 |
无 | 2 |
| 7 | 11110111 |
10100100 |
00010111 |
00001000 |
无 | 4 |
读这张表的要点有三处。
第一,avail 的 0 位可以直接读出冲突来源。第 0 列八位全是 1,所以取最低位得到行 1;
到第 1 列时 cols 已占行 1(...0001)、d1 左移成 ...0010,
两者相或后低两位为 1,取反并截断低 8 位后 avail 的低两位就变成 0——
行 1 因同行冲突、行 2 因副对角线冲突,都被一次位运算筛掉。
第二,“仅当前列合法”不等于“最终能成解”。第 1 列的 avail 是行 3~8,
其中行 3、4 在本列并不冲突,放下后也确实能继续往下走几层,
但这两支最多只能填满前 7 列,第 8 列(avail 恒为 0;
这种下游死路只能靠递归返回才能发现,也正是回溯的代价所在。
第三,越靠后候选越少。按实际搜索统计,处理
输出顺序从哪来? 题目没有给顺序规则,但判题是逐行比对的,所以顺序本身就是答案的一部分。 只能由搜索树的前序遍历决定:第一层是"第 0 列的行号"、第二层是"第 1 列的行号"……最内层是 “第 7 列的行号”,每层按行号从小到大尝试。真实数据的开头三个解可以印证这一点:
| 编号 | 列 0 | 列 1 | 列 2 | 列 3 | 列 4 | 列 5 | 列 6 | 列 7 |
|---|---|---|---|---|---|---|---|---|
| No. 1 | 1 | 5 | 8 | 6 | 3 | 7 | 2 | 4 |
| No. 2 | 1 | 6 | 8 | 3 | 7 | 4 | 2 | 5 |
| No. 3 | 1 | 7 | 4 | 6 | 8 | 2 | 5 | 3 |
| No. 92 | 8 | 4 | 1 | 3 | 6 | 2 | 7 | 5 |
前三个解的第 0 列都固定在第 1 行,而第 1 列依次是 5、6、7,严格递增;
直到"第 0 列 = 第 1 行"的分支全部走完,第 0 列才换成第 2 行。
所以枚举顺序是列优先、每层行号升序,用 bit = avail & -avail 正好实现。
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-29 23:53
# update_at: 2026-09-30 00:11
from collections.abc import Iterator
SIZE = 8 # 棋盘边长,本题固定为八皇后
ALL_ROWS = (1 << SIZE) - 1 # 八行全满:掩码的低 8 位全是 1
def queens(cols: int, d1: int, d2: int) -> Iterator[tuple[int, ...]]:
"""按列深搜,依次产出每个解:第 c 项是第 c 列皇后所在的行号(0 起)。
cols / d1 / d2 分别是已被占用的行、副对角线(左上→右下)、
主对角线(左下→右上)掩码;列每右移一格,两条对角线掩码就各整体移位一位,
于是"对角线冲突"退化成一次位与。每层都先取最低位的可放行,
所以产出顺序就是题目样例的顺序:列优先,每列按行号升序。
"""
if cols == ALL_ROWS: # 八列都放好了,前缀就是一个完整解
yield ()
return
avail = ALL_ROWS & ~(cols | d1 | d2) # 这一列还能落在哪些行
while avail:
bit = avail & -avail # 最低位的可行行
avail ^= bit # 该行枚举过就划掉
for tail in queens(cols | bit, (d1 | bit) << 1, (d2 | bit) >> 1):
yield (bit.bit_length() - 1,) + tail # 行号 = 二进制位数 - 1
def board(solution: tuple[int, ...]) -> Iterator[str]:
"""把一个解翻译成 8 行 `0/1` 棋盘文本:第 r 行只有 solution[c] == r 的那格是 1。"""
yield from (
' '.join('1' if solution[c] == r else '0' for c in range(SIZE)) + ' '
for r in range(SIZE)
)
def solve() -> None:
solutions = list(queens(0, 0, 0)) # 八皇后共 92 个解,按列枚举一次全部展开
out: list[str] = []
for no, solution in enumerate(solutions, 1):
out.append(f'No. {no}')
out += board(solution)
print('\n'.join(out))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-05 10:51
* update_at: 2026-10-05 10:51
*/
// main.cpp:八皇后问题,按列回溯放皇后,用位掩码压缩"行 / 两条对角线"的占用状态。
// 与 main.py 同一算法:每层取最低可行行,产出顺序就是题目要求的"列优先、行号升序"。
#include <bits/stdc++.h>
using namespace std;
const int SIZE = 8; // 棋盘边长,本题固定为八皇后
const int ALL_ROWS = (1 << SIZE) - 1; // 八行全满:掩码的低 8 位全是 1
int choose_col[SIZE]; // choose_col[c] 表示第 c 列皇后所在的行号(0 起)
int no; // 当前解的编号,从 1 开始递增
// cols / d1 / d2 分别是已被占用的行、副对角线(左上→右下)、
// 主对角线(左下→右上)掩码;列每右移一格,两条对角线掩码就各整体移位一位,
// 于是"对角线冲突"退化成一次位与。掩码只有 8 位,用 int 足够。
void dfs(int c, int cols, int d1, int d2) {
if (c == SIZE) { // 八列都放好了,choose_col[0..7] 就是一个完整解,直接输出
no++;
cout << "No. " << no << "\n";
for (int r = 0; r < SIZE; r++) { // 按行打印棋盘,皇后所在格是 1
for (int col = 0; col < SIZE; col++) {
cout << (choose_col[col] == r ? 1 : 0) << " ";
}
cout << "\n";
}
return;
}
int avail = ALL_ROWS & ~(cols | d1 | d2); // 这一列还能落在哪些行
while (avail) {
int bit = avail & -avail; // 最低位的可行行
avail ^= bit; // 该行枚举过就划掉
choose_col[c] = __builtin_ctz(bit); // 行号 = 二进制最低位的下标
dfs(c + 1, cols | bit, (d1 | bit) << 1, (d2 | bit) >> 1);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
dfs(0, 0, 0, 0); // 从第 0 列开始,三个掩码都为空
return 0;
}复杂度
- 时间:搜索树的每个节点对应一次落子尝试,因为
cols强制各行互不相同, 节点数不超过。 加上对角线剪枝,实际只发生 2 056 次落子尝试、产出 92 个解, 每个节点的代价是与 无关的常数次位运算,实测 17 ms(限时 1000 ms)。 - 空间:递归深度为 8,每层只保存三个整数,
;保存全部 92 个解需要 。输出本身有 828 行、13 147 字节, 实测峰值内存约 15 MB(限时 128 MB)。 - 与 C++ 解法的关系:
main.cpp是同一算法的 C++ 版本:读入为空, 同样用三个掩码按列回溯、边搜边输出,不需要vis数组,复杂度同为。 Python 版本靠掩码把常数压下来后同样快,本题不存在 Python 特有的 TLE 风险。
总结
- 先压缩状态,再谈优化:把"8 个格子的布尔值"换成一个 8 位整数, 三类冲突(行、两条对角线)就都变成一次位与,循环和显式撤销一起消失。
- 对角线随列号移位:
型对角线跨列时行号整体加 1, 型整体减 1, 于是"记列号 + 算下标"被替换成一次 << 1/>> 1,这是本题位运算写法的核心技巧。 - 枚举顺序也是答案的一部分:本题输出顺序必须与判题数据一致,
它由搜索树的前序遍历与每层的枚举方向共同决定;
avail & -avail在"取最低可行行"的同时顺手实现了"行号升序"。 - 不可变参数让回溯免费:每层递归拿到的都是新的掩码值, 返回即恢复上一层状态,不需要任何清理代码。
- 八皇后共有 92 个解,其中 12 个在旋转与镜像下互不等价; 本题只要求按固定顺序输出全部 92 个,不要求去重。
图示解析
这张图展示输出顺序是怎么从搜索树里长出来的:根节点开始按第 0 列分行, 实线标出数据里 No. 1~No. 3 走过的分支,虚线后面省略其余分支。
flowchart TD R["C0: 可选行 1..8"] --> A1["C0=1"] R --> A2["C0=2"] R --> A5["C0=5"] R --> A8["C0=8"] A1 --> B5["C1=5"] A1 --> B6["C1=6"] A1 --> B7["C1=7"] R -.->|"省略其余分支"| X["..."] B5 --> N2["C2=8"] N2 --> N3["C3=6"] N3 --> N4["C4=3"] N4 --> N5["C5=7"] N5 --> N6["C6=2"] N6 --> N7["C7=4"]
先看 C0=1 这个分支:它的三个子节点 C1=5、C1=6、C1=7 按行号从小到大排列,
正对应 No. 1、No. 2、No. 3 三个解;只有这一层全部走完,才会回到根节点换 C0=2,
这就是 No. 5 的第 0 列变成第 2 行的原因。再顺着实线往下看,每一层的分支都被上一层的
掩码缩小:越靠后的列,avail 为 0 的节点越多(函数在这里直接返回上一层,
不再往下展开),真正能走到第 8 列的只有 92 条路径——
这也是整棵树加起来只有 2 056 次落子尝试的原因。