算法总结之回溯法篇
算法总结之回溯法篇

算法总结之回溯法篇

前排声明,算法总结中的刷题顺序完全依照Carl的代码随想录

在解决了二叉树的问题后,我们正式迎来了回溯法的时代。二叉树中会多次使用递归来解决各种问题,而回溯与递归是深度绑定的,即回溯是递归的副产品,只要有递归就会有回溯

回溯法实际上是暴力搜索,并不是很高效的算法,但是会通过剪枝来优化性能。

回溯法经常解决的问题有:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 棋盘问题:N皇后,解数独等等

在Carl的讲解中,会将回溯法抽象成树形结构,个人认为这是最直观的讲解方式,一目了然。

回溯法拥有自己固定的模板,简称为回溯三部曲:

  • 回溯函数模板返回值以及参数
  • 回溯函数终止条件
  • 回溯搜索的遍历过程
void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

组合问题

以leetcode77组合一题为例,模拟回溯过程很容易得出下图的树形结构(这里copy一下Carl的图片):

如图所示,不难发现for循环是横向遍历,递归是纵向遍历,两者正好对应了树层树枝,而回溯法的本质就是在树层与树枝间不断找寻结果集。

不难写出代码:

class Solution {
    public ArrayList<List<Integer>> res = new ArrayList<>();  // 存放结果集
    public ArrayList<Integer> path = new ArrayList<>();  // 存放符合条件的组合

    // startIndex是起始数
    public void backTracking(int n, int k, int startIndex) {
        if (k == path.size()) {
            res.add(new ArrayList<>(path));	// 深拷贝,非引用
            return;
        }
        for (int i = startIndex; i <= n; i++) {
            path.add(i);
            backTracking(n, k, i + 1);
            path.remove(path.size() - 1);
        }
    }

    public List<List<Integer>> combine(int n, int k) {
        backTracking(n, k, 1);
        return res;
    }
}

这里要格外注意的是,res.add(new ArrayList<>(path));这一句。如果直接传入path,实际是传入了path列表的引用,后续对path的修改都会影响path的元素,所以这里要使用Java的深拷贝来复制一个新的列表传入。

有回溯必然要有剪枝来优化性能,这道题的剪枝可以这样想:

我们要求取k个元素,如果在树形结构的遍历过程中,此时选取的元素+剩余元素+已选元素的总个数不够k个,那么没有必要再去选择这个元素和其之后的元素,所以这就是剪枝条件:

  • 已经选取的元素个数: path.size()
  • 还需要的元素个数:k – path.size()
  • 集合n中至多可以开始的起始位置:n – (k – path.size()) + 1

修改for循环条件,增加上述剪枝条件即可:

for (int i = startIndex; i <= n - (k - path.size()) + 1; i++)

再以leetcode40组合总和II为例,这道题给出的数组 candidates 中会含有重复元素,要求解集的元素可以有重复,但要求解集不能包含重复的组合。

class Solution {
    public ArrayList<List<Integer>> res = new ArrayList<>();
    public ArrayList<Integer> path = new ArrayList<>();

    public void backTracking(int[] candidates, int target, int sum, int startIndex) {
        if (sum > target) return;
        if (sum == target) {
            res.add(new ArrayList<>(path));
            return;
        }
        for (int i = startIndex; i < candidates.length && sum + candidates[i] <= target; i++) {
            if (i > startIndex && candidates[i] == candidates[i - 1]) continue;
            sum += candidates[i];
            path.add(candidates[i]);
            backTracking(candidates, target, sum, i + 1);
            sum -= candidates[i];
            path.remove(path.size() - 1);
        }
    }

    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        Arrays.sort(candidates);
        backTracking(candidates, target, 0, 0);
        return res;
    }
}

这里涉及到了树层去重的概念,根据代码中的剪枝条件来看:

if (i > startIndex && candidates[i] == candidates[i - 1]) continue;

这里的思想是,candidates数组中如果有相同元素,在前一个元素已经被选取的情况下,遍历到后一个元素时会直接跳过。直观的来讲,以 [1, 1, 2] 为例,在选取前一个1之后,这个1的之后纵向递归的树枝的选取情况已经包含了后一个1纵向递归的所有选取情况,自然可以跳过后一个1。

重要的是,要使用这个树层去重的剪枝条件,需要数组有序

上面两道题,都使用了startIndex作为每次遍历的起始值,防止每次都从0开始重复选取元素,而对于组合问题,startIndex也可以不使用:

  • 如果是一个集合来求组合的话,就需要startIndex(leetcode77、216)
  • 如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex(leetcode17)

另外,切割问题也和组合问题非常类似,可以使用相同的思路。

子集问题

在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果,例如leetcode78。

子集问题中,有些题目可以不写终止条件,因为上面已经说了,子集问题收集的是所有节点的结果。

排列问题

排列问题也是求叶子结点的结果,但是不需要startIndex,因为每一层都是从0开始搜索。

另外,排列问题通常需要used数组(和题目给出的数组等长,元素为0和1)来记录path列表中已经存放过哪些元素。也可以使用set集合来去重,但是set集合的性能通常不如使用数组,因为在运行过程中set集合要频繁的进行哈希映射。

棋盘问题

经典的棋盘问题主要有N皇后和解数独。

N皇后的思想是,我们将棋盘的搜索过程抽象为一棵树,每一层代表同一行的不同位置,而递归的过程即为下一行的不同位置

解数独的思想是二维递归,即一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

index