bigywater.tech
算法与数据结构 · LeetCode 52

LeetCode 52. N 皇后 II

上一题要求列出所有摆法,这一题只要求数出有多少种。既然不需要保存每种摆法,就可以把"哪些列、哪些对角线被占"压缩成三个整数位掩码:一位对应一列。每往下一行,两条对角线的影响各移动一位:\ 方向的掩码左移一位,/ 方向的右移一位。"当前行能放的位置"就是三个掩码的并集取反,用 avail & -avail 每次取出最低位的一个可放位置。本文验证这个位运算做法与上一题的集合做法个数完全一致,实测两者的耗时差距,给出五种语言的实现,并把掩码的变化做成分步演示。

40
组随机对拍,与暴力解的结果全部一致
19 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。基准解枚举全部排列再检查对角线,只数个数。一共 9 组用例,n 从 1 到 9:

真实实测 · 本机执行输出
输入 [1]
输出 1
输入 [2]
输出 0
输入 [3]
输出 0
输入 [4]
输出 2
输入 [5]
输出 10
输入 [6]
输出 4

位运算和集合:个数一致,耗时差 4 倍

下面对 n 从 1 到 11,用位运算的 solve 与用集合的回溯(上一题的做法,只数个数)各数了一遍,每个取三次中最快的一次:

真实实测 · 本机执行输出
n | 解的个数(位运算 solve)| 用集合的回溯得到的个数 | 位运算耗时 | 用集合耗时(各取三次中最快)
 1 |          1 |          1 |      0.00 ms |      0.00 ms
 2 |          0 |          0 |      0.00 ms |      0.00 ms
 3 |          0 |          0 |      0.00 ms |      0.00 ms
 4 |          2 |          2 |      0.00 ms |      0.01 ms
 5 |         10 |         10 |      0.01 ms |      0.02 ms
 6 |          4 |          4 |      0.02 ms |      0.08 ms
 7 |         40 |         40 |      0.07 ms |      0.33 ms
 8 |         92 |         92 |      0.29 ms |      1.33 ms
 9 |        352 |        352 |      1.41 ms |      5.89 ms
10 |        724 |        724 |      6.26 ms |     25.93 ms
11 |       2680 |       2680 |     30.01 ms |    129.96 ms

两种做法在所有 n 上的个数完全一致:1、0、0、2、10、4、40、92、352、724、2680,与文献里已知的 N 皇后解的个数一致。位运算比集合快约 4 倍(n = 10 时 6.26ms 对 25.93ms,n = 11 时 30.01ms 对 129.96ms),因为位运算一次性算出"当前行所有可放位置",不用逐列检查集合,也没有集合的增删开销。这里的耗时只是 Python 里的实测。

位掩码回溯:五种语言的实现

三个位掩码:cols 是已经被占的列,diag1 是被 \ 方向对角线占用的列(相对当前行),diag2 是被 / 方向占用的列。每往下一行,diag1 左移一位,diag2 右移一位,并且用 & full 丢掉超出棋盘的位。当前行的可放位置 avail = full & ~(cols | diag1 | diag2)。cols == full(每一列都放了皇后)时就是一个解。Python、Java、Go、JavaScript、C++ 都是真实编译运行的;JavaScript 的位运算只有 32 位,对 n ≤ 9 足够。

Python · 解法代码def solve(n): full = (1 << n) - 1 # n 个 1:所有列都被占满 def dfs(cols, diag1, diag2): # 三个位掩码:被占用的列、被"\"方向占用、被"/"方向占用(已对齐到当前行) if cols == full: return 1 count = 0 avail = full & ~(cols | diag1 | diag2) # 当前行可以放皇后的位置 while avail: bit = avail & -avail # 取最低位的 1:一个可放的位置 avail ^= bit count += dfs(cols | bit, ((diag1 | bit) << 1) & full, (diag2 | bit) >> 1) # 下一行:两条对角线的影响各移动一位 return count return dfs(0, 0, 0)
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
[1]  →  1
[2]  →  0
[3]  →  0
[4]  →  2
[5]  →  10
[6]  →  4
Java · 解法代码class Solution { private int full; public int totalNQueens(int n) { full = (1 << n) - 1; // n 个 1:所有列都被占满 return dfs(0, 0, 0); } private int dfs(int cols, int diag1, int diag2) { // 三个位掩码:被占用的列、"\"方向、"/"方向(已对齐到当前行) if (cols == full) return 1; int count = 0; int avail = full & ~(cols | diag1 | diag2); // 当前行可以放皇后的位置 while (avail != 0) { int bit = avail & -avail; // 取最低位的 1:一个可放的位置 avail ^= bit; count += dfs(cols | bit, ((diag1 | bit) << 1) & full, (diag2 | bit) >> 1); // 下一行:两条对角线的影响各移动一位 } return count; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
[1]  →  1
[2]  →  0
[3]  →  0
[4]  →  2
[5]  →  10
[6]  →  4
Go · 解法代码func totalNQueens(n int) int { full := (1 << n) - 1 // n 个 1:所有列都被占满 var dfs func(cols, diag1, diag2 int) int dfs = func(cols, diag1, diag2 int) int { // 三个位掩码:被占用的列、"\"方向、"/"方向(已对齐到当前行) if cols == full { return 1 } count := 0 avail := full &^ (cols | diag1 | diag2) // 当前行可以放皇后的位置 for avail != 0 { bit := avail & -avail // 取最低位的 1:一个可放的位置 avail ^= bit count += dfs(cols|bit, ((diag1|bit)<<1)&full, (diag2|bit)>>1) // 下一行:两条对角线的影响各移动一位 } return count } return dfs(0, 0, 0) }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
[1]  →  1
[2]  →  0
[3]  →  0
[4]  →  2
[5]  →  10
[6]  →  4
JavaScript · 解法代码function totalNQueens(n) { const full = (1 << n) - 1; // n 个 1:所有列都被占满 const dfs = (cols, diag1, diag2) => { // 三个位掩码:被占用的列、"\"方向、"/"方向(已对齐到当前行) if (cols === full) return 1; let count = 0; let avail = full & ~(cols | diag1 | diag2); // 当前行可以放皇后的位置 while (avail) { const bit = avail & -avail; // 取最低位的 1:一个可放的位置 avail ^= bit; count += dfs(cols | bit, ((diag1 | bit) << 1) & full, (diag2 | bit) >> 1); // 下一行:两条对角线的影响各移动一位 } return count; }; return dfs(0, 0, 0); }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
[1]  →  1
[2]  →  0
[3]  →  0
[4]  →  2
[5]  →  10
[6]  →  4
C++ · 解法代码using namespace std; class Solution { int full; int dfs(int cols, int diag1, int diag2) { // 三个位掩码:被占用的列、"\"方向、"/"方向(已对齐到当前行) if (cols == full) return 1; int count = 0; int avail = full & ~(cols | diag1 | diag2); // 当前行可以放皇后的位置 while (avail) { int bit = avail & -avail; // 取最低位的 1:一个可放的位置 avail ^= bit; count += dfs(cols | bit, ((diag1 | bit) << 1) & full, (diag2 | bit) >> 1); // 下一行:两条对角线的影响各移动一位 } return count; } public: int totalNQueens(int n) { full = (1 << n) - 1; // n 个 1:所有列都被占满 return dfs(0, 0, 0); } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
[1]  →  1
[2]  →  0
[3]  →  0
[4]  →  2
[5]  →  10
[6]  →  4
真实实测 · 本机执行输出
共 9 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 9/9
  Java        通过 9/9
  Go          通过 9/9
  JavaScript  通过 9/9
  C++         通过 9/9

耗时实测

n 分别为 6、8、10、11,每个规模取三次中最快的一次,基准是枚举全部 n! 个排列:

真实实测 · 本机执行输出
n | 暴力解 | 本文解法
         6 |     0.228 ms | 0.021 ms
         8 |    14.222 ms | 0.284 ms
        10 |      (未测,太慢) | 6.326 ms
        11 |      (未测,太慢) | 30.524 ms

n = 8 时,位运算回溯 0.28ms,枚举全部排列 14.22ms,约 50 倍。位运算回溯从 n = 10 到 n = 11 增长约 4.8 倍。基准在 n = 10 及以上没有再测。

随机对拍

Python 版本另外对 n 从 1 到 8 的随机取值和枚举排列做了对拍:

真实实测 · 本机执行输出
随机对拍 40 组(n 取 1 到 8 的随机值),solve 与 brute 的结果全部一致。

n 只有很少的几个值,固定用例已经覆盖了全部小规模输入。另外四种语言只在上面 9 组固定用例上验证过(n 从 1 到 9)。

分步演示:三个位掩码怎么变

下面的演示用 n = 4。掩码用 4 位二进制显示,最右边一位是第 0 列,1 表示这一列被占用(或可放)。第 0 行时三个掩码都是 0,可放位置是 1111;放在第 0 列之后,下一行的可放位置是 1100,也就是第 2 列和第 3 列(第 0 列被占,第 1 列在副对角线上)。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示最终统计出的个数与 solve 的返回值一致。

N 皇后 II:n = 4,用位掩码统计解的个数
已放置的皇后

    参考与说明

    • 官方题目页:LeetCode 52. N 皇后 II。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量。
    • 没有覆盖:只搜索左半边再翻倍的对称优化;n 大于 11 时的耗时。
    京ICP备2026067502号-1京公网安备11010502063487号