回溯算法

一、回溯法理论基础

1.1 回溯法解决的问题

回溯法,一般可以解决如下几种问题:

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

1.2 回溯法的理解

回溯法解决的问题都可以抽象为树形结构集合的大小就构成了树的宽度,递归的深度,都构成的树的深度

递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。

1.3 回溯法模板

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

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

for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。

回溯算法理论基础

二、组合问题

2.1 组合

77.组合

https://leetcode-cn.com/problems/combinations/

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

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

输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]

把组合问题抽象为如下树形结构:

77.组合1

每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围

图中可以发现n相当于树的宽度,k相当于树的深度

2.2 剪枝优化

遍历的范围是可以剪枝优化的,怎么优化呢?

来举一个例子,n = 4,k = 4的话,那么第一层for循环的时候,从元素2开始的遍历都没有意义了。 在第二层for循环,从元素3开始的遍历都没有意义了。

这么说有点抽象,如图所示:

77.组合4

图中每一个节点(图中为矩形),就代表本层的一个for循环,那么每一层的for循环从第二个数开始遍历的话,都没有意义,都是无效遍历。

所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置

如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了

注意代码中i,就是for循环里选择的起始位置。

for (int i = startIndex; i <= n; i++) {

接下来看一下优化过程如下:

  1. 已经选择的元素个数:path.size();
  2. 还需要的元素个数为: k - path.size();
  3. 在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历

为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。

举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。

从2开始搜索都是合理的,可以是组合[2, 3, 4]。

这里大家想不懂的话,建议也举一个例子,就知道是不是要+1了。

所以优化之后的for循环是:

for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置

优化后整体代码

public class 组合_77 {
    //返回结果集 二维数组
    List<List<Integer>> result = new ArrayList<>();
    //频繁的增删 使用linkedlist更好
    LinkedList<Integer> path = new LinkedList<>();
    public List<List<Integer>> combine(int n, int k) {
        combineHelper(1, k, n);
        return result;
    }
    /**
     * 回溯模板
     * 每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围,就是要靠startIndex
     * @param startIndex 用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,...,n] )。
     */
    private void combineHelper(int startIndex,int k, int n){
        //终止条件
        if (path.size() == k){
            //存放结果
            result.add(new ArrayList<>(path));
            return;
        }
        //本层集合中元素
        //剪枝优化
        for (int i = startIndex; i <= n - (k - path.size()) + 1; i++){
            //处理节点 考虑选择当前位置
            path.add(i);
            //递归
            combineHelper(i + 1, k, n);
            //回溯 撤销处理结果 考虑不选择当前位置
            path.removeLast();
        }
    }
}

2.3 组合求和

数组无重复元素,不能重复选,解集不重复

https://leetcode-cn.com/problems/combination-sum-iii/

找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。

说明:

  • 所有数字都是正整数。
  • 解集不能包含重复的组合。

示例 1: 输入: k = 3, n = 7 输出: [[1,2,4]]

示例 2: 输入: k = 3, n = 9 输出: [[1,2,6], [1,3,5], [2,3,4]

相对于77. 组合 (opens new window),无非就是多了一个限制,本题是要找到和为n的k个数的组合,而整个集合已经是固定的了[1,...,9]。同时也要考虑剪枝,已选元素总和如果已经大于n(图中数值为4)了,那么往后遍历就没有意义了,直接剪掉

216.组合总和III1

整体代码:

public class 组合总和三_216 {
    List<List<Integer>> result = new ArrayList<>();
    LinkedList<Integer> path = new LinkedList<>();
    int sum = 0;
    public List<List<Integer>> combinationSum3(int k, int n) {
        backTracking(1, k, n);
        return result;

    }
    private void backTracking(int cur, int k, int targetSum) {
        // 减枝
        if (sum > targetSum) {
            return;
        }

        if (path.size() == k) {
            if (sum == targetSum) {
                result.add(new ArrayList<>(path));
            }
            return;
        }

        // 减枝 9 - (k - path.size()) + 1
        for (int i = cur; i <= 9 - (k - path.size()) + 1; i++) {
            path.add(i);
            sum += i;
            backTracking(i + 1, k, targetSum);
            //回溯
            path.removeLast();
            //回溯
            sum -= i;
        }
    }
}

数组无重复元素,可以重复选,解集不重复

https://leetcode-cn.com/problems/combination-sum/

给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的数字可以无限制重复被选取。

说明:

  • 所有数字(包括 target)都是正整数。
  • 解集不能包含重复的组合。

示例 1: 输入:candidates = [2,3,6,7], target = 7, 所求解集为: [ [7], [2,2,3] ]

示例 2: 输入:candidates = [2,3,5], target = 8, 所求解集为: [ [2,2,2,2], [2,3,3], [3,5] ]

本题和77.组合 (opens new window)216.组合总和III (opens new window)和区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。

本题搜索的过程抽象成树形结构如下:

39.组合总和

注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!

对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。

其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。那么可以在for循环的搜索范围上做做剪枝处理。

对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历

39.组合总和1

最后整体代码:

public class 组合总和_39 {
    List<List<Integer>> result = new ArrayList<>();
    LinkedList<Integer> path = new LinkedList<>();
    int sum = 0;
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        Arrays.sort(candidates); // 先进行排序
        backtracking(candidates, target, 0);
        return result;
    }
    //cur 用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,...,n] )。
    public void backtracking(int[] candidates, int target, int cur) {
        // 找到了数字和为 target 的组合
        if (sum == target) {
            result.add(new ArrayList<>(path));
            return;
        }
        //这里没有个数的限制
        for (int i = cur; i < candidates.length; i++) {
            // 如果 sum + candidates[i] > target 就终止遍历
            //剪枝 后面的必然也大
            if (sum + candidates[i] > target) {
                break;
            }
            path.add(candidates[i]);
            sum += candidates[i];
            //关键点 不用i+1了,表示可以重复读取当前的数
            backtracking(candidates, target, i);
            // 回溯,移除路径 path 最后一个元素
            sum -= candidates[i];
            path.removeLast();
        }
    }

}

数组有重复元素,解集不重复

https://leetcode-cn.com/problems/combination-sum-ii/

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

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

说明: 所有数字(包括目标数)都是正整数。 解集不能包含重复的组合。

示例 1: 输入: candidates = [10,1,2,7,6,1,5], target = 8, 所求解集为: [ [1, 7], [1, 2, 5], [2, 6], [1, 1, 6] ]

示例 2: 输入: candidates = [2,5,2,1,2], target = 5, 所求解集为: [ [1,2,2], [5] ]

这道题目和39.组合总和 如下区别:

  1. 本题candidates 中的每个数字在每个组合中只能使用一次。
  2. 本题数组candidates的元素是有重复的,而39.组合总和是无重复元素的数组candidates

最后本题和39.组合总和要求一样,解集不能包含重复的组合。

本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合。

所谓去重,其实就是使用过的元素不能重复选取。

组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。

回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。

所以要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重

为了理解去重举一个例子,candidates = [1, 1, 2], target = 3,(方便起见candidates已经排序了)

强调一下,树层去重的话,需要对数组排序!

此题还需要加一个bool型数组used,用来记录同一树枝上的元素是否使用过。这个集合去重的重任就是used来完成的。

如果candidates[i] == candidates[i - 1] 并且 used[i - 1] == false,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]

此时for循环里就应该做continue的操作,这块比较抽象,如图:

40.组合总和II1

将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:

  • used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
  • used[i - 1] == false,说明同一树层candidates[i - 1]使用过

整体代码:

public class 组合总和二_40 {
    List<List<Integer>> result = new ArrayList<>();
    LinkedList<Integer> path = new LinkedList<>();
    int sum = 0;

    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        Arrays.sort(candidates); // 先进行排序
        //加标志数组,用来辅助判断同层节点是否已经遍历
        boolean[] used = new boolean[candidates.length];
        backTracking(candidates, target, 0,used);
        return result;
    }

    public void backTracking(int[] candidates, int target, int index, boolean[] used) {
        if (sum == target) {
            result.add(new ArrayList<>(path));
            return;
        }
        for (int i = index; i < candidates.length ;i++) {
            //剪枝
            if (sum + candidates[i] > target) {
                break;
            }
            // 同 90题一样  同一树枝上可以出现重复,但同一树层不能出现重复
            // 跳过同一树层使用过的元素
            if (i > 0 && candidates[i] == candidates[i - 1]&& !used[i - 1]) {
                continue;
            }
            used[i] = true;
            sum += candidates[i];
            path.add(candidates[i]);
            //每个节点仅能选择一次,所以从下一位开始
            backTracking(candidates, target, i + 1, used);
            //回溯 恢复
            used[i] = false;
            sum -= candidates[i];
            path.removeLast();
        }
    }
}

三、子集问题

3.1子集

数组无重复元素,解集不重复

78.子集

https://leetcode-cn.com/problems/subsets/

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。

说明:解集不能包含重复的子集。

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

求子集问题和[77.组合]和[131.分割回文串]不一样了。

如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!

其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。

那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!

有同学问了,什么时候for可以从0开始呢?

求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,排列问题我们后续的文章就会讲到的。

以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:

78.子集

从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合

求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树

整体代码:

class Solution {
    List<List<Integer>> result = new ArrayList<>();// 存放符合条件结果的集合
    LinkedList<Integer> path = new LinkedList<>();// 用来存放符合条件结果

    public List<List<Integer>> subsets(int[] nums) {

        if (nums.length == 0){
            result.add(new ArrayList<>());
            return result;
        }
        subsetsHelper(nums, 0);
        return result;
    }

    private void subsetsHelper(int[] nums, int startIndex) {
        result.add(new ArrayList<>(path));//遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
        if (startIndex >= nums.length) { //终止条件可不加
            return;
        }
        for (int i = startIndex; i < nums.length; i++) {
            path.add(nums[i]);
            //递归
            subsetsHelper(nums, i + 1);
            //回溯 撤销处理结果 考虑不选择当前位置
            path.removeLast();
        }
    }
}

数组有重复元素,解集不重复

  1. 子集Ⅱ

https://leetcode-cn.com/problems/subsets-ii/

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

说明:解集不能包含重复的子集。

示例:

  • 输入: [1,2,2]
  • 输出: [ [2], [1], [1,2,2], [2,2], [1,2], [] ]

这道题目和[78.子集]区别就是集合里有重复元素了,而且求取的子集要去重。

那么关于回溯算法中的去重问题,在[40.组合总和II]中已经详细讲解过了,和本题是一个套路。

先对数组进行排序

用示例中的[1, 2, 2] 来举例,如图所示:

90.子集II

要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重

使用used数组的整体代码:

class Solution {
    // 同一树枝上可以出现重复,但同一树层不能出现重复
    List<List<Integer>> result = new ArrayList<>();// 存放符合条件结果的集合
    LinkedList<Integer> path = new LinkedList<>();// 用来存放符合条件结果
    boolean[] used;
    public List<List<Integer>> subsetsWithDup( int[] nums ) {
        // 先排好序
        Arrays.sort( nums );
        used = new boolean[nums.length];
        subsetsWithDupHelper( nums, 0 );
        return result;
    }


    private void subsetsWithDupHelper( int[] nums, int startIndex ) {
        result.add( new ArrayList<>(path));
        if (startIndex >= nums.length){
            return;
        }

        for ( int i = startIndex; i < nums.length; i++ ) {
            // 跳过当前树层使用过的、相同的元素
            if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]){
                continue;
            }

            path.add(nums[i]);
            used[i] = true;
            subsetsWithDupHelper( nums, i + 1 );
            path.removeLast();
            used[i] = false;
        }
    }
}

3.2 递增子序列

  1. 递归子序列

https://leetcode-cn.com/problems/increasing-subsequences/

给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是2。

示例:

  • 输入: [4, 6, 7, 7]
  • 输出: [[4, 6], [4, 7], [4, 6, 7], [4, 6, 7, 7], [6, 7], [6, 7, 7], [7,7], [4,7,7]]

说明:

  • 给定数组的长度不会超过15。
  • 数组中的整数范围是 [-100,100]。
  • 给定数组中可能包含重复数字,相等的数字应该被视为递增的一种情况。

这个递增子序列比较像是取有序的子集。而且本题也要求不能有相同的递增子序列。

但本题求自增子序列,是不能对原数组经行排序的,排完序的数组都是自增子序列了。

所以不能使用之前的去重逻辑!

本题给出的示例,还是一个有序数组 [4, 6, 7, 7],这更容易误导大家按照排序的思路去做了。

为了有鲜明的对比,我用[4, 7, 6, 7]这个数组来举例,抽象为树形结构如图:

491. 递增子序列1

整体代码:

public class 递增子序列_491 {
    LinkedList<Integer> path = new LinkedList<>();
    List<List<Integer>> res = new ArrayList<>();

    public List<List<Integer>> findSubsequences(int[] nums) {
        backtracking(nums,0);
        return res;
    }

    private void backtracking (int[] nums, int startIndex) {
        //题目要求递增子序列大小至少为2
        if (path.size() > 1) {
            res.add(new ArrayList<>(path));
            // 注意这里不要加return,因为要取树上的所有节点
        }

        int[] used = new int[201];// nums范围是[-100,100] 将其映射到[0,201]
        for (int i = startIndex; i < nums.length; i++) {
            if (!path.isEmpty() && nums[i] < path.get(path.size() - 1) || (used[nums[i] + 100] == 1)){
                continue;
            }
            used[nums[i] + 100] = 1; // 记录这个元素在本层用过了,本层后面不能再用了
            path.add(nums[i]);
            backtracking(nums, i + 1);
            path.removeLast();
        }
    }
}

四、排列问题

posted @ 2022-03-20 19:02  王陸  阅读(198)  评论(0编辑  收藏  举报