目录

题目描述

52. N 皇后 II

题意分析

给定整数 n,在 n × n 的棋盘上放 n 个皇后,使它们两两不能互相攻击,返回不同摆法的数量。与 51 题不同,这里只要计数,不需要把棋盘还原出来。

皇后的攻击范围是所在的行、列以及两条对角线。「n 个皇后放进 n 行且同行不能有两个」直接推出一个强约束:每一行恰好放一个皇后,一个不多一个不少。这把二维的「在棋盘上选 n 个格子」压缩成一维的「给每一行选一个列号」,搜索层数确定为 n,行冲突自动消失。

于是冲突判定只剩三类:列相同、主对角线相同(行列之差相等)、副对角线相同(行列之和相等)。这三类都是「某个编号是否已被占用」的集合成员判断,只需要查询和插入、不需要顺序,正是位集合最擅长的场景。

数据规模 1 <= n <= 9 小得反常,这本身就是信号:答案没有多项式公式(n = 9 时是 352),必须把每个摆法枚举出来;同时一行的占用状态可以完整塞进一个 int 的低 n 位里。

边界:n = 1 答案为 1n = 2n = 3 无解,答案为 0。好的实现应该让这几种情况从主逻辑里自然落出来,不需要任何特判。

解法:位运算回溯

核心思路

朴素做法是逐行枚举列号,对每个候选位置回头扫描前面已放的所有皇后,检查列与对角线是否冲突,单次检查 $O(n)$。用三个布尔数组(col[]diag1[]diag2[])把检查降到 $O(1)$ 是第一步优化,但每层仍要遍历 n 个列号、逐个查表,绝大多数候选都会被否掉,这些遍历纯属浪费。

于是问题变成:能不能一次性算出当前行所有合法的位置?把三个布尔数组换成三个整数的二进制位就可以——占用信息按位取并集,一条指令搞定。

状态定义:用 int 的第 j 位(从低位起)表示第 j 列,三个参数含义如下。

  • columns:第 j 位为 1 表示第 j 列已被前面某个皇后占用。
  • diagonals1:第 j 位为 1 表示当前行的第 j 列被某条「左上到右下」方向的对角线封锁。
  • diagonals2:第 j 位为 1 表示当前行的第 j 列被某条「右上到左下」方向的对角线封锁。

关键在于对角线状态如何随行推进。传统写法要给每条对角线单独编号(row - col + nrow + col),共 2n - 1 条,管理起来啰嗦。这里换个视角:对角线的封锁位置随行号增加而平移。若某皇后在第 r 行第 c 列,它的主对角线在第 r + 1 行封锁第 c + 1 列、第 r + 2 行封锁第 c + 2 列,也就是每下一行封锁位向高位挪一格,恰好是 << 1;副对角线则每行向低位挪一格,恰好是 >> 1。所以递归传参时写 (diagonals1 | position) << 1(diagonals2 | position) >> 1,对角线状态就自动完成了「随行平移」,不需要任何编号数组,也不需要显式撤销。

有了三个集合,当前行的合法位置就是 available = mask & ~(columns | diagonals1 | diagonals2),其中 mask = (1 << n) - 1 是低 n 位全为 1 的棋盘边界。并集是全部被封锁的列,取反得到未被封锁的列,再与 mask 相与,是为了抹掉取反产生的高位 1——那些位对应棋盘外的列;同时左移也会把封锁位挤到棋盘外,必须靠 mask 统一裁掉,否则垃圾位会一路累积。

逐个取出候选用 position = available & -available,这是补码的经典技巧:-available 等于按位取反再加一,与原值相与只保留最低位的那个 1。取完用 available -= position 移除它,循环到 available0,本行所有合法列就遍历完了。

不变量:进入 backtrack(row, columns, diagonals1, diagonals2) 时,第 0row - 1 行各放了一个互不攻击的皇后,且三个参数已经把它们对第 row 行的全部封锁效果折算完毕。函数返回值是「在此局面下把剩余 n - row 行放完的方案数」。因此 row == n 说明 n 个皇后全部合法落子,本身就是一个完整方案,返回 1;把各分支返回值累加即为总数。

解题步骤

  • 构造边界掩码mask = (1 << n) - 1,低 n 位全为 1,代表棋盘只有 n 列。它每一层都把移位溢出到棋盘外的封锁位裁掉,是这套位运算写法能成立的前提。
  • 从第 0 行、三个空集合开始递归:初始 columns = diagonals1 = diagonals2 = 0,表示尚未占用任何位置。
  • 递归出口row == n 返回 1。走到这里说明前 n 行都合法放置了皇后,即凑出一个完整方案;返回 1 而不是 true,是因为上层要做累加而非短路。
  • 计算可用位置available = mask & ~(columns | diagonals1 | diagonals2)available == 0 时下面的循环体一次都不进,直接返回 0,天然表达了「本行无处可放,此分支作废」,不必写额外的失败判断。
  • 逐位取出候选position = available & -available 取最低位的 1available -= position 把它从候选集中移除。这里用减法而非异或只是写法差异,因为该位确定为 1,效果一致。
  • 递归下一行columns | position 让该列永久占用;(diagonals1 | position) << 1(diagonals2 | position) >> 1 先把新皇后并入对角线集合、再整体移位,顺序不能颠倒——先移位后取并会漏掉当前这个皇后自己的对角线影响。
  • 累加返回值count += backtrack(...)。注意全程没有「撤销选择」的语句,因为所有状态都通过参数按值传递,子调用改不到上层的局部变量,回溯是自动完成的。
  • 返回 count,把本层所有分支的方案数汇总给上层。

n = 4 走一遍(mask = 0b1111,已知答案为 2)。

第 0 行:三个集合全为 0available = 0b1111,四列都可放,从最低位开始逐个尝试。
取第 0 列(position = 0b0001):传下去 columns = 0b0001diagonals1 = 0b0001 << 1 = 0b0010diagonals2 = 0b0001 >> 1 = 0b0000。第 1 行封锁集为 0b0011available = 0b1100,即第 2、3 列。先试第 2 列,得到第 2 行的封锁集 0b0101 | 0b1100 | 0b0010 = 0b1111available = 0,返回 0;改试第 3 列,第 2 行的 diagonals1 = 0b10100,其中第 4 位已溢出棋盘,正是被 mask 裁掉的那一位,最终 available = 0b0010 只剩第 1 列,放下后第 3 行 available = 0,仍返回 0。所以第 0 行放第 0 列的整棵子树颗粒无收
取第 1 列(position = 0b0010):传下去 columns = 0b0010diagonals1 = 0b0100diagonals2 = 0b0001。第 1 行封锁集为 0b0111available = 0b1000,只能放第 3 列。放下后第 2 行 columns = 0b1010diagonals1 = 0b11000diagonals2 = 0b0100available = 0b0001,只能放第 0 列。再放下后第 3 行 columns = 0b1011diagonals1 = 0b110010diagonals2 = 0b0010available = 0b0100,只能放第 2 列。放下后 row == 4,返回 1。这条路径对应摆法「各行依次放在第 1、3、0、2 列」。
取第 2 列:与上一条完全镜像,得到摆法「第 2、0、3、1 列」,返回 1
取第 3 列:与第 0 列镜像,同样一无所获,返回 0
四个分支累加得 0 + 1 + 1 + 0 = 2,与 n = 4 的正确答案一致。

顺带看边界:n = 1 时第 0 行 available = 0b1,放下即 row == 1,返回 1n = 2 时无论第 0 行放哪一列,第 1 行的 available 都为 0,两条分支各返回 0,总和为 0。两者都无需特判。

代码实现

class Solution {
    public int totalNQueens(int n) {
        return backtrack(n, 0, 0, 0, 0);
    }

    private int backtrack(int n, int row, int columns, int diagonals1, int diagonals2) {
        if (row == n) {
            return 1;
        }

        int mask = (1 << n) - 1;
        int available = mask & ~(columns | diagonals1 | diagonals2);
        int count = 0;
        while (available != 0) {
            int position = available & -available;
            available -= position;
            count += backtrack(n, row + 1, columns | position, (diagonals1 | position) << 1, (diagonals2 | position) >> 1);
        }
        return count;
    }
}
func totalNQueens(n int) int {
    var backtrack func(int, int, int, int) int
    backtrack = func(row int, columns int, diagonals1 int, diagonals2 int) int {
        if row == n {
            return 1
        }

        mask := (1 << n) - 1
        available := mask & ^(columns | diagonals1 | diagonals2)
        count := 0
        for available != 0 {
            position := available & -available
            available -= position
            count += backtrack(row+1, columns|position, (diagonals1|position)<<1, (diagonals2|position)>>1)
        }
        return count
    }
    return backtrack(0, 0, 0, 0)
}

复杂度分析

  • 时间复杂度:$O(n!)$ 级别。第 0 行有 n 个候选,第 1 行至多 n - 2 个(本列与两条对角线各封掉至少一个),越往下候选越少,搜索树节点数被 n! 上界卡住。位运算不改变这个量级,但把「每层枚举 n 列并三次查表」压成几条按位指令,常数极小。
  • 空间复杂度:$O(n)$,递归深度等于行数 n,每层只有 maskavailablepositioncount 这几个 int。三个状态集合是按值传递的整数而非数组,没有额外的 $O(n)$ 结构,这是位运算写法相对布尔数组写法的额外收益。

关键点总结

  • 把二维搜索降成一维决策:识别出「每行必放且只放一个」,是这道题从「在棋盘上选格子」变成「给每行选列号」的转折点,也是所有棋盘类回溯题的第一步。
  • 状态压缩的判断标准:当状态是「一组不超过 64 个元素的占用标记」且只做并集、取反、取最低位这类操作时,位集合几乎总是优于布尔数组。n <= 9 这样的小上界就是出题人在提示可以压缩。
  • 移位表达对角线随行平移,免去了 2n - 1 条对角线的编号映射,也免去了显式撤销——代价是必须用 mask 裁掉溢出位。这个技巧能整套搬到数独、骑士巡游等棋盘题上。
  • 参数按值传递即天然回溯:状态放在参数里而不是成员变量或数组里,就没有「忘记恢复现场」的风险,白板上写更不容易出错。
  • x & -x 取最低位 1x -= x & -x 消最低位 1,这对组合是位运算枚举子集/候选集的基本功,同样用于树状数组和状压 DP。
  • 面试视角:先说清「逐行放置 + 三集合冲突判定」的朴素回溯,写出来后再主动提出位运算优化并解释 << 1 / >> 1 的几何含义,是本题的加分路径。若面试官问「n 更大怎么办」,可答:n15 以上时可加对称性剪枝(第一行只枚举一半列,答案翻倍并单独处理正中列),但复杂度量级不变,本质上无多项式解。

易错点总结

  • 递归时先移位再取并:写成 (diagonals1 << 1) | position,当前行这个皇后的对角线影响没有跟着平移,下一行会把它封锁在同一列而不是斜方向,n = 48n = 81686,全部偏大。
  • 对角线忘记移位:直接传 diagonals1 | position,两个对角线集合退化成「列占用的副本」,只剩列约束,n = 4 返回 24(即 4!)而不是 2
  • mask 写成 1 << n 而不是 (1 << n) - 1:可用位只剩棋盘外的第 n 位,第 0 行放到界外、第 1 行就无位可放,任何 n 都返回 0
  • 漏掉 mask:写成 available = ~(columns | diagonals1 | diagonals2),取反后的高位全是 1,会被当成棋盘外的合法列继续放皇后,n = 4 直接跑出天文数字甚至栈溢出。
  • 取最低位写成 available & (available - 1):这是「消掉」最低位而不是「取出」最低位,position 会带上一堆无关的高位,n = 4 得到 97029900 这种彻底错乱的结果。
  • 忘记 available -= position:候选集永远不减少,while 循环在第一列上无限重复,直接死循环。
  • 递归出口返回 true / 0 而不是 1:出口返回 0 会让所有方案都被计成 0n = 8 输出 0 而不是 92
  • 把 52 题当成 51 题写:额外维护棋盘字符串数组并返回方案列表,n = 9 时内存与耗时都成倍上升,本题只需要一个计数器。

相似题目

题目 难度 考察点
51. N 皇后 困难 同一套搜索,但要还原棋盘,必须额外记录每行的列号并拼出字符串
面试题 08.12. 八皇后 困难 与 51 同题,输出格式略有差异,可直接套用本题骨架
37. 解数独 困难 同为「填格子 + 冲突集合」,约束来自行列宫三重,且需就地改写棋盘
79. 单词搜索 中等 网格上的回溯,状态是访问标记而非位集合,必须显式撤销
46. 全排列 中等 只有列约束、没有对角线约束,本题去掉两个对角线集合就退化成它
78. 子集 中等 每个元素二选一的无约束枚举,用来对照理解冲突剪枝带来的收益
473. 火柴拼正方形 中等 同为可行性搜索,剪枝靠「超出目标值」与「同构分支只试一次」
698. 划分为k个相等的子集 中等 分组型回溯,n 较小时同样可用位掩码压缩已选集合
191. 位1的个数 简单 专练 x & -xx & (x - 1) 这两个位技巧的差别