回溯算法

168 阅读7分钟

1 前言

本文主要解决的问题如下:

  • 回溯算法是什么?
  • 解决回溯算法相关问题有什么技巧?
  • 如何学习回溯算法?
  • 回溯算法是否有规律可循?

1.1 回溯算法概述

其实,回溯算法和DFS算法可以认为是同一种算法。

抽象的说,解决一个回溯问题,实际上就是遍历一遍决策树的过程,树的每一个叶子节点存放着一个合法答案。将整棵树遍历一遍,就将叶子节点的答案收集起来,就能得到所有的合法答案。

站在回溯树上的一个节点,需要思考三个问题:

  • 路径:已经做出的选择
  • 选择列表:当前可以做的选择
  • 结束条件:到达树的底层,无法再做选择的条件

回溯算法的基本框架为:

List<E> result = new ArrayList<>();
void backtrack(路径, 选择列表) {
    if (end condition) {
        result.add(路径);
        return;
    }
    for (选择 in 选择列表) {
        do choose;
        backtrack(路径, 选择列表);
        undo choose;
    }
}

核心在于for循环里面的递归,在递归调用之前做选择,在递归调用之后撤销选择

下面通过几个例题来实战一下,探究其中的奥妙。

1.2 全排列问题

见LeetCode第46题T46-全排列问题

1.2.1 思路分析

这道题就是穷举出所有可能的排列。根据我们的经验,若要穷举出全排列:

  • 固定一个数字,将这个数字记录下来
  • 选择需要固定的第二个数字,记录下来
  • ...

如何固定一个数字?

  • 使用boolean[] visited数组来记录某个元素是否被访问过

如何记录数字?

  • 需要有一个容器,来记录历史访问到的数据

如何返回?

  • 当容器的元素个数等于数组的个数,也就是我们用完了所有的数字,可以返回

返回后要做什么?

  • 将加入路径的元素清除
  • 将该元素的访问标记清除

1.2.2 实现代码

boolean[] visited; // 访问标记
List<List<Integer>> res;
/**
     * 全排列问题
     * @param nums
     * @return
     */
public List<List<Integer>> permute(int[] nums) {
    res = new ArrayList<>();
    int n = nums.length;
    visited = new boolean[n];
    List<Integer> path = new ArrayList<>(); // 记录当前路径
    dfs(nums, path);
    return res;

}

private void dfs(int[] nums, List<Integer> path) {
    // 结束条件,nums元素全部在path中出现
    if (path.size() == nums.length) {
        res.add(new ArrayList<>(path));
        return;
    }

    for (int i = 0; i < nums.length; i++) {
        if (!visited[i]) { // 当前数字没有被访问过
            path.add(nums[i]); // 加入路径
            visited[i] = true; // 标记访问
            dfs(nums, path); // 进入下一层决策树
            int lastIndex = path.size() - 1;
            path.remove(lastIndex); // 取消选择
            visited[i] = false; // 取消标记

        }
    }
}

该算法的时间复杂度为o(N!)o(N!),穷举了整颗决策树。回溯算法并不像动态规划问题一样存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般比较高。

1.3 N皇后问题

见LeetCode第51题N皇后

1.4 总结

回溯算法就是一个多叉树遍历的过程,写backtrack函数的时候,一定要维护走过的[路径]和当前可以做选择的列表,当触发结束条件的时候,将路径计入结果集中。

2 排列|组合|子集问题

2.1 问题概述

排列|组合|子集问题,就是从给定nums中取出若干元素,主要有下面几种变体:

  • 元素无重复不可重复选取nums中的元素都是唯一的,每个元素只能使用一次

  • 重复元素不可重复选取nums中的元素存在重复,但是每个元素只能选取一次

  • 无重复元素可以重复选取nums中的元素唯一,每个元素可以被重复使用

  • 重复元素重复选取:重复元素就代表着可以重复选取

无论问题的形式如何变化,本质就是穷举出所有的解,这些解呈现树形结构,所以合理运用回溯算法框架,便可以将这些问题一网打尽。

  • 组合|子集树

image-20241011205609900

  • 排列树

image-20241011205922481

组合问题和子集问题其实是等价的,问题的三种形式,无非就是在组合树排列树剪掉或者增加一点树枝罢了。

2.2 子集-去重不可复选

见LeetCode第78题,78、子集

思路分析

  • 第一个元素:nums[0] ~ nums[n - 1]
  • 第一个节点的子节点:nums[1] ~ nums[n - 1]
  • 选择】对于某一层的某个节点,他的孩子节点的为nums[i + 1] ~ nums[n - 1]
  • 路径List<Integer> tempList

2.3 组合-去重不可重复选

见力扣第77题,[77-组合]。

思路见T77-组合

2.4 排列-去重不可重复选

全排列问题在[1.2 全排列问题](#1.2 全排列问题)中讲过。

2.5 子集|组合-元素可重不可重复选

标准的子集问题输入的nums是没有重复元素的,如果出现了重复元素,应当如何处理呢?

对于这样的问题,可以先对nums中的元素进行排序,如果nums[i - 1] == nums[i],就可以跳过深度搜索,实现剪枝的效果。

LeetCode中第T90 子集II描述了这样的问题。

2.6 排列-元素可重不可重复选

排列问题存在重复,比子集问题复杂,见LeetCode第47题全排列II

核心思路依旧是:

  • 排序
  • 剪枝

2.7 子集|组合-元素无重复可以重复选

见LeetCode第39题[组合总数]

2.8 排列-元素无重复可以重复选

LeetCode上没有类似的题,但是可以想想一下nums中的元素没有重复,可以可以重复选,总共有哪些排列?

若输入为nums = {1, 2, 3},总有有3×3=273 \times 3 = 27种排列方式。

标准的全排列问题需要使用剪枝数组boolean[] isVisited来进行剪枝,避免使用相同的元素。如果允许使用同一个元素,则去掉剪枝逻辑即可。

class Solution {

    List<List<Integer>> res = new LinkedList<>();
    LinkedList<Integer> track = new LinkedList<>();

    public List<List<Integer>> permuteRepeat(int[] nums) {
        backtrack(nums);
        return res;
    }

    // 回溯算法核心函数
    void backtrack(int[] nums) {
        // base case,到达叶子节点
        if (track.size() == nums.length) {
            // 收集叶子节点上的值
            res.add(new LinkedList(track));
            return;
        }

        // 回溯算法标准框架
        for (int i = 0; i < nums.length; i++) {
            // 做选择
            track.add(nums[i]);
            // 进入下一层回溯树
            backtrack(nums);
            // 取消选择
            track.removeLast();
        }
    }
}

2.9 总结

  • 元素无重复不可重复选:在于记录已经选择过的元素,需要使用boolean[] isvisted数组来判断当前元素是否合法
  • 元素有重复不可重复选:在于排序剪枝,如果两个相邻的子节点的元素相同,则执行剪枝
  • 元素无重复可重复选:在于去重逻辑,需要删除重复的东西

3 回溯算法两种穷举视角-球盒模型

在上一节中通过列举9种不同的排列组合问题,详细探究了回溯算法是如何解决这些问题的。

本节,将深入研究递归穷举算法的原理,为读者解释为什么要那样写。

先说重要结论:

  • 回溯的本质思维模式是【球盒模型】,一些回溯算法,皆从此出,别无二法
  • 【球盒模型】,必然有两种穷举视角,分别为【球】和【盒】
  • 从理论上分析,两种穷举视角本质上是一样的,但涉及到代码的实现,复杂度可能有优劣之分

3.1 穷举思维方式-球盒模型

首先回顾我们学过的排列组合知识:

  • 全排列A(n,k)A(n, k)表示从nn个球中有序地拿出kk个球,共有几种排列方式
A(n,k)=n!(nk)!A(n, k) = \frac{n!}{(n - k)!}
  • 组合C(n,k)C(n,k)表示从nn个球中一把抓出kk个球,结果可能为几种
C(n,k)=n!k!(nk)!C(n,k) = \frac{n!}{k!(n - k)!}

3.1.1 全排列

盒子视角

第一个盒子从n个盒子中选择一个球,有n种选择方法,剩余的k - 1个盒子从n - 1个球中选择自己的球,即

A(n,k)=nA(n1,k1)A(n, k) = nA(n - 1, k - 1)

球视角

当前球可以选择入盒子,也可以选择不入盒子

  • 不入盒子:将剩下的n1n - 1个球装进kk个盒子
  • 入盒子:将剩下的n1n - 1个球装进k1k - 1个盒子

即:

A(n,k)=A(nk)+kA(n1,k1)A(n,k) = A(n - k) + kA(n-1, k-1)

3.1.2 组合

由于组合不在乎球的顺序,所以盒子是1个容量为kk的盒子

盒子视角

第一个球可以选择nn个球的任意一个,然后剩余k1k - 1的容量从n1n - 1个球中选择,即

C(n,k)=nC(n1,k1)kC(n,k) = \frac{nC(n-1,k-1)}{k}

球视角

每个球有两种选择:

  • 装进盒子:将剩下的n1n - 1个球装进容量为k1k - 1盒子
  • 不进盒子:将剩下的n1n - 1个球装进k1k - 1盒子
C(n,k)=C(n1,k)+C(n1,k1)C(n,k) = C(n - 1,k) + C(n-1,k-1)

TODO

4 回溯算法实践

4.1 解数独

见LeetCode第37题[解数独]。

算法的核心思路非常简单,就是对每个空着的格子穷举1-9,如果遇到不合法的数字,即在同一行或者同一列或者3*3的格子里头有相同的数字,则跳过,如果遇到一个合法的数字,则继续穷举下一个空格。

我的具体思路见T37-解数独

4.2 集合划分

回溯算法是一种暴力求解方法,其过程就是穷举一棵决策树的过程,只要在递归之前做出选择,递归之后撤销选择即可。

本小节分析LeetCode第698题[划分为K个相等的子集]

本题我是没有一点思路的。

这道算法题就是让我们求集合的划分,可以借鉴球盒模型的抽象,从两种不同的角度来解决这道集合划分问题。

将装有n个数字的数组nums分成k个和相同的集合,类似于从一堆数字球中选择一个球,放入到第k个桶中,最后这k个桶数字之和要相同。

回溯算法的关键在于:如何正确地做选择,这样才能利用递归进行穷举。

4.2.1 从数字出发

从某个数字nums[i]来看,该数字要选择进入到bucket[j]

如何使用递归的方式,实现这种思路呢?

对数字递归遍历,而对桶使用遍历的方式。

int[] bucket = new int[k];

// 穷举 nums 中的每个数字
void backtrace(int[] nums, index) {
    // 返回条件
    if (index == nums.length) {
        return;
    }
    
    // 穷举每一个桶
    for (int i = 0; i< bucket.length; i++) {
        // 选择装进第 i 个桶
        bucket[i] += nums[index];
        // 递归下一个数字的选择
        backtrace(nums, index + 1);
        // 撤销选择
        bucket[i] -= nums[index];
    }
}

对上面的进行修改,就可以得到从数字角度出发的解:

/**
 * 划分为 K 个相等的子集
 * @param nums
 * @param k
 * @return
 */
public boolean canPartitionKSubsets(int[] nums, int k) {
    // 排除一些基本情况
    if (k > nums.length) return false;
    int sum = 0;
    for(int num : nums) {
        sum += num;
    }
    if (sum % k != 0) return false;

    // 桶集合,记录每个桶的和
    int[] buckets = new int[k];
    // 理论上每个桶集合中的数字和
    int target = sum / k;
    // 穷举,判断是否能够划分为 target 子集
    return backtrace(nums, 0, buckets, target);
}

/**
 * 递归判断是否可以拆分
 * @param nums
 * @param i
 * @param buckets
 * @param target 每个桶的目标和
 * @return
 */
private boolean backtrace(int[] nums, int i, int[] buckets, int target) {
    if (i == nums.length) {
        // 遍历检查所有桶中的数字之和是否为 target
        for (int j = 0; j < buckets.length; j++) {
            if (buckets[j] != target) {
                return false;
            }
        }
        return true;
    }

    // 穷举 nums[i] 可能放到的桶中
    for (int j = 0; j < buckets.length; j++) {
        if (buckets[j] + nums[i] > target) { // 剪枝逻辑
            // 这个桶桶不能放这个数字
            continue;
        }
        buckets[j] += nums[i];
        // 穷举递归下一个数字的选择
        if (backtrace(nums, i + 1, buckets, target)) {
            return true;
        }
        // 撤销数字的选择
        buckets[j] -= nums[i];
    }

    // 放到哪个桶中都是不可以的
    return false;
}

上述代码是可以进行优化的,为了降低算法的递归深度,我们尽量让算法走剪枝逻辑,也即是说用少量的数字就填满桶,因此我们可以预先对数组从大到小进行排序

计算复杂度分析

  • 对于每个数字,都有k个桶可以选择,所以组合出来的结果个数为knk^n,即时间复杂度为O(kn)O(k^n)

4.2.2 从桶的视角出发

从桶的视角出发,每个桶需要遍历nums数组中的所有数字,决定是否将当前数字装进桶中,当装满一个桶的时候,还要装下一个桶,直到所有的桶都装满。

/**
 * 带有备忘录的解法:从桶的视角出发
 * @param nums
 * @param k
 * @return
 */
public boolean canPartitionKSubsetsI(int[] nums, int k) {
    if (k > nums.length) return false;
    int sum = 0;
    for (int num : nums) sum += num;
    if (sum % k != 0) return false;

    // 每个桶的目标数字
    int target = sum / k;
    int used = 0; // 使用位图的方式,来判读某个元素是否被使用过了
    return dfs(k, 0, nums, 0, used, target);
}

HashMap<Integer, Boolean> memo = new HashMap<>();
private boolean dfs(int k, int curSum, int[] nums, int start, int used, int target) {
    if (k == 0) {
        return true;
    }

    if (curSum == target) {
        // 递归下一个桶
        boolean res = dfs(k - 1, 0, nums, 0, used, target);
        memo.put(used, res); // 记录 used 状态以及其 搜索结果
        return res;
    }

    if (memo.containsKey(used)) {
        return memo.get(used); // 如果备忘录中含有当前状态的结果,直接返回,避免冗余计算
    }

    for (int i = start; i < nums.length; i++) {
        if (((used >> i) & 1) == 1) {
            // 判断当前数字nums[i]是否被使用过
            continue;
        }
        if (nums[i] + curSum > target) continue;

        // 做选择
        used |= 1 << i;
        curSum += nums[i];
        if (dfs(k, curSum, nums, i + 1, used, target)) {
            return true;
        }
        // 撤销选择
        used ^= 1 << i;
        curSum -= nums[i];
    }
    return false;
}

在上面的方法中,使用到了位图的方式记录某个数字是否已经被访问过,并且使用了memo来记录当前状态下是否有正确的结果,降低计算的复杂度。

时间复杂度

  • 每个桶要遍历n个数字,对于每个数字有不装两种状态,所以对于每个桶,有2n2^n中选择,总共k个桶,时间复杂度为O(k×2n)O(k\times2^n)

回溯算法经典例题

T46-全排列问题

题目描述

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

示例 1:

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

我的思路

  • 定义一个访问标记int[n] visited
  • 结果集List<List<Integer>>

思路凌乱

T51-N皇后问题

题目描述

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

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

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

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

皇后可以攻击同一行、同一列、左上、左下、右上、右下四个方向上的任意单位

我的思路

  • 棋盘初始化char[][] board = new char[n][n],初始化为.
  • List<int[]> position用来存储皇后的位置,后续根据这个判断是否合法
  • 从第row = 0行开始递归
  • 递归函数dfs(row, col, postion)
    • 递归截止条件:
      • row >= n || position.size() == 8
    • 对于row行,每一列进行遍历,通过available(row, col, posiziotn)判断是否合法
    • 将当前棋子的位置放进position
    • 在棋盘board的对应位置置为Q
    • 递归遍历找到下一行
    • 将当前棋子从集合中移出
    • 棋盘对应位置为.
  • 判断是否合法:同一行|同一列|斜率为±1\pm 1的位置都是不合法的,单独一个方法去判断

T78-子集

题目描述

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的

子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

我的思路

  • DFS深度优先遍历

T77-组合

题目描述

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

我的思路

  • k限定了数的深度,所以递归结束条件由k决定,或者是临时列表的size
  • DFS深度优先遍历
    • [选择]对于某个固定的节点nums[i],其孩子节点可能为nums[i + 1] ~ nums[n - 1]
    • 返回条件size == k
  • 要先添加元素,再判断返回条件,防止出现重复组合

T90-子集II

问题描述

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的 子集(幂集)。

解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列

我的思路

  • 子集问题+去重
  • 如何去重?可以使用HashSet,值是排列内容字符串,如果已经存在,就不往结果集合里头插入了

优化思路

剪枝,排序之后的数组,如果当前数字和前一个数字相同,就不再进行遍历了,只遍历 第一条。

class Solution {

    List<List<Integer>> res = new LinkedList<>();
    LinkedList<Integer> track = new LinkedList<>();

    public List<List<Integer>> subsetsWithDup(int[] nums) {
        // 先排序,让相同的元素靠在一起
        Arrays.sort(nums);
        backtrack(nums, 0);
        return res;
    }

    void backtrack(int[] nums, int start) {
        // 前序位置,每个节点的值都是一个子集
        res.add(new LinkedList<>(track));
        
        for (int i = start; i < nums.length; i++) {
            // 剪枝逻辑,值相同的相邻树枝,只遍历第一条
            if (i > start && nums[i] == nums[i - 1]) {
                continue;
            }
            track.addLast(nums[i]);
            backtrack(nums, i + 1);
            track.removeLast();
        }
    }
}
}

T40-组合总和II

题目描述

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次

**注意:**解集不能包含重复的组合

我的思路

  • 组合中元素的个数是不是固定的,所以不能够使用多数之和
  • 深度优先遍历
    • 结束条件为start == nums.length,或者是curSum == target
    • 先排序,这样在搜索子节点的时候,可以及时跳出
    • 也需要进行剪枝

T47-全排列II

题目描述

给定一个可包含重复数字的序列 nums按任意顺序 返回所有不重复的全排列。

我的思路

  • 不重复的全排列,就需要剪枝
  • 剪枝之前先排序,保证相同的节点位置相邻
  • 如何剪枝?如果相邻的子节点相同,就跳过这个子节点
  • 增加一个变量,记录访问的前一个子节点,如果当前节点和之前的节点相同,就跳过

优化

剪枝条件可以为

if (i > 0 && nums[i] == nums[i - 1] && !vis[i - 1]) {
    continue;
}

T39-组合总数

题目描述

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。

对于给定的输入,保证和为 target 的不同组合数少于 150 个。

我的思路

  • 首先排序
  • 回溯算法判断是否满足target
  • 递归结束条件为target == 0

注意的点

  • 保证组合的不重复,遍历子节点的时候,起始索引为i + 1
  • 本体可以重复使用元素,因此递归搜索子节点的时候,起始索引为i

T37-解数独

题目描述

编写一个程序,通过填充空格来解决数独问题。

数独的解法需 遵循如下规则

  1. 数字 1-9 在每一行只能出现一次。
  2. 数字 1-9 在每一列只能出现一次。
  3. 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)

数独部分空格内已填入了数字,空白格用 '.' 表示。

我的思路

  • 遍历寻找下一个需要填数字坐标(i, j)
  • 根据函数isValid(i,j, board)判断合适的数字
  • 将合法的数字填入到board
  • 进入递归函数backtrace(i, j, board)判断下一个位置
  • 将当前写入的数字擦除
  • 返回条件所有空位都被填上了数字

需要注意的是,backtrace()函数的返回值是boolean类型的,这样可以在找到最优解的时候,直接返回

T698-划分为k个相等的子集

题目描述

给定一个整数数组 nums 和一个正整数 k,找出是否有可能把这个数组分成 k 个非空子集,其总和都相等。

示例 1:

输入: nums = [4, 3, 2, 3, 5, 2, 1], k = 4
输出: True
说明: 有可能将其分成 4 个子集(5),(1,4),(2,3),(2,3)等于总和。

我的思路

完全没有思路