【算法训练-回溯算法 二】【子集组合问题】子集、组合、子集II、组合总和

简介: 【算法训练-回溯算法 二】【子集组合问题】子集、组合、子集II、组合总和

废话不多说,喊一句号子鼓励自己:程序员永不失业,程序员走向架构!本篇Blog的主题是【回溯算法】,使用【数组】这个基本的数据结构来实现,这个高频题的站点是:CodeTop,筛选条件为:目标公司+最近一年+出现频率排序,由高到低的去牛客TOP101去找,只有两个地方都出现过才做这道题(CodeTop本身汇聚了LeetCode的来源),确保刷的题都是高频要面试考的题。

明确目标题后,附上题目链接,后期可以依据解题思路反复快速练习,题目按照题干的基本数据结构分类,且每个分类的第一篇必定是对基础数据结构的介绍

子集【MID】

元素无重不可复选。首先来看一个组合子集树

题干

基本的,不能包含重复的,不可复选的子集

解题思路

组合问题和子集问题其实是等价的,原解题思路,我们暂时不考虑如何用代码实现,先回忆一下我们的高中知识,如何手推所有子集?首先,生成元素个数为 0 的子集,即空集 [],为了方便表示,我称之为 S_0。然后,在 S_0 的基础上生成元素个数为 1 的所有子集,我称为 S_1

接下来,我们可以在 S_1 的基础上推导出 S_2,即元素个数为 2 的所有子集

为什么集合 [2] 只需要添加 3,而不添加前面的 1 呢?因为集合中的元素不用考虑顺序,[1,2,3] 中 2 后面只有 3,如果你添加了前面的 1,那么 [2,1] 会和之前已经生成的子集 [1,2] 重复,换句话说,我们通过保证元素之间的相对顺序不变来防止出现重复的子集,接着,我们可以通过 S_2 推出 S_3,实际上 S_3 中只有一个集合 [1,2,3],它是通过 [1,2] 推出的。整个推导过程就是这样一棵树

注意这棵树的特性:如果把根节点作为第 0 层,将每个节点和根节点之间树枝上的元素作为该节点的值,那么第 n 层的所有节点就是大小为 n 的所有子集。你比如大小为 2 的子集就是这一层节点的值

代码实现

给出代码实现基本档案

基本数据结构数组

辅助数据结构

算法回溯算法

技巧

import java.util.*;
public class Solution {
    // 最终结果集
    private  List<List<Integer>> result = new LinkedList<>();
    // 定义路径存储集
    List<Integer> path = new LinkedList<>();
    /**
     * 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
     *
     *
     * @param n int整型 the n
     * @return int整型
     */
    public List<List<Integer>> subsets(int[] nums) {
        backtrack(nums, 0);
        return result;
    }
    private void backtrack(int[] nums, int start) {
        // 1 结果添加到结果集
        result.add(new LinkedList<>(path));
        // 2 遍历寻找结果集
        for (int i = start; i < nums.length; i++) {
            // 2-1 执行选择
            path.add(nums[i]);
            // 2-2 继续向下探索,这里的start为i+1,标识下层路径从下一个元素选取
            backtrack( nums, i + 1);
            // 2-3 撤销选择
            path.remove(path.size() - 1);
        }
    }
}

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

复杂度分析

这段代码是用于生成一个整数数组的所有子集的回溯算法。算法通过递归的方式生成子集,每次在递归中有两种选择:包含当前元素或不包含当前元素。以下是对代码的分析:

  • subsets 方法是公共的入口点,它接受一个整数数组 nums 作为输入,并返回一个包含所有子集的列表。
  • subsets 方法中,创建了一个空的 path 列表用于存储当前子集,然后调用 backtrack 方法来开始回溯过程。
  • backtrack方法是递归函数,用于生成子集。它采用以下步骤:
  1. 将当前 path 添加到最终结果集 result 中,将当前 path 中的元素加入结果集表示一个子集。
  2. 遍历nums数组,从start开始,对于每个元素,执行以下操作:
  • 将当前元素添加到 path 中,表示选择当前元素。
  • 递归调用 backtrack 方法,但这次的 starti + 1 开始,这样可以确保在同一个子集中不重复使用元素。
  • 撤销选择,将刚刚添加的元素从 path 中移除,继续遍历下一个元素。
  • 回溯算法会递归生成所有可能的子集,直到遍历完 nums 数组中的所有元素。
  • 最终,subsets 方法返回包含所有子集的 result 列表。

时间复杂度

  • 时间复杂度主要取决于递归调用的次数。对于每个元素,有两种选择,包括或不包括,而每种状态都需要 O (n) 的构造时间,因此时间复杂度是 O(2^n),其中 n 是输入数组 nums 的大小。

空间复杂度

  • 空间复杂度主要取决于递归调用的深度和存储中间结果的数据结构。递归的深度最多为 n,因此空间复杂度是 O(n)
  • 此外,path 列表的空间复杂度也需要考虑。在最坏的情况下,path 列表的长度可能等于 n,因为每个元素都可能被包括在一个子集中。因此,总的空间复杂度为 O(n)

总结:这段代码使用回溯算法生成了输入数组 nums 的所有子集,时间复杂度为 O(2^n),空间复杂度为 O(n)

组合【MID】

元素无重不可复选。给你输入一个数组 nums = [1,2…,n] 和一个正整数 k,请你生成所有大小为 k 的子集。

题干

解题思路

还是以 nums = [1,2,3] 为例,刚才让你求所有子集,就是把所有节点的值都收集起来;现在你只需要把第 2 层(根节点视为第 0 层)的节点收集起来,就是大小为 2 的所有组合

代码实现

给出代码实现基本档案

基本数据结构数组

辅助数据结构

算法回溯算法

技巧

import java.util.*;
public class Solution {
    // 最终结果集
    private  List<List<Integer>> result = new LinkedList<>();
    // 定义路径存储集
    List<Integer> path = new LinkedList<>();
    /**
     * 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
     *
     *
     * @param n int整型 the n
     * @return int整型
     */
    public List<List<Integer>> combine(int n, int k) {
        // 2 初始值进行回溯
        backtrack(n, 1, k);
        return result;
    }
    private void backtrack( int n, int start, int k) {
        // 1 结果添加到结果集
        if (path.size() == k) {
            result.add(new LinkedList<>(path));
            return;
        }
        // 2 遍历寻找结果集
        for (int i = start; i <= n; i++) {
            // 2-1 执行选择
            path.add(i);
            // 2-2 继续向下探索,这里的start为i+1,标识下层路径从下一个元素选取
            backtrack(n, i + 1, k);
            // 2-3 撤销选择
            path.remove(path.size() - 1);
        }
    }
}

复杂度分析

时间复杂度和空间复杂度同上

子集II

元素有重不可复选。 再简单补充下重复元素的情况

解题思路

就以 nums = [1,2,2] 为例,为了区别两个 2 是不同元素,后面我们写作 nums = [1,2,2’]。按照之前的思路画出子集的树形结构,显然,两条值相同的相邻树枝会产生重复

其结果为:

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

你可以看到,[2] 和 [1,2] 这两个结果出现了重复,所以我们需要进行剪枝,如果一个节点有多条值相同的树枝相邻,则只遍历第一条,剩下的都剪掉,不要去遍历

体现在代码上,需要先进行排序,让相同的元素靠在一起,如果发现 nums[i] == nums[i-1],则跳过,和全排列II的思路一致

代码实现

给出代码实现基本档案

基本数据结构数组

辅助数据结构

算法回溯算法

技巧

import java.util.*;
public class Solution {
    // 最终结果集
    private  List<List<Integer>> result = new LinkedList<>();
    // 定义路径存储集
    List<Integer> path = new LinkedList<>();
    /**
     * 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
     *
     *
     * @param n int整型 the n
     * @return int整型
     */
    public List<List<Integer>> subsetsWithDup(int[] nums) {
        // 1 数组排序,相同值元素相邻
        Arrays.sort(nums);
        //2 初始值进行回溯
        backtrack( nums, 0);
        return result;
    }
    private void backtrack( int[] nums, int start) {
        // 1 结果添加到结果集
        result.add(new LinkedList<>(path));
        // 2 遍历寻找结果集
        for (int i = start; i < nums.length; i++) {
            // 2-1 剪枝,前面出现过的元素不再遍历
            if (i > start && nums[i] == nums[i - 1]) {
                continue;
            }
            // 2-2 执行选择
            path.add(nums[i]);
            // 2-3 继续向下探索,这里的start为i+1,标识下层路径从下一个元素选取
            backtrack(nums, i + 1);
            // 2-4 撤销选择
            path.remove(path.size() - 1);
        }
    }
}

复杂度分析

时间和空间复杂度同上

组合总和

元素无重可复选。这道题目也比较高频

题干

解题思路

这道题说是组合问题,实际上也是子集问题:candidates 的哪些子集的和为 target,想解决这种类型的问题,也得回到回溯树上,我们不妨先思考思考,标准的子集/组合问题是如何保证不重复使用元素的?答案在于 backtrack 递归时输入的参数 start

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

这个 i 从 start 开始,那么下一层回溯树就是从 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 时就没必要再遍历下去了

代码实现

给出代码实现基本档案

基本数据结构数组

辅助数据结构

算法回溯算法

技巧

import java.util.*;
public class Solution {
    // 最终结果集
    private  List<List<Integer>> result = new LinkedList<>();
    // 定义路径存储集
    List<Integer> path = new LinkedList<>();
    // 定义随路径存储变化的总和
    int pathSum = 0;
    /**
     * 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
     *
     *
     * @param n int整型 the n
     * @return int整型
     */
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        if (candidates.length == 0) {
            return new LinkedList<>();
        }
        backtrack( candidates, 0, target);
        return result;
    }
    private void backtrack( int[] nums, int start, int target) {
        // 1 等于目标和结果添加到结果集
        if (pathSum == target) {
            result.add(new LinkedList<>(path));
            return;
        }
        // 2 超过目标和返回
        if (pathSum > target) {
            return;
        }
        // 3 遍历寻找结果集
        for (int i = start; i < nums.length; i++) {
            // 3-1 执行选择
            path.add(nums[i]);
            pathSum += nums[i];
            // 3-2 继续向下探索,这里的start为i表示元素可以重复被使用
            backtrack(nums, i, target);
            // 3-3 撤销选择
            path.remove(path.size() - 1);
            pathSum -= nums[i];
        }
    }
}

复杂度分析

时间和空间复杂度同上

拓展知识:组合子集问题

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

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

当然,也可以说有第四种形式,即元素可重可复选。但既然元素可复选,那又何必存在重复元素呢?元素去重之后就等同于形式三,所以这种情况不用考虑。上面用组合问题举的例子,但排列、组合、子集问题都可以有这三种基本形式,所以共有 9 种变化

相关文章
|
4月前
|
算法
【算法系列篇】递归、搜索和回溯(四)
【算法系列篇】递归、搜索和回溯(四)
|
18天前
|
算法
算法系列--递归,回溯,剪枝的综合应用(3)(下)
算法系列--递归,回溯,剪枝的综合应用(3)(下)
18 0
|
18天前
|
存储 算法
算法系列--递归,回溯,剪枝的综合应用(3)(上)
算法系列--递归,回溯,剪枝的综合应用(3)(上)
23 0
算法系列--递归,回溯,剪枝的综合应用(3)(上)
|
2月前
|
算法
回溯算法练习题
回溯算法练习题
13 0
|
2月前
|
算法 Java 定位技术
【数据结构与算法】递归、回溯、八皇后 一文打尽!
【数据结构与算法】递归、回溯、八皇后 一文打尽!
|
2月前
|
算法 决策智能
深度探讨回溯算法:追寻解空间的奇妙之旅
深度探讨回溯算法:追寻解空间的奇妙之旅
|
4月前
|
算法
【算法系列篇】递归、搜索和回溯(三)
【算法系列篇】递归、搜索和回溯(三)
|
4月前
|
算法
【算法系列篇】递归、搜索和回溯(二)
【算法系列篇】递归、搜索和回溯(二)
|
4月前
|
算法
【算法系列篇】递归、搜索与回溯(一)
【算法系列篇】递归、搜索与回溯(一)
|
4月前
|
算法 前端开发 JavaScript
【面试高频题】难度 2/5,回溯算法经典运用
【面试高频题】难度 2/5,回溯算法经典运用