LeetCode hot100——51.N 皇后:Java 回溯模板、列与对角线剪枝、O(n!) 复杂度分析

一句话说明核心方法

按行逐层放置皇后(每行必放一个),用 三个 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]colUseddiag1diag2 加了什么就撤什么,顺序一一对应。漏撤任何一个,后续分支都会带着"幽灵皇后"的冲突标记,漏解。

  • 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 皇后多了固定盘面的部分预填,剪枝思想一脉相承。
相关推荐
重生之小比特1 小时前
【Java SE】IDEA 调试 Debug 案例完整分析
java·ide·intellij-idea
晴天161 小时前
JavaScript 与 Java 垃圾回收机制对比
java·开发语言·javascript
xiaohua10091 小时前
JVM内存优化-jemalloc
java·jvm
吃饱了得干活1 小时前
Redisson 进阶实战:看门狗机制与分布式锁完全指南
java·redis·后端
淘源码d2 小时前
基于Java开发的上门家政系统源码,采用SpringBoot+MySQL+UniApp+Vue3技术栈,构建三端一体化平台
java·源码·上门家政·家政系统·同城家政·源码部署
Wang's Blog2 小时前
Java框架快速入门: Spring Security+OAuth2之多因子认证逻辑与实体类改造
java·开发语言·spring
ashiho2 小时前
【Spring】RequestParam/PathVariable/RequestBody详解
java·后端·spring·servlet·springmvc
Escalating_xu2 小时前
【mmap 进程间通信】从共享内存到跨进程唤醒:用互斥锁、条件变量控制多个进程
java·linux·开发语言·jvm
泡海椒2 小时前
脚本包导入实战:JQuick-Java自定义包引入与别名配置教程
java·开发语言·python