废话不多说,喊一句号子鼓励自己:程序员永不失业,程序员走向架构!本篇Blog的主题是【回溯算法】,使用【数组】这个基本的数据结构来实现,这个高频题的站点是:CodeTop,筛选条件为:目标公司+最近一年+出现频率排序,由高到低的去牛客TOP101去找,只有两个地方都出现过才做这道题(CodeTop本身汇聚了LeetCode的来源),确保刷的题都是高频要面试考的题。
明确目标题后,附上题目链接,后期可以依据解题思路反复快速练习,题目按照题干的基本数据结构分类,且每个分类的第一篇必定是对基础数据结构的介绍。
元素无重不可复选。首先来看一个组合子集树
组合问题和子集问题其实是等价的,原解题思路,我们暂时不考虑如何用代码实现,先回忆一下我们的高中知识,如何手推所有子集?首先,生成元素个数为 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(2^n)
,其中 n
是输入数组 nums
的大小。空间复杂度:
空间复杂度主要取决于递归调用的深度和存储中间结果的数据结构。递归的深度最多为 n
,因此空间复杂度是 O(n)
。
此外,path
列表的空间复杂度也需要考虑。在最坏的情况下,path
列表的长度可能等于 n
,因为每个元素都可能被包括在一个子集中。因此,总的空间复杂度为 O(n)
。
总结:这段代码使用回溯算法生成了输入数组 nums
的所有子集,时间复杂度为 O(2^n)
,空间复杂度为 O(n)
。
元素无重不可复选。给你输入一个数组 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);
}
}
}
时间复杂度和空间复杂度同上
就以 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 中以给定规则取若干元素,主要有以下几种变体:
当然,也可以说有第四种形式,即元素可重可复选。但既然元素可复选,那又何必存在重复元素呢?元素去重之后就等同于形式三,所以这种情况不用考虑。上面用组合问题举的例子,但排列、组合、子集问题都可以有这三种基本形式,所以共有 9 种变化