回溯算法秒杀所有排列/组合/子集问题

无论是排列、组合还是子集问题,简单说无非就是让你从序列 nums 中以给定规则取若干元素,主要有以下几种变体:

  1. 元素无重不可复选,即 nums 中的元素都是唯一的,每个元素最多只能被使用一次,这也是最基本的形式。
    以组合为例,如果输入 nums = [2,3,6,7],和为 7 的组合应该只有 [7]
  2. 元素可重不可复选,即 nums 中的元素可以存在重复,每个元素最多只能被使用一次。
    以组合为例,如果输入 nums = [2,5,2,1,2],和为 7 的组合应该有两种 [2,2,2,1][5,2]
  3. 元素无重可复选,即 nums 中的元素都是唯一的,每个元素可以被使用若干次。
    以组合为例,如果输入 nums = [2,3,6,7],和为 7 的组合应该有两种 [2,2,3][7]

但无论形式怎么变化,其本质就是穷举所有解,而这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可把这些问题一网打尽。

记住如下子集问题和排列问题的回溯树,就可以解决所有排列组合子集相关的问题:
在这里插入图片描述在这里插入图片描述

子集(元素无重不可复选)

力扣第 78 题「 子集」就是这个问题:
在这里插入图片描述
我们通过保证元素之间的相对顺序不变来防止出现重复的子集。整个推导过程就是这样一棵树:
在这里插入图片描述
注意这棵树的特性:

如果把根节点作为第 0 层,将每个节点和根节点之间树枝上的元素作为该节点的值,那么第 n 层的所有节点就是大小为 n 的所有子集。

你比如大小为 2 的子集就是这一层节点的值
在这里插入图片描述
那么再进一步,如果想计算所有子集,那只要遍历这棵多叉树,把所有节点的值收集起来不就行了?

直接看代码:

class Solution {
    List<List<Integer>> res=new ArrayList<>();
    public List<List<Integer>> subsets(int[] nums) {
        dfs(new ArrayList<>(),nums,0,nums.length);
        return res;
    }

    public void dfs(List<Integer> list,int[] nums,int start,int n){
        res.add(new ArrayList<>(list));
        for(int i=start;i<n;i++){
            list.add(nums[i]);
            dfs(list,nums,i+1,n);
            list.remove(list.size()-1);
        }
    }
}

我们使用 start 参数控制树枝的生长避免产生重复的子集,用 list记录根节点到每个节点的路径的值,同时在前序位置把每个节点的路径值收集起来,完成回溯树的遍历就收集了所有子集:

组合(元素无重不可复选)

比如力扣第 77 题「 组合」:
在这里插入图片描述

List<List<Integer>> res = new LinkedList<>();
// 记录回溯算法的递归路径
LinkedList<Integer> track = new LinkedList<>();

// 主函数
public List<List<Integer>> combine(int n, int k) {
    backtrack(1, n, k);
    return res;
}

void backtrack(int start, int n, int k) {
    // base case
    if (k == track.size()) {
        // 遍历到了第 k 层,收集当前节点的值
        res.add(new LinkedList<>(track));
        return;
    }
    
    // 回溯算法标准框架
    for (int i = start; i <= n; i++) {
        // 选择
        track.addLast(i);
        // 通过 start 参数控制树枝的遍历,避免产生重复的子集
        backtrack(i + 1, n, k);
        // 撤销选择
        track.removeLast();
    }
}

排列(元素无重不可复选)

力扣第 46 题「 全排列」就是标准的排列问题:
在这里插入图片描述

刚才讲的组合/子集问题使用 start 变量保证元素 nums[start] 之后只会出现 nums[start+1..] 中的元素,通过固定元素的相对位置保证不出现重复的子集。

但排列问题本身就是让你穷举元素的位置,nums[i] 之后也可以出现 nums[i] 左边的元素,所以之前的那一套玩不转了,需要额外使用 used 数组来标记哪些元素还可以被选择。

标准全排列可以抽象成如下这棵二叉树:
在这里插入图片描述我们用 used 数组标记已经在路径上的元素避免重复选择,然后收集所有叶子节点上的值,就是所有全排列的结果:

List<List<Integer>> res = new LinkedList<>();
// 记录回溯算法的递归路径
LinkedList<Integer> track = new LinkedList<>();
// track 中的元素会被标记为 true
boolean[] used;

/* 主函数,输入一组不重复的数字,返回它们的全排列 */
public List<List<Integer>> permute(int[] nums) {
    used = new boolean[nums.length];
    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 中的元素,不能重复选择
        if (used[i]) {
            continue;
        }
        // 做选择
        used[i] = true;
        track.addLast(nums[i]);
        // 进入下一层回溯树
        backtrack(nums);
        // 取消选择
        track.removeLast();
        used[i] = false;
    }
}

子集/组合(元素可重不可复选)

子集

力扣第 90 题「 子集 II」就是这样一个问题:
在这里插入图片描述
就以 nums = [1,2,2] 为例,为了区别两个 2 是不同元素,后面我们写作 nums = [1,2,2']
在这里插入图片描述
所以我们需要进行剪枝,如果一个节点有多条值相同的树枝相邻,则只遍历第一条,剩下的都剪掉,不要去遍历:
按照之前的思路画出子集的树形结构,显然,两条值相同的相邻树枝会产生重复:

在这里插入图片描述

class Solution {
    List<List<Integer>> res=new ArrayList<>();
    List<Integer> list=new ArrayList<>();
    public List<List<Integer>> subsetsWithDup(int[] nums) {
        // 先排序,让相同的元素靠在一起
        Arrays.sort(nums);
        dfs(nums,0);
        return res;
    }
    public void dfs(int[] nums,int start){
         // 前序位置,每个节点的值都是一个子集
        res.add(new ArrayList<>(list));
        for(int i=start;i<nums.length;i++){
            // 剪枝逻辑,值相同的相邻树枝,只遍历第一条
            if(i>start&&nums[i]==nums[i-1]) continue;
            list.add(nums[i]);
            dfs(nums,i+1);
            list.remove(list.size()-1);
        }
    }
}

组合

我们说了组合问题和子集问题是等价的,所以我们直接看一道组合的题目吧,这是力扣第 40 题「 组合总和 II」:

在这里插入图片描述

import java.util.*;
class Solution {
    private List<List<Integer>> res;
    public List<List<Integer>> combinationSum2(int[] candidates, int target) {
        res=new ArrayList<>();
        // 记录回溯的路径
        List<Integer> list=new ArrayList<>();
        // 先排序,让相同的元素靠在一起
        Arrays.sort(candidates);
        dfs(candidates,list,target,0);
        return res;
    }

    public void dfs(int[] candidates,List<Integer> list,int target,int idx){
    		// base case,达到目标和,找到符合条件的组合
        if(target==0){
            res.add(new ArrayList<>(list));
            return;
        }else{
            for(int i=idx;i<candidates.length;i++){
            // 剪枝逻辑,值相同的树枝,只遍历第一条
                if(i > idx && candidates[i] == candidates[i-1]) continue;
                if(target>=candidates[i]){
                		// 做选择
                    list.add(candidates[i]);
                    // 递归遍历下一层回溯树
                    dfs(candidates,list,target-candidates[i],i+1);
                     // 撤销选择
                    list.remove(list.size()-1);
                }else{// base case,超过目标和,直接结束
                    break;
                }
            }
        }
    }
}

排列(元素可重不可复选)

排列问题的输入如果存在重复,比子集/组合问题稍微复杂一点,我们看看力扣第 47 题「 全排列 II」:
在这里插入图片描述

class Solution {
    List<List<Integer>> res = new LinkedList<>();
    LinkedList<Integer> track = new LinkedList<>();
    boolean[] used;
    public List<List<Integer>> permuteUnique(int[] nums) {
        used=new boolean[nums.length];
        // 先排序,让相同的元素靠在一起
        Arrays.sort(nums);
        dfs(nums);
        return res;
    }
    public void dfs(int[] nums){
        if(track.size()==nums.length){
            res.add(new ArrayList<>(track));
            return;
        }
        for(int i=0;i<nums.length;i++){
            if(used[i]) continue;
            // 新添加的剪枝逻辑,固定相同的元素在排列中的相对位置
            if(i!=0&&nums[i]==nums[i-1]&&!used[i-1]) continue;
            track.add(nums[i]);
            used[i]=true;
            dfs(nums);
            used[i]=false;
            track.remove(track.size()-1);
        }
    }
}

你对比一下之前的标准全排列解法代码,这段解法代码只有两处不同:

  1. nums 进行了排序。
  2. 添加了一句额外的剪枝逻辑。

类比输入包含重复元素的子集/组合问题,你大概应该理解这么做是为了防止出现重复结果。

但是注意排列问题的剪枝逻辑,和子集/组合问题的剪枝逻辑略有不同:新增了 !used[i - 1] 的逻辑判断。

这个地方理解起来就需要一些技巧性了,且听我慢慢到来。为了方便研究,依然把相同的元素用上标 ' 以示区别。

假设输入为 nums = [1,2,2'],标准的全排列算法会得出如下答案:

[
    [1,2,2'],[1,2',2],
    [2,1,2'],[2,2',1],
    [2',1,2],[2',2,1]
]

显然,这个结果存在重复,比如 [1,2,2'][1,2',2] 应该只被算作同一个排列,但被算作了两个不同的排列。

所以现在的关键在于,如何设计剪枝逻辑,把这种重复去除掉?

答案就是,保证相同元素在排列中的相对位置保持不变

比如说 nums = [1,2,2'] 这个例子,我保持排列中 2 一直在 2' 前面。

标准全排列算法之所以出现重复,是因为把相同元素形成的排列序列视为不同的序列,但实际上它们应该是相同的;而如果固定相同元素形成的序列顺序,当然就避免了重复。

那么反映到代码上,你注意看这个剪枝逻辑:

// 新添加的剪枝逻辑,固定相同的元素在排列中的相对位置
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
    // 如果前面的相邻相等元素没有用过,则跳过
    continue;
}

当出现重复元素时,比如输入 nums = [1,2,2’,2’‘],2’ 只有在 2 已经被使用的情况下才会被选择,同理,2’’ 只有在 2’ 已经被使用的情况下才会被选择,这就保证了相同元素在排列中的相对位置保证固定。

子集/组合(元素无重可复选)

输入数组无重复元素,但每个元素可以被无限次使用。,力扣第 39 题「 组合总和」:
在这里插入图片描述
这道题说是组合问题,实际上也是子集问题:candidates 的哪些子集的和为 target

想解决这种类型的问题,也得回到回溯树上,我们不妨先思考思考,标准的子集/组合问题是如何保证不重复使用元素的?

答案在于 backtrack 递归时输入的参数 start

// 无重组合的回溯算法框架
void backtrack(int[] nums, int start) {
    for (int i = start; i < nums.length; i++) {
        // ...
        // 递归遍历下一层回溯树,注意参数
        backtrack(nums, i + 1);
        // ...
    }
}

这个 istart 开始,那么下一层回溯树就是从 start + 1 开始,从而保证 nums[start] 这个元素不会被重复使用:
在这里插入图片描述
那么反过来,如果我想让每个元素被重复使用,我只要把 i + 1 改成 i 即可:

// 可重组合的回溯算法框架
void backtrack(int[] nums, int start) {
    for (int i = start; i < nums.length; i++) {
        // ...
        // 递归遍历下一层回溯树,注意参数
        backtrack(nums, i);
        // ...
    }
}

这相当于给之前的回溯树添加了一条树枝,在遍历这棵树的过程中,一个元素可以被无限次使用:
在这里插入图片描述
当然,这样这棵回溯树会永远生长下去,所以我们的递归函数需要设置合适的 base case 以结束算法,即路径和大于 target 时就没必要再遍历下去了。

class Solution {
    List<List<Integer>> res = new LinkedList<>();
    // 记录回溯的路径
    LinkedList<Integer> track = new LinkedList<>();
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        Arrays.sort(candidates);
        backtrack(candidates,target,0);
        return res;
    }
    public void backtrack(int[] candidates,int target,int start){
        // base case,找到目标和,记录结果
        if(target==0){
            res.add(new ArrayList<>(track));
            return;
        }
        for(int i=start;i<candidates.length;i++){
            // base case,超过目标和,停止向下遍历
            if(candidates[i]>target) break;
            //选择当前元素
            track.add(candidates[i]);
            target-=candidates[i];
            // 递归遍历下一层回溯树
            // 同一元素可重复使用,注意参数
            backtrack(candidates,target,i);
            // 撤销选择 nums[i]
            target+=candidates[i];
            track.remove(track.size()-1);
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值