深度解析:LeetCode 51. N 皇后 —— 回溯算法的巅峰之作

0 阅读4分钟

N 皇后问题是算法面试中的“常客”,也是理解回溯算法(Backtracking)最经典的题目。很多初学者看到这道题,第一反应是“暴力穷举”,但很快就会因为超时而放弃。其实,只要掌握了 “逐行放置”“对角线数学映射” 这两个核心技巧,这道 Hard 级别的题目就能迎刃而解。

一、 题目核心与约束条件

题目要求在一个 n × n 的棋盘上放置 n 个皇后,使得它们彼此不能相互攻击。皇后的攻击范围极广,涵盖了:

  1. 同一行
  2. 同一列
  3. 同一主对角线(左上到右下)
  4. 同一副对角线(右上到左下)

二、 核心思路:降维打击与空间换时间

如果我们在 n × n 的网格里盲目尝试每个格子,时间复杂度会高得离谱。但我们可以利用规则进行降维
既然任意两个皇后不能同行,那么每一行必然且只能放置一个皇后

这样一来,我们就不需要遍历二维网格了,只需要逐行递归。在第 row 行,我们只需要尝试 0 到 n-1 列,检查当前位置是否安全即可。

为了保证 O(1) 的时间复杂度判断冲突,我们需要用三个布尔数组来记录“攻击范围”:

  • cols[col]:记录第 col 列是否被占用。
  • diag1[row - col]:记录主对角线是否被占用。
  • diag2[row + col]:记录副对角线是否被占用。

三、 难点深度解析:对角线的数学映射

这是本题最劝退小白的地方,但其实背后的数学逻辑非常优美:

  1. 副对角线()
    观察棋盘,同一条副对角线上的格子,其 行号 + 列号 的值是恒定的。
    例如:(0, 3)(1, 2)(2, 1)(3, 0),它们的 row + col 都等于 3。
    所以,我们可以直接用 row + col 作为副对角线的唯一索引。
  2. 主对角线()
    同一条主对角线上的格子,其 行号 - 列号 的值是恒定的。
    例如:(0, 0)(1, 1)(2, 2),它们的 row - col 都等于 0。
    注意:  row - col 可能会出现负数(比如 0 - 3 = -3),数组下标不能为负。因此,我们加上一个偏移量 n - 1,将其映射到正数区间。最终索引为 row - col + n - 1

四、 完整代码实现(JavaScript)

理解了上述逻辑,代码其实就是“做选择 -> 递归 -> 撤销选择”的标准回溯模板:

/**
 * @param {number} n
 * @return {string[][]}
 */
var solveNQueens = function(n) {
    const res = [];
    // 初始化棋盘,全部填充为 '.'
    const board = Array.from({ length: n }, () => Array(n).fill('.'));
    
    // 记录列、主对角线、副对角线的占用情况
    const cols = new Array(n).fill(false);
    const diag1 = new Array(2 * n - 1).fill(false); // 主对角线
    const diag2 = new Array(2 * n - 1).fill(false); // 副对角线

    // 回溯函数:从第 row 行开始放置皇后
    const backtrack = (row) => {
        // 终止条件:所有行都成功放置了皇后
        if (row === n) {
            // 将二维字符数组转换为字符串数组,加入结果集
            res.push(board.map(row => row.join('')));
            return;
        }

        // 在当前行的每一列尝试放置
        for (let col = 0; col < n; col++) {
            // 计算两条对角线的索引
            const d1 = row - col + n - 1; // 主对角线
            const d2 = row + col;         // 副对角线

            // 剪枝:如果当前列或对角线已被占用,直接跳过
            if (cols[col] || diag1[d1] || diag2[d2]) {
                continue;
            }

            // 1. 做选择:放置皇后,并标记占用
            board[row][col] = 'Q';
            cols[col] = true;
            diag1[d1] = true;
            diag2[d2] = true;

            // 2. 递归:进入下一行
            backtrack(row + 1);

            // 3. 撤销选择(回溯):拿走皇后,恢复未占用状态
            board[row][col] = '.';
            cols[col] = false;
            diag1[d1] = false;
            diag2[d2] = false;
        }
    };

    // 从第 0 行开始回溯
    backtrack(0);
    return res;
};

五、 复杂度分析

  • 时间复杂度:O(N!)。第一行有 N 种选择,第二行最多有 N-1 种选择,依此类推。虽然实际运行中剪枝会跳过大量无效分支,但最坏情况下依然是阶乘级别。
  • 空间复杂度:O(N)。主要是递归调用栈的深度(N 层)以及三个辅助数组的空间(O(N) + O(2N-1) + O(2N-1)),忽略存储结果的空间,额外空间为 O(N)。

六、 总结

N 皇后问题看似复杂,实则是对回溯思想数学映射能力的双重考察。只要记住“逐行放置”的降维策略,以及 row+col 和 row-col+n-1 这两个对角线公式,你就能轻松写出优雅的解法。建议大家在看完解析后,自己动手敲一遍代码,感受“做选择”与“撤销选择”的对称美。