首页 重启DAY11 回溯算法套路③排列型回溯+N皇后
文章
取消

重启DAY11 回溯算法套路③排列型回溯+N皇后

跟着灵茶山艾府大佬学习算法思路的day11(并没有在一个月内掌握hot100)

b站链接如下:

回溯算法套路③排列型回溯+N皇后【基础算法精讲 16】

回溯算法本质就是遍历多叉树,只要能把问题抽象成树结构,就一定能用回溯算法解决。

“判断 → 不选 → 选(push/递归/pop)” 是子集和组合类回溯的标准模板。排列、搜索、分割等其他回溯题型模板不同,但核心思想都是”试错 → 回退”。

全排列 题目描述:

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

示例 1:

1
2
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:

1
2
输入:nums = [0,1]
输出:[[0,1],[1,0]]

示例 3:

1
2
输入:nums = [1]
输出:[[1]]

提示:

  • 1 <= nums.length <= 6
  • -10 <= nums[i] <= 10
  • nums 中的所有整数 互不相同

思路:

排列问题相比于组合,在排列中的元素的顺序是有区别的。在选择12之后还可以选择21,这是因为排列问题的答案是有序的,而组合问题的答案无序。

组合问题是基于子集问题演化的,而为了避免子集问题的答案无序带来的穷举困难,我们当时手动规定了子集问题的筛选顺序(下标递增等等)。所以即使组合问题的答案无序,我们实际上也手动规定了一个隐含的穷举顺序。

1
2
3
4
5
// 组合:传 start,保证只往后选,避免 [1,2] 和 [2,1] 重复
function combine(nums, start, path) { ... combine(nums, i + 1, ...) ... }

// 排列:不传 start,每轮从头选,但用 used[] 跳过已选的
function permute(nums, used, path) { ... permute(nums, used, ...) ... }

因此对于排列型来说,除了需要维护路径,还需要显式地维护一个组合(在组合型中被start隐式维护)。

image-20260526011945806

对于如何判断剩余可选择数,有几种不同的写法。可以用一个数组记录已选择的数字,用一个集合记录还剩哪些数字可以被选。

image-20260526012635831

这个抽象的集合实际上可以用数组实现,这是更通用的做法。

image-20260526032832827

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var permute = function(nums) {
    const n = nums.length;
    const path = [];
    const onPath = [];
    const ans = [];

    const dfs = (i) => {
        if (i === n) {
            ans.push(path.slice());
            return;
        }

        for (let j = 0; j < n; j++) {
            if (onPath.includes(nums[j])) continue; // 跳过已选的
            path.push(nums[j]);
            onPath.push(nums[j]);
            dfs(i + 1);
            onPath.pop();
            path.pop();
        }
    }

    dfs(0);

    return ans;
};

总结:

时间复杂度的分析是这题的一个重点,根据树状图,可以发现路径长度为n,有n!个叶子结点,所以时间复杂度是o(n*n!)。但显然有部分节点的复杂度会被重复考虑。

如果想更加精确地计算时间复杂度,可以考虑直接计算这棵树有多少节点,知道了节点个数也就知道了递归的次数。

而每层节点的总数,从下至上分别是A33到A30。

最终可以算出节点总数是e*n!(向下取整),这是一个精确的数值。也就是最终精准的复杂度是o(n!)。

最后再考虑上解法中复制路径数组的时间开销,最终是o(n*n!)的时间复杂度。空间复杂度是o(n)。

image-20260526025319922

N 皇后 题目描述:

按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q''.' 分别代表了皇后和空位。

示例 1:

img

1
2
3
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。

示例 2:

1
2
输入:n = 1
输出:[["Q"]]

提示:

  • 1 <= n <= 9

思路:

由于每个皇后一定不同行不同列,所以说明每行每列都恰好有一个皇后,考虑用一个长为n的数组col(下标为行号,元素为列号)来记录皇后位置,那么第i行的皇后就应该在第col[i]列。

如果仅有不同行不同列的要求,通过这个col,我们可以看出同样是一个枚举全排列的问题,但因为要求皇后也不能在同一斜线,所以我们需要进行判断,去掉部分排列。

image-20260526180145897

由于行代表下标,我们是按行插入,只需要保证插入的皇后的左上和右上没有皇后即可。通过行号列号之间的固定数学关系,我们可以限制插入的行为。

image-20260526182349750

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
var solveNQueens = function(n) {
    const ans = [];
    const path = []; // path[i] = j,表示第 i 行的皇后放在第 j 列
    const onPath = Array(n).fill(false); // 哪些列已被占用
    const diag1 = new Set(); // r - c,主对角线
    const diag2 = new Set(); // r + c,副对角线

    function dfs(r) {
        if (r === n) {
            // 根据 path 生成棋盘
            ans.push(path.map(j => '.'.repeat(j) + 'Q' + '.'.repeat(n - 1 - j)));
            return;
        }

        for (let c = 0; c < n; c++) {
            // 排列约束:这一列不能有皇后
            if (onPath[c]) continue;
            // 对角线约束:左上和右上不能有皇后
            if (diag1.has(r - c)) continue;
            if (diag2.has(r + c)) continue;

            // 选
            path.push(c);
            onPath[c] = true;
            diag1.add(r - c);
            diag2.add(r + c);

            dfs(r + 1);

            // 恢复现场
            path.pop();
            onPath[c] = false;
            diag1.delete(r - c);
            diag2.delete(r + c);
        }
    }

    dfs(0);
    return ans;
};

总结:

相较于全排列,n皇后主要有两个额外难点:

  1. 多维护两条对角线集合,列冲突排列本身已经约束,但对角线冲突是全新的,需要额外发现 r-cr+c 这个规律
  2. 结果的表示,全排列直接输出 path 就行,N皇后要转换成棋盘字符串

核心难点就是想到用 r±c 判对角线,其余仍然是排列的套路。

最后,对于onPath和其余集合的维护,主要有三种方式:

方式查找适用
静态数组 onPath[c]O(1)值域小且确定(如列号 0~n-1)
Set onPath.has(c)O(1)值域大或不确定(如 r-c 可能是负数)
动态数组 includes(c)O(n)都能用,但最慢

显然静态数组方式是相对好的。

本文由作者按照 CC BY 4.0 进行授权

重启DAY10 回溯算法套路②组合型回溯+剪枝

重启DAY12 动态规划入门:从记忆化搜索到递推