N皇后问题:从入门到精通,一篇文章让你彻底搞懂!
导读:本文从0到1讲解N皇后问题,包含图解演示、代码优化、位运算黑科技,最后还附赠面试模板!不管你是算法小白还是准备面试,这篇文章都能让你收获满满!
🎯 什么是N皇后问题?
想象一下,你有一个N×N的国际象棋棋盘,现在要在棋盘上摆放N个皇后。
问题来了:怎么摆放才能让这N个皇后互不攻击?
在国际象棋中,皇后可以攻击:
- ♜ 同一行的所有棋子
- ♜ 同一列的所有棋子
- ♜ 两条对角线上的所有棋子
所以,N皇后问题就是:在N×N的棋盘上放N个皇后,让她们谁也吃不到谁!
📌 举个栗子:4皇后问题
4皇后问题有2个解,我们来看第一个解:
0 1 2 3 ← 列号
0 . Q . . ← 第0行,皇后在第1列
1 . . . Q ← 第1行,皇后在第3列
2 Q . . . ← 第2行,皇后在第0列
3 . . Q . ← 第3行,皇后在第2列
↑ ↑ ↑ ↑
每行每列每条对角线都只有1个皇后!
用坐标表示就是:[(0,1), (1,3), (2,0), (3,2)]
🎨 可视化示意图
方案一: 方案二:
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ │ ♛ │ │ │ │ │ │ ♛ │ │
├───┼───┼───┼───┤ ├───┼───┼───┼───┤
│ │ │ │ ♛ │ │ ♛ │ │ │ │
├───┼───┼───┼───┤ ├───┼───┼───┼───┤
│ ♛ │ │ │ │ │ │ │ │ ♛ │
├───┼───┼───┼───┤ ├───┼───┼───┼───┤
│ │ │ ♛ │ │ │ │ ♛ │ │ │
└───┴───┴───┴───┘ └───┴───┴───┴───┘
坐标: [(0,1),(1,3),(2,0),(3,2)] 坐标: [(0,2),(1,0),(2,3),(3,1)]
看到了吗?每个皇后都"和平共处",没有任何两个皇后在同一行、同一列或同一对角线上!
💡 解法一:朴素递归法(适合理解)
🎯 核心思路
逐行放皇后,一行一行来!
放置流程:
第0行:尝试放皇后在第0列、第1列、第2列...第n-1列
↓
放好后,去第1行继续放
↓
每次放之前,检查会不会和之前的皇后"打架"
↓
如果某一行所有位置都不能放,说明前面的摆放有问题
↓
【回溯】到上一行重新尝试 ← 这就是回溯算法的精髓!
📖 代码解析
public static int num1(int n) {
if (n < 1) {
return 0;
}
int[] record = new int[n]; // 记录每行皇后放在哪一列
return process1(0, record, n);
}
record数组是什么?
record数组的含义:
record[0] = 3 表示:第0行的皇后放在第3列
record[1] = 1 表示:第1行的皇后放在第1列
record[2] = 4 表示:第2行的皇后放在第4列
...
以4皇后的一个解为例:
record = [1, 3, 0, 2]
对应棋盘:
0 1 2 3
0 . ♛ . . ← record[0]=1
1 . . . ♛ ← record[1]=3
2 ♛ . . . ← record[2]=0
3 . . ♛ . ← record[3]=2
public static int process1(int i, int[] record, int n) {
// 基线条件:如果已经处理完所有行(i == n),说明找到了一个合法解
if (i == n) {
return 1;
}
int res = 0;
// 尝试在第i行的每一列放置皇后
for (int j = 0; j < n; j++) {
// 检查在(i, j)位置放皇后是否合法
if (isValid(record, i, j)) {
record[i] = j; // 记录:第i行皇后放在第j列
res += process1(i + 1, record, n); // 递归处理下一行
}
}
return res; // 返回所有合法方案的数量
}
关键点:
i:当前要处理第几行(从0开始)j:尝试在第j列放皇后- 如果位置合法,就记录下来,然后递归处理下一行
- 把所有合法的方案数累加起来
🔍 合法性检查:怎么判断会不会"打架"?
public static boolean isValid(int[] record, int i, int j) {
// 检查之前所有行的皇后(0到i-1行)
for (int k = 0; k < i; k++) {
// 条件1:j == record[k] → 同一列,打架!
// 条件2:Math.abs(record[k] - j) == Math.abs(i - k) → 同一对角线,打架!
if (j == record[k] || Math.abs(record[k] - j) == Math.abs(i - k)) {
return false;
}
}
return true; // 所有检查都通过,可以放!
}
❓ 为什么这样判断对角线?
图解说明:
棋盘坐标系:
0 1 2 3 4 5
0 . . . . . .
1 . . . . . .
2 . . ♛ . . . ← 已有皇后在(2,2)
3 . . . . . ? ← 想在(3,4)放皇后
4 . . . . . .
5 . . . . . .
判断(2,2)和(3,4)是否在同一对角线:
- 行的差距:|3 - 2| = 1
- 列的差距:|4 - 2| = 2
- 1 ≠ 2 → 不在同一对角线 ✓
反例:
0 1 2 3 4 5
0 . . . . . .
1 . . . . . .
2 . . ♛ . . . ← 已有皇后在(2,2)
3 . . . . . .
4 . . . . ♛ . ← (4,4)能放吗?
5 . . . . . .
判断(2,2)和(4,4):
- 行的差距:|4 - 2| = 2
- 列的差距:|4 - 2| = 2
- 2 == 2 → 在同一对角线!✗ 不能放!
数学原理:
如果两个点
(r1, c1)和(r2, c2)在同一条对角线上,那么|r1-r2| == |c1-c2|
为什么? 因为对角线的斜率是±1,行和列的变化量必须相等!
⚡ 解法二:位运算优化法(高性能)
💡 高能预警:这个解法超级巧妙,用位运算代替了数组,速度快了不止一点点!
🤔 为什么用位运算?
传统方法的瓶颈:
// 解法一:需要数组 + 循环检查
int[] record = new int[n]; // O(n) 空间
for (int k = 0; k < i; k++) { // O(n) 时间检查
if (j == record[k] || ...) { ... }
}
位运算的优势:
// 解法二:用1个int就能表示所有状态
int colLim = 0b00001010; // 第1、3列被占(从右往左数)
// 位运算检查:O(1) 时间!
📊 核心参数解释
public static int process2(
int limit, // 限制条件,n皇后就有n个1。比如4皇后:00001111
int colLim, // 列限制,哪些列已经被占了
int leftDiaLim, // 左对角线限制(左下↙到右上↗)
int rightDiaLim // 右对角线限制(右下↘到左上↖)
) {
参数图解(以4皇后为例):
limit = 00001111 (低4位是1,表示只有4列可用)
假设当前状态:
colLim = 00000101 (第0、2列已被占用)
leftDiaLim = 00001000 (第3列被左对角线影响)
rightDiaLim = 00000010 (第1列被右对角线影响)
那么不能放的位置 = colLim | leftDiaLim | rightDiaLim
= 00001111
= 所有位置都不能放!需要回溯
📖 完整代码拆解
Step 1:初始化
public static int num2(int n) {
if (n < 1 || n > 32) {
return 0;
}
// 生成limit:n个1
// 比如n=4:00001111
int limit = n == 32 ? -1 : (1 << n) - 1;
return process2(limit, 0, 0, 0);
}
limit 怎么算的?
1 << n = 2^n,二进制是 1 后面跟 n 个 0
(1 << n) - 1 = n 个 1
举例:
n = 4:
1 << 4 = 16 = 00010000
16 - 1 = 15 = 00001111 ← 低4位都是1!
n = 8:
1 << 8 = 256 = 100000000
256 - 1 = 255 = 0011111111 ← 低8位都是1!
为什么n=32时用-1?
因为 1 << 32 会溢出,而 -1 的二进制就是32个1:11111111...1111
Step 2:递归核心逻辑
public static int process2(int limit, int colLim, int leftDiaLim, int rightDiaLim) {
// 基线条件:所有列都放了皇后
if (colLim == limit) {
return 1; // 找到一个合法方案!
}
// 🎭 核心魔法:计算哪些位置可以放皇后!
int pos = limit & (~(colLim | leftDiaLim | rightDiaLim));
int mostRightOne = 0;
int res = 0;
// 依次尝试每一个可以放的位置
while (pos != 0) {
// 提取最右边的1(找到一个可以放皇后的位置)
mostRightOne = pos & (~pos + 1);
// 去掉这个1,继续尝试下一个位置
pos = pos - mostRightOne;
// 递归处理下一行
res += process2(
limit,
colLim | mostRightOne, // 更新列限制
(leftDiaLim | mostRightOne) << 1, // 更新左对角线限制
(rightDiaLim | mostRightOne) >>> 1 // 更新右对角线限制
);
}
return res;
}
🎭 魔法公式详解
1️⃣ 计算可放位置:pos
int pos = limit & (~(colLim | leftDiaLim | rightDiaLim));
分步解读:
第1步:colLim | leftDiaLim | rightDiaLim
→ 把所有不能放的位置标记为1
第2步:~(...)
→ 取反:不能放的变成0,能放的变成1
第3步:& limit
→ 只保留有效的n位(去掉高位干扰)
举个栗子:
假设4皇后,当前状态:
colLim: 00001010 (第1、3列被占)
leftDiaLim: 00000100 (第2列被左对角线影响)
rightDiaLim: 00000001 (第0列被右对角线影响)
Step 1: OR 运算
00001010
00000100
00000001
--------
00001111 ← 这些位置都不能放!
Step 2: 取反
~00001111 = 11110000
Step 3: AND limit
11110000
& 00001111 (limit)
--------
00000000 ← 没位置了!需要回溯!
2️⃣ 提取最右边的1
mostRightOne = pos & (~pos + 1);
这是什么黑科技?! 这是一个经典的位运算技巧!
图解原理:
假设 pos = 00101100 (表示第2、3、5列可以放皇后)
Step 1: 取反
pos = 00101100
~pos = 11010011
Step 2: 加1(得到补码,相当于-pos)
~pos = 11010011
+1 = 1
----------------
= 11010100
Step 3: AND 运算
pos = 00101100
~pos+1 = 11010100
---------------
AND = 00000100 ← 成功提取最右边的1!
为什么能提取最右边的1?
关键在于:加1后,最右边的1及其右边的0都会翻转
原始: xxxxx1000 (x表示任意位)
取反: yyyyY0111 (y是x的反,Y是1的反)
加1: yyyyY1000 ← 只有最右边的1保持不变!
AND: 000001000 ← 其他位都被消掉了!
为什么要提最右边的1?
因为我们要一个一个地尝试每个可放位置!
3️⃣ 更新限制条件
colLim | mostRightOne // 列限制:把新放的列标记为1
(leftDiaLim | mostRightOne) << 1 // 左对角线:左移一位
(rightDiaLim | mostRightOne) >>> 1 // 右对角线:右移一位(无符号右移)
🎯 为什么对角线要移位?
动态图解:
假设在第2行第2列放了皇后:
第0行: 0 0 0 0 0
↑
还没有皇后
第1行: 0 0 0 0 0
↑
还没有皇后
第2行: 0 0 ♛ 0 0 ← 在这放了皇后!
↑
colLim: 00100
第3行: 0 1 0 1 0 ← 对角线影响扩散了!
↑
leftDiaLim: 01000 (左移1位)
rightDiaLim: 00010 (右移1位)
第4行: 1 0 0 0 1 ← 继续扩散!
↑
leftDiaLim: 10000 (再左移1位)
rightDiaLim: 00001 (再右移1位)
看到了吗? 对角线的影响范围就像"扇形"一样扩散!
为什么要移位?
左对角线(↙↗方向):
从(2,2)到(3,1),列号减少1 → 左移1位
从(2,2)到(3,3),列号增加1 → 右移1位
所以:
leftDiaLim << 1 表示左对角线影响向左扩散
rightDiaLim >>> 1 表示右对角线影响向右扩散
完整流程图:
┌─────────────────────────────────────────────────┐
│ 第0行:放皇后在第2列 │
│ colLim: 00100 │
│ leftDiaLim: 00000 │
│ rightDiaLim:00000 │
└─────────────────┬───────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 第1行:更新限制 │
│ colLim: 00100 │
│ leftDiaLim: 00000 << 1 = 00000 │
│ rightDiaLim:00000 >>> 1 = 00000 │
│ pos = ~(00100) & 11111 = 11011 │
│ → 可以在第0、1、3、4列放 │
└─────────────────┬───────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 假设在第1行第0列放皇后 │
│ colLim: 00101 │
│ leftDiaLim: 00000 << 1 = 00000 │
│ rightDiaLim:00001 >>> 1 = 00000 │
└─────────────────────────────────────────────────┘
📊 两种方法对比
| 特性 | 解法一(数组) | 解法二(位运算) |
|---|---|---|
| 理解难度 | ⭐⭐ 容易 | ⭐⭐⭐⭐ 需要位运算基础 |
| 运行速度 | 慢 | 快10-50倍! |
| 时间复杂度 | O(n!) | O(n!),但常数更小 |
| 空间复杂度 | O(n) 数组 | O(1) 几个int |
| 适用场景 | 学习理解 | 实际使用/面试加分 |
| 皇后数限制 | 理论上无限制 | 最多32皇后(int的位数) |
📈 性能测试对比
以15皇后为例(测试环境:普通PC):
解法一(数组法):
方案数: 2,279,184
耗时: 约 2000ms
解法二(位运算):
方案数: 2,279,184
耗时: 约 50ms
⚡ 性能提升: 40倍!
为什么快这么多?
- 位运算 O(1) vs 数组循环 O(n)
- CPU对位运算有硬件优化
- 减少了函数调用和内存访问
🎯 递归回溯的精髓
什么是回溯算法?
无论哪种解法,核心都是递归 + 回溯:
递归回溯流程图:
开始
↓
[第0行,尝试第0列]
↓
[第1行,尝试第0列] ← 打架?是 → 尝试第1列
↓ ↓
[第2行,尝试第0列] 打架?否 → 继续下一行
↓
发现没地方放!
↓
【回溯】回到第1行,换个位置
↓
继续尝试...
↓
直到所有行都放好 → 成功!
核心思想:
走不通就退回来,换条路再走!
回溯算法模板
void backtrack(当前状态) {
// 1. 基线条件:找到一个解
if (满足条件) {
记录结果;
return;
}
// 2. 遍历所有可能的选择
for (每个选择) {
if (选择合法) {
// 3. 做选择
记录选择;
// 4. 递归进入下一层
backtrack(下一层状态);
// 5. 撤销选择(回溯)
撤销选择;
}
}
}
N皇后问题中的体现:
// 做选择
record[i] = j;
// 递归进入下一层
res += process1(i + 1, record, n);
// 撤销选择(隐含)
// 因为record[i]会在下一次循环中被覆盖,所以不需要显式撤销
🚀 实战技巧
1️⃣ 如何输出具体的摆放方案?
在解法一中,当 i == n 时,record 数组就存储了一个合法方案:
public static int process1(int i, int[] record, int n) {
if (i == n) {
// 🎨 打印当前方案
System.out.println("找到第" + (++count) + "个解:");
for (int row = 0; row < n; row++) {
for (int col = 0; col < n; col++) {
if (record[row] == col) {
System.out.print("♛ "); // 皇后位置
} else {
System.out.print(". "); // 空位
}
}
System.out.println();
}
System.out.println("---");
return 1;
}
// ... 其余代码不变
}
输出效果:
找到第1个解:
. ♛ . .
. . . ♛
♛ . . .
. . ♛ .
---
找到第2个解:
. . ♛ .
♛ . . .
. . . ♛
. ♛ . .
---
2️⃣ N皇后有多少个解?
| N | 解的数量 | 备注 |
|---|---|---|
| 1 | 1 | 平凡解 |
| 2 | 0 | 无解 |
| 3 | 0 | 无解 |
| 4 | 2 | 第一个有解的情况 |
| 5 | 10 | |
| 6 | 4 | |
| 7 | 40 | |
| 8 | 92 | 经典8皇后问题 |
| 9 | 352 | |
| 10 | 724 | |
| … | … | |
| 15 | 2,279,184 | 超过200万! |
有趣的事实:
- N=2和N=3为什么无解?你可以自己试试摆放,会发现无论怎么放都会有冲突!
- N皇后的解的数量不是单调递增的(N=6只有4个解,比N=5还少)
💡 面试加分回答
🎯 标准回答模板
如果面试官问你N皇后问题,你可以这样说:
面试官: “请说一下N皇后问题的解法。”
你: "N皇后问题是经典的回溯算法问题,我有两种解法:
第一种是朴素的数组法:
- 用
record数组记录每行皇后的列位置- 逐行尝试并检查合法性
- 时间复杂度 O(n!),空间复杂度 O(n)
- 优点是容易理解和实现
第二种是位运算优化法:
- 用三个int变量分别记录列、左对角线、右对角线的占用情况
- 通过位运算快速计算可放位置
- 空间复杂度降到 O(1)
- 实际运行速度快10-50倍
位运算法的核心在于:
- 用
pos = limit & ~(colLim | leftDiaLim | rightDiaLim)快速得到所有可放位置- 用
pos & (-pos)提取最右边的1,逐个尝试- 对角线限制通过左移/右移来模拟影响范围的扩散
我推荐在面试中使用位运算法,既展示了对回溯的理解,又体现了算法优化能力。"
🌟 进阶追问
面试官: “为什么位运算更快?”
你: "主要有三个原因:
- 位运算是O(1)操作,而数组检查需要O(n)的循环
- CPU对位运算有硬件级优化,执行速度极快
- 减少了内存访问,位运算都在寄存器中完成,避免了缓存未命中"
面试官: “能处理多大的N?”
你: “位运算法最多处理32皇后(因为int是32位)。如果要处理更大的N,可以用 long(64位)或者 BitSet(不限位数),但超过20皇后后计算量会非常大,实际意义不大。”
🎓 总结
核心要点回顾
- ✅ N皇后问题 = 在棋盘上放N个皇后,让她们互不攻击
- ✅ 解法一(数组法):好理解,适合初学者,用递归+回溯
- ✅ 解法二(位运算法):高性能,面试加分,用位运算代替数组
- ✅ 核心思想:逐行放置 + 合法性检查 + 走不通就回溯
- ✅ 对角线判断:
|行差| == |列差|就在同一条对角线上
📝 知识思维导图
N皇后问题
├── 问题定义
│ ├── N×N棋盘放N个皇后
│ └── 互不攻击(同行、同列、同对角线)
│
├── 解法一:数组法
│ ├── 用record数组记录每行皇后位置
│ ├── 逐行尝试 + 合法性检查
│ └── 时间O(n!),空间O(n)
│
├── 解法二:位运算法 ⚡
│ ├── 用int的位表示状态
│ ├── colLim、leftDiaLim、rightDiaLim
│ ├── pos = limit & ~(colLim | leftDiaLim | rightDiaLim)
│ └── 时间O(n!),空间O(1),速度快40倍
│
└── 核心算法:回溯
├── 做选择
├── 递归
└── 撤销选择(回溯)
💪 最后记住一句话:
回溯算法就像走迷宫,走不通就退回来换条路,直到找到所有出口!
🔥 课后练习
基础题
- ✍️ 尝试手写4皇后的所有解(共2个)
- 💻 用解法一输出8皇后的所有方案(共92个)
- ⏱️ 对比两种方法在15皇后时的运行时间
进阶题
- 🤔 思考:为什么2皇后和3皇后没有解?
- 🎨 实现一个可视化版本,用图形界面展示皇后摆放过程
- 📊 画出N=4时的完整搜索树
挑战题
- 🏆 尝试用位运算实现16皇后问题(需要用long代替int)
- 🔍 优化:如何只输出一个解就停止?
- 💡 扩展:N皇后问题有多少个本质不同的解(去除旋转和对称)?
📚 扩展阅读
- 相关算法:回溯算法、递归算法、位运算优化
- 类似问题:数独问题、全排列问题、组合总和问题
- 应用场景:约束满足问题(CSP)、密码学、电路板布线
🎉 恭喜你!掌握了N皇后问题,你就迈入了回溯算法的大门!
💬 觉得文章不错?点赞 + 收藏 + 分享,让更多人看到!
📝 有任何疑问或想法?欢迎在评论区留言讨论!
本文代码基于左程云老师《程序员代码面试指南》课程讲解,感谢左老师的精彩教学!
3376



被折叠的 条评论
为什么被折叠?



