一句话说明核心方法
按行逐层放置皇后(每行必放一个),用 三个 HashSet 分别记录已占用的列、主对角线、副对角线 做剪枝,递归到第 n 行就收集一份棋盘快照,这就是经典的"行优先 + 集合剪枝"回溯。
思路推导
题意转化:在 n×n 棋盘放 n 个皇后,任意两个不能同行、同列、同斜线,列出所有摆法。
关键观察 1 :每行恰好放一个皇后 ------ 那"行"这个维度就不用决策了。问题简化成:第 row 行的皇后放在哪一列? 每层决策从"n×n 个格子"缩小为"n 个列",决策树深度固定为 n。
关键观察 2:冲突检测可以 O(1) 完成------
- 同列:列号相同 → 用一个
Set<Integer>记录已用列 - 主对角线(↘ 方向):同一条线上所有格子满足
row - col为定值(如 (0,1)、(1,2)、(2,3) 都是 -1) - 副对角线(↙ 方向):同一条线上所有格子满足
row + col为定值(如 (0,2)、(1,1)、(2,0) 都是 2)
所以三个集合 colUsed / diag1(row-col) / diag2(row+col) 就能完整描述"当前棋盘上所有皇后的攻击范围",每步查询 O(1)。
为什么不用二维暴力判断 :每放一个皇后就往集合里登记一次,之后所有查询都是 O(1);如果每次都扫全盘验证,单次判断 O(n²),白白慢一个数量级。这是"空间换判断时间"的典型剪枝设计。
回溯树示意(n = 4,只展开左半部分)
row=0
┌─────────┼─────────┬─────────┐
col=0 col=1 ✗ col=2 col=3
(Q在第0列) (同列冲突? │ │
不会,但 │ │
后续剪掉) │ │
row=1 row=1 row=1
┌───┬───┬───┐ ...
col=0 col=1 col=2 col=3
✗ ✗ ✓ ✗
(同列) (主对角线) (副对角线)
│
row=2
┌────┬────┬────┐
col=0 col=1 col=2 col=3
✗ ✓ ✗ ✗
│
row=3
...
│
row=4 == n → 收集 ✓
[".Q..","...Q","Q...","..Q."]
以 col=0 → col=2 → ... 这条链为例:第 1 行的 4 个列分支里,col=0/1 被"同列或对角线"剪掉,只有 col=2、col=3 能深入,最终整棵树只走出 2 个叶子 ,对应题目输出 [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]。剪枝的威力就体现在:大量分支在入口处就被 continue 掉,连递归都不进。
Java 完整代码
java
class Solution {
List<List<String>> res = new ArrayList<>();
Set<Integer> colUsed = new HashSet<>(); // 已占用的列
Set<Integer> diag1 = new HashSet<>(); // 已占用的主对角线(row - col)
Set<Integer> diag2 = new HashSet<>(); // 已占用的副对角线(row + col)
public List<List<String>> solveNQueens(int n) {
char[][] board = new char[n][n];
for (int i = 0; i < n; i++) {
Arrays.fill(board[i], '.'); // 初始化全空棋盘
}
backTrack(board, 0, n);
return res;
}
private void backTrack(char[][] board, int row, int n) {
// 所有行都放完了,当前棋盘就是一种合法方案
if (row == n) {
List<String> tmp = new ArrayList<>();
for (char[] chars : board) {
tmp.add(new String(chars)); // 必须拷贝成 String 快照
}
res.add(tmp);
return; // ★ 收集后必须 return,不能继续往下走
}
// 本行只有"放哪一列"这一个问题,枚举 n 个列
for (int col = 0; col < n; col++) {
// 剪枝:同列 / 同主对角线 / 同副对角线,任一冲突直接跳过
if (colUsed.contains(col) || diag1.contains(row - col) || diag2.contains(row + col)) {
continue;
}
// 1. 放置,并把三个冲突维度登记进集合
board[row][col] = 'Q';
colUsed.add(col);
diag1.add(row - col);
diag2.add(row + col);
// 2. 递归到下一行(行号固定 +1,天然保证不同行)
backTrack(board, row + 1, n);
// 3. 撤销:棋盘恢复 '.' + 三个集合移除,严格对称
board[row][col] = '.';
colUsed.remove(col);
diag1.remove(row - col);
diag2.remove(row + col);
}
}
}
关键代码逐行解释
-
按行递归而不是按格子递归 ------
backTrack(board, row + 1, n)行号固定 +1,意味着"同行冲突"这个约束根本不会发生,不需要判断。这是把 n² 的搜索空间压成 n 层、每层 n 分支的关键建模,决策树规模直接从棋盘格子的组合降到 n 的排列量级。 -
diag1.contains(row - col)用差值标识主对角线 ------同一条 ↘ 线上row - col恒定,所以两个皇后只要差值相同就在同一条主对角线上。注意差值范围是-(n-1)到n-1,可能是负数,所以这里必须用 HashSet 而不是普通 boolean 数组(数组下标不能为负;要用数组就得整体偏移 n)。 -
diag2.contains(row + col)用和值标识副对角线 ------同一条 ↙ 线上row + col恒定,范围 0 到 2n-2,非负。一差一和,两个方向就都覆盖了。 -
三个 if 条件用
||串接后continue------这是剪枝位置的体现:冲突的列连放置、递归都不做,直接看下一列。不要写成"先放下、递归进去再发现冲突",那样会白白展开无效子树。 -
放置与撤销严格 4 对 4 对称 ------
board[row][col]、colUsed、diag1、diag2加了什么就撤什么,顺序一一对应。漏撤任何一个,后续分支都会带着"幽灵皇后"的冲突标记,漏解。 -
tmp.add(new String(chars))收集快照 ------board 是整个递归共享的,收集时必须把每行 char\[\] 拷贝成新的 String 。如果直接存引用(或存chars本身),后续回溯会把棋盘改回'.',已收集的方案全被污染。 -
if (row == n) { ...; return; }里的 return 不能少 ------你原代码这里漏了return。它之所以还能 AC,是因为进入 for 循环后每一列都命中colUsed.contains(col)(n 行各放了一个皇后,n 列全被占),全部 continue,侥幸没有越界访问board[n][col]。这是"碰巧不炸"的隐患写法,面试和实际工程里都可能翻车,务必补上。
时间、空间复杂度
-
时间复杂度:O(n!)
- 第 0 行最多 n 种放法;第 1 行去掉 1 个冲突列,最多 n-1 种;第 2 行约 n-2 种......但注意对角线约束比列约束更紧,实际分支往往少于 n-k,所以 n! 是上界。每找到一组合法解,拷贝棋盘快照花 O(n²),解的数量 ≤ n!,总计 O(n · n!) 级别(常数上被快照拷贝主导)。n ≤ 9,9! = 362880,完全可控。
-
空间复杂度:O(n)(不计输出)
- 递归栈深度 n;三个集合最多各存 n 个元素;board 是 O(n²) 但属于题目本身要构建的输出载体,额外状态是 O(n) 级。
易错点
-
收集后漏写
return(你原代码的问题) :没有 return 会带着 row == n 继续跑 for 循环。本题靠"所有列已被占"侥幸跳过,但这是定时炸弹------一旦约束建模稍有变化(比如允许某行空着),board[n][col]直接数组越界。终止条件命中后立刻 return 是回溯的铁律。 -
对角线用 boolean 数组直接存负下标 :把
diag1写成boolean[2n-1]却拿row - col当下标,负值会抛异常。要么用 HashSet(推荐),要么偏移:boolean[2n-1]存row - col + n - 1。 -
撤销不完整 :三个集合只撤了两个、或 board 忘了恢复
'.',都会导致后续分支的冲突判断失真,表现为漏解(本该出现的方案被幽灵皇后挡住)。加什么撤什么,一行都不能少。 -
收集时存引用而不是快照 :写成
tmp.add(Arrays.toString(chars))倒是新的字符串没问题,但写成把char[]引用塞进 List 之后再继续递归,board 被改,结果全错。核心原则:共享可变对象进结果集前必须拷贝。 -
按列递归而不是按行 :如果外层决策写成"第 col 列放哪一行",逻辑同样能对,但快照拼接方向和 most 教材相反,容易在行/列转换时写错下标。统一约定"外层决策行、内层枚举列"最不易错。
可复用模板
"逐行放置 + 集合剪枝"框架,所有棋盘放置类问题(N 皇后、数独、皇后 II)都能套:
java
class Solution {
private final List<List<String>> res = new ArrayList<>();
private final Set<Integer> cols = new HashSet<>(); // 列占用
private final Set<Integer> diag1 = new HashSet<>(); // row - col
private final Set<Integer> diag2 = new HashSet<>(); // row + col
public List<List<String>> solveNQueens(int n) {
char[][] board = new char[n][n];
for (char[] r : board) Arrays.fill(r, '.');
backtrack(board, 0, n);
return res;
}
// 模板:逐行决策 = 终止收集(return!) + 枚举列 + 剪枝 + 放置/递归/撤销
private void backtrack(char[][] board, int row, int n) {
if (row == n) { // ① 所有行放完
res.add(snapshot(board)); // ② 快照拷贝
return; // ③ 必须 return
}
for (int col = 0; col < n; col++) {
if (cols.contains(col)
|| diag1.contains(row - col) // ④ 三个维度剪枝
|| diag2.contains(row + col)) {
continue;
}
place(board, row, col, true); // ⑤ 放置 + 登记
backtrack(board, row + 1, n); // ⑥ 下一行
place(board, row, col, false); // ⑦ 撤销 + 注销
}
}
private void place(char[][] board, int row, int col, boolean put) {
board[row][col] = put ? 'Q' : '.';
if (put) { cols.add(col); diag1.add(row - col); diag2.add(row + col); }
else { cols.remove(col); diag1.remove(row - col); diag2.remove(row + col); }
}
private List<String> snapshot(char[][] board) {
List<String> out = new ArrayList<>();
for (char[] r : board) out.add(new String(r));
return out;
}
}
变体提示:
- 只数解不列方案 (LeetCode 52)→ 把收集快照换成
count++,其余一行不改。 - 用位运算加速 → 三个 Set 换成三个 int 的位掩码,
avail = ~(cols | diag1 | diag2)一条指令算出本行所有合法列,常数快很多(n ≤ 9 时收益有限,面试加分项)。 - 约束更多(数独 37)→ 决策维度从"列"变成"格子",剪枝集合换成行/列/宫的占用表,框架不变。
相似题及区别
- LeetCode 52 N 皇后 II:同一道题的计数版,不要求输出方案只返回解的个数,把快照收集换成计数器即可;是检验"是否真懂模板"的秒杀题。
- LeetCode 46 全排列:决策结构同源(每层从 n 个候选里选一个、用 Set 剪枝),但全排列的剪枝集只有"已选元素"一个维度;N 皇后多出两条对角线约束,且用行/列坐标编码,是"排列模板 + 空间约束"的升级版。
- LeetCode 78 子集 :同为回溯,但子集是"每个元素选/不选"的二叉决策、每层不定长;N 皇后是"每行必选一列"的定长多叉决策,且只在叶子收集。对照着看能加深"收集时机"的理解。
- LeetCode 37 解数独:约束最重的棋盘回溯------行/列/宫三组冲突表 + 空格枚举,还涉及"填失败要回滚整个格子状态";比 N 皇后多了固定盘面的部分预填,剪枝思想一脉相承。