在算法学习中,N 皇后问题是回溯算法的经典案例之一,它不仅考察对回溯思想的理解,更能锻炼我们对问题约束条件的转化能力。今天我们就来拆解 LeetCode 52 题------N 皇后 II,重点分析如何用回溯算法高效统计所有合法的放置方案数量,同时理解代码背后的逻辑设计。
一、问题回顾:N 皇后 II 的核心需求
N 皇后问题的核心规则很简单:将 n 个皇后放置在 n×n 的棋盘上,要求任意两个皇后不能相互攻击。皇后的攻击范围是:同一行、同一列、同一主对角线(从左上到右下)、同一副对角线(从右上到左下)。
与 N 皇后 I 不同,本题不需要返回具体的棋盘放置方案,只需要返回不同解决方案的数量,这意味着我们可以在回溯过程中直接统计有效方案,无需记录具体布局,一定程度上可以简化逻辑(但核心回溯思路不变)。
二、核心思路:回溯 + 约束剪枝
解决 N 皇后问题的核心思路是「回溯法」,本质是一种"试错"思想:从第一行开始,逐行放置皇后,每放置一个皇后,就记录它的约束条件(所在列、两条对角线),后续放置时避开这些约束,若走到最后一行(所有皇后都放置完毕),则计数加 1;若某一行无法放置皇后,则回溯到上一行,调整皇后的位置,继续尝试。
关键在于如何高效记录约束条件,避免重复判断:
-
同一列:皇后不能在同一列,因此用一个集合记录已经使用过的列索引。
-
主对角线(左上→右下):观察发现,同一主对角线上的皇后,其「行号 - 列号」的值是固定的(例如 (0,0)、(1,1)、(2,2),行-列=0),因此用一个集合记录已使用的「行-列」值。
-
副对角线(右上→左下):同一副对角线上的皇后,其「行号 + 列号」的值是固定的(例如 (0,2)、(1,1)、(2,0),行+列=2),因此用另一个集合记录已使用的「行+列」值。
通过这三个集合,我们可以在 O(1) 时间内判断当前位置是否可以放置皇后,实现高效剪枝,避免无效的尝试,提升算法效率。
三、代码逐行解析
先给出完整代码,再逐部分拆解,让大家清晰理解每一步的作用:
typescript
function totalNQueens(n: number): number {
const columns = new Set(); // 记录已使用的列
const diagonals1 = new Set(); // 记录已使用的主对角线(行-列)
const diagonals2 = new Set(); // 记录已使用的副对角线(行+列)
function backtrack(row: number): number {
// 终止条件:当行号等于n时,说明所有皇后都放置完毕,返回1个有效方案
if (row === n) {
return 1;
} else {
let count = 0; // 记录当前行开始的有效方案数
// 遍历当前行的每一列,尝试放置皇后
for (let i = 0; i < n; i++) {
// 剪枝:如果当前列、主对角线、副对角线已被使用,跳过当前位置
if (columns.has(i) || diagonals1.has(row - i) || diagonals2.has(row + i)) {
continue;
}
// 选择:放置皇后,记录约束条件
columns.add(i);
diagonals1.add(row - i);
diagonals2.add(row + i);
// 递归:继续处理下一行
count += backtrack(row + 1);
// 回溯:撤销选择,清除当前皇后的约束,尝试下一个位置
columns.delete(i);
diagonals1.delete(row - i);
diagonals2.delete(row + i);
}
// 返回当前行开始的有效方案总数
return count;
}
}
// 从第0行开始回溯,返回最终的方案数
return backtrack(0);
};
1. 初始化约束集合
我们定义了三个 Set 集合,分别用于记录已使用的列、主对角线、副对角线:
-
columns:存储已放置皇后的列索引,比如 columns.has(2) 表示第 2 列已经有皇后,不能再放置。
-
diagonals1:存储已使用的主对角线标识(行-列),比如某皇后在 (2,1),则 row - i = 1,后续所有 (row - i = 1) 的位置都不能放置。
-
diagonals2:存储已使用的副对角线标识(行+列),比如某皇后在 (2,1),则 row + i = 3,后续所有 (row + i = 3) 的位置都不能放置。
2. 回溯函数 backtrack(row)
回溯函数的参数 row 表示当前要处理的行,核心作用是:尝试在当前行的每一列放置皇后,统计从当前行开始的有效方案数。
-
终止条件:当 row === n 时,说明我们已经成功将 n 个皇后放置在 n 行上(每一行一个),此时返回 1,表示找到一个有效方案。
-
循环遍历:遍历当前行的每一列(i 从 0 到 n-1),判断当前位置 (row, i) 是否可以放置皇后。
-
剪枝操作:如果当前列 i 已被使用,或者当前位置所在的主对角线、副对角线已被使用,直接跳过该列(避免无效尝试)。
-
选择与递归:如果当前位置可用,就"选择"放置皇后,将对应的列、对角线标识加入集合,然后递归处理下一行(row + 1),并将递归返回的结果累加到 count 中(统计有效方案数)。
-
回溯操作:递归返回后,需要"撤销选择",将之前加入集合的列、对角线标识删除,这样才能尝试当前行的下一个列,实现回溯的"回退"效果。
3. 初始调用与返回结果
我们从第 0 行开始调用回溯函数(backtrack(0)),最终返回的就是所有有效方案的总数,也就是题目要求的结果。
四、算法复杂度分析
理解算法复杂度,能帮助我们更好地掌握其适用场景:
-
时间复杂度:O(n!)。最坏情况下,我们需要尝试每一行的所有可能列,第一行有 n 种选择,第二行有 n-2 种选择(排除当前列和两条对角线),第三行有 n-4 种选择,以此类推,整体接近 n! 的时间复杂度。但由于剪枝操作,实际运行时间会远小于 n!。
-
空间复杂度:O(n)。主要消耗在三个约束集合和递归栈上,集合的大小最多为 n(每个集合最多存储 n 个元素),递归栈的深度也为 n(从第 0 行到第 n-1 行),因此空间复杂度为 O(n)。
五、关键总结与优化思路
1. 核心要点
本题的核心是「回溯 + 剪枝」,其中剪枝的关键是用三个集合快速判断约束条件,避免无效尝试。相比于 N 皇后 I,本题不需要记录具体棋盘,因此代码更简洁,重点聚焦于"计数"。
2. 优化方向(可选)
如果想进一步优化空间,可以用位运算替代 Set 集合(适用于 n 较小的场景,比如 n ≤ 32)。因为 Set 集合的核心作用是"判断是否存在",而位运算可以用二进制位来表示约束条件,效率更高,空间消耗也更小。
例如,用三个整数 columns、diagonals1、diagonals2 分别表示列、主对角线、副对角线的使用情况,某一位为 1 表示该位置已被使用,0 表示未使用,通过位运算(与、或、异或)来判断和更新约束条件。
六、总结
N 皇后 II 是回溯算法的典型应用,核心在于理解"约束条件的转化"和"回溯的回退逻辑"。通过用集合记录列和对角线的使用情况,我们实现了高效剪枝,避免了无效尝试,让算法能够快速统计出所有有效方案的数量。
对于初学者来说,重点要掌握「选择-递归-回溯」的三步流程,以及如何将实际问题中的约束条件(如皇后不能相互攻击)转化为可代码化的判断逻辑(如用集合记录约束)。掌握了这道题,再去解决 N 皇后 I 或者其他回溯类问题,都会更加轻松。