废话不多说,喊一句号子鼓励自己:程序员永不失业,程序员走向架构!本篇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
方法是递归函数,用于生成子集。它采用以下步骤:
- 将当前
path
添加到最终结果集result
中,将当前path
中的元素加入结果集表示一个子集。 - 遍历
nums
数组,从start
开始,对于每个元素,执行以下操作:
- 将当前元素添加到
path
中,表示选择当前元素。 - 递归调用
backtrack
方法,但这次的start
从i + 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 种变化