子集问题
思路
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点
!
78.子集
相比组合问题,此子集问题题目更为简单,收集的是树的所有节点,无递归条件约束
。
/**
* @param {number[]} nums
* @return {number[][]}
*/
var subsets = function(nums) {
//确定参数和返回值类型
let res=[],path=[];
let backtracking=(index)=>{
//子集问题需要收集树的所有节点
//又是这错的!!!path.slice()浅拷贝
res.push([...path]);
for(let i=index;i<nums.length;i++){
path.push(nums[i]);
backtracking(i+1);
path.pop();
}
}
backtracking(0);
return res;
};
90子集II
在上题基础上,此题由于数组中有相同元素,所以要进行去重操作,去重和组合问题中去重类似j>index&&nums[j]===nums[j-1]
,j>index表示是树层去重
,index为此层的开始索引!切记别写成j>0!
/**
* @param {number[]} nums
* @return {number[][]}
*/
var subsetsWithDup = function(nums) {
//去重先需要排序
nums.sort((a,b)=>a-b);
//确定参数和返回值
let res=[],path=[];
let backtracking=(index)=>{
res.push(path.slice());
if(index>=nums.length)return;
for(let j=index;j<nums.length;j++){
let item=nums[j];
//去重操作
//j>index表示“当层”后面的值是否与此值相同!!!!
if(j>index&&nums[j]===nums[j-1]){
continue;
}
path.push(item);
backtracking(j+1);
path.pop();
}
}
backtracking(0);
return res;
};
递增子序列
此题,由题意知子序列长度至少为2,递归结束条件收集结果时需要进行约束。题目的另外一个核心就是递增
!要保证后续加进的元素大于数组的最后一个元素path.length>0&&item<path[path.length-1]
!不满足条件直接跳过continue,而不是在push方法加if条件!
/**
* @param {number[]} nums
* @return {number[][]}
*/
var findSubsequences = function(nums) {
//排序
// nums.sort((a,b)=>a-b);
//定义参数和返回值
let res=[],path=[];
let backtracking=(index)=>{
//至少有两个元素
if(path.length>=2){
res.push(path.slice());
}
let uset=[];
for(let j=index;j<nums.length;j++){
let item=nums[j];
//树层去重|保证大于数组path最后一个元素
//(位置应该放在此,不满足直接跳过!!而不是在push方法前加一个,这样仍会加入[4,4])
if(j>index&&nums[j]===nums[j-1]||(path.length>0&&item<path[path.length-1])){
continue;
}
//加入元素
path.push(item);
backtracking(j+1);
path.pop();
}
}
backtracking(0);
return res;
};
子集问题详解
子集问题 是经典的回溯问题之一,它的目标是从一个给定的集合中生成所有的子集。具体来说,给定一个整数集合 nums
,你需要找出所有的子集(包括空集和 nums
本身)。
我们通过 回溯 来实现这个问题。回溯算法的基本思想是探索所有可能的组合路径,在每一步做出选择后继续深入探索,如果某个路径不符合要求或已经完成,就返回上一步,尝试其他路径。
题目描述
给定一个整数数组 nums
,返回该数组所有可能的子集(幂集)。返回的子集中,子集的元素可以按任意顺序排列。
示例:
输入: nums = [1, 2, 3]
输出: [
[1],
[2],
[3],
[1,2],
[1,3],
[2,3],
[1,2,3],
[]
]
回溯算法的思路
- 路径选择:每一层递归都可以选择当前元素在当前子集中出现或不出现。
- 状态回溯:如果当前选择了某个元素进入子集,进入下一层递归后会回退,尝试不选择当前元素,继续递归。
- 终止条件:递归的终止条件可以是遍历完所有元素。
解法步骤
- 初始化一个结果数组
res
,用于存储最终的子集。 - 定义一个递归函数,该函数接受当前的索引
start
和一个当前子集current
,每次递归调用时向结果数组添加当前子集。 - 从当前索引开始,尝试将每个元素加入子集并递归。递归完成后,从当前子集中移除最后一个元素,回溯到上一步,尝试不加入该元素。
回溯算法实现
function subsets(nums) {
let res = []; // 存储所有子集
let current = []; // 当前子集
let n = nums.length;
// 回溯函数
function backtrack(start) {
// 将当前子集添加到结果数组
res.push([...current]);
// 从当前起始点开始遍历每个元素
for (let i = start; i < n; i++) {
// 选择当前元素
current.push(nums[i]);
// 递归调用,i + 1 确保每个元素只被选一次
backtrack(i + 1);
// 回溯,撤销选择
current.pop();
}
}
// 从索引 0 开始回溯
backtrack(0);
return res;
}
// 示例
console.log(subsets([1, 2, 3]));
代码分析
res.push([...current]);
:每次将当前子集current
的一个副本加入结果集。需要使用[...]
或slice()
来避免引用共享问题。current.push(nums[i]);
:选择当前元素,将其加入当前子集。backtrack(i + 1);
:递归进入下一个元素,i + 1
确保每个元素只会被选择一次。current.pop();
:回溯时,撤销上一步的选择,尝试下一个选择。
时间复杂度
- 递归的深度是
n
,即数组的长度。 - 每一层递归有 2 种选择(选或不选),因此总的子集数是
2^n
。 - 因此,时间复杂度是
O(2^n)
,其中n
是输入数组的长度。
空间复杂度
- 递归栈的深度为
n
,因此空间复杂度是O(n)
。 - 结果存储所有的子集,子集的总数为
2^n
,每个子集最多有n
个元素。所以空间复杂度为O(2^n)
。
优化
- 对于此类问题,回溯算法已经是最优解法,因为要枚举所有子集,最小复杂度也必须是
O(2^n)
。 - 没有进一步的优化空间,除非能通过其他算法技巧(如动态规划)来处理更特殊的情况,但通常回溯是比较直观和易于实现的解决方法。
总结
子集问题的回溯解法通过递归的方式枚举所有可能的子集。每个决策点有 2 种选择:选或不选当前元素,通过递归深入到下一个元素,直到遍历完所有元素后返回。回溯算法清晰地展示了递归思想和状态回溯的特性,是理解深度优先搜索(DFS)的一种非常好的实践。
全排列问题
思路
排列问题和顺序有关,每次遍历都是相同的数组且从索引为0开始,为了要标识哪些已经遍历完需要借助一个额外数组
!
46全排列
此题需借助一个额外的used数组标识
哪些元素遍历完,且在回溯时又需标识其未访问 if(used[i])continue; used[i]=true; backtracking(nums,used);used[i]=false;
/**
* @param {number[]} nums
* @return {number[][]}
*/
var permute = function(nums) {
//确定参数和返回值
let res=[],path=[];
let backtracking=(arr,used)=>{
//收集的叶子结点(全排列)
if(path.length===arr.length){
res.push(path.slice());
return;
}
//排列问题考虑顺序无需index作为参数
for(let i=0;i<arr.length;i++){
//used数组标识是否已经访问过
if(used[i])continue;
path.push(arr[i]);
used[i]=true;
backtracking(arr,used);
path.pop();
//回溯时别忘记重新标识此原始至未访问
used[i]=false;
}
}
backtracking(nums,[]);
return res;
};
47 全排列II
此题在上面题目,多了一个树层去重的工作!知道是数层去重,怎样表示嘞?这样的话i>0&&nums[i]===nums[i-1]
未考虑相同元素在不同层,结果不是树层去重!还需加一个条件!used[i-1]
排除第一个元素和上一层元素相同情况,分析过程
:
//上一层访问过,此层直接跳过
if(used[i])continue;
//树层去重,对于全排列,相比其他多了一个条件!used[i-1],标识此层的首个元素不去重
//例如:1 1 2 树的第二层传的数组仍是 1 1 2但是第一个1标识遍历过(第一层就遍历)
//因此本层元素应该就是1 2 ,要保证1无需做去重操作
if(i>0&&!used[i-1]&&nums[i]===nums[i-1])continue
/**
* @param {number[]} nums
* @return {number[][]}
*/
var permuteUnique = function(nums) {
let res=[],path=[],len=nums.length;
let backtracking=(arr,used)=>{
if(path.length===len){
res.push(path.slice());
return;
}
for(let i=0;i<len;i++){
//上一层访问过,此层直接跳过
if(used[i])continue;
//树层去重,对于全排列,相比其他多了一个条件!used[i-1],标识此层的首个元素不去重
//例如:1 1 2 树的第二层传的数组仍是 1 1 2但是第一个1标识遍历过(第一层就遍历)
//因此本层元素应该就是1 2 ,要保证1无需做去重操作
if(i>0&&!used[i-1]&&nums[i]===nums[i-1])continue
path.push(nums[i]);
used[i]=true;
backtracking(arr,used);
path.pop();
used[i]=false;
}
}
backtracking(nums,[]);
return res;
};
全排列问题详解
全排列问题 是回溯算法中的经典问题之一,目标是生成给定集合中所有可能的排列。全排列问题通常会涉及到整数数组、字符数组等,要求我们生成所有不同的排列。
题目描述
给定一个没有重复数字的整数数组 nums
,返回所有这些数字的 排列。
示例:
输入: nums = [1, 2, 3]
输出: [
[1, 2, 3],
[1, 3, 2],
[2, 1, 3],
[2, 3, 1],
[3, 1, 2],
[3, 2, 1]
]
解法思路
全排列问题本质上是要通过递归来探索每个位置可能的选择。对每个位置的选择,递归地决定其他位置的元素,直到所有位置都填充完。
回溯算法的思路
- 路径选择:每次从当前未使用的元素中选择一个,加入到排列中。
- 状态回溯:每次递归时,选择了一个元素后,进入下一层递归。在递归结束后,要撤销选择,恢复状态。
- 终止条件:当当前排列的长度等于输入数组的长度时,说明排列完成,可以将其加入结果集。
解法步骤
- 初始化一个空的结果数组
res
,用来存储所有排列。 - 使用一个辅助数组
current
来存储当前的排列。 - 使用一个
used
数组(或标志)来记录哪些元素已经被选择过,避免重复选择。 - 递归地选择每个元素,生成所有的排列,并通过回溯撤销选择。
回溯算法实现
function permute(nums) {
let res = []; // 存储所有排列
let current = []; // 当前排列
let used = Array(nums.length).fill(false); // 记录每个元素是否被使用过
// 回溯函数
function backtrack() {
// 当排列的长度与数组长度相等时,表示当前排列已完成
if (current.length === nums.length) {
res.push([...current]); // 将当前排列加入结果集
return;
}
// 遍历所有元素
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue; // 如果元素已经被使用,跳过
// 选择当前元素
current.push(nums[i]);
used[i] = true;
// 递归进行下一步选择
backtrack();
// 回溯,撤销选择
current.pop();
used[i] = false;
}
}
// 从索引 0 开始回溯
backtrack();
return res;
}
// 示例
console.log(permute([1, 2, 3]));
代码解析
used[i]
:用来标记当前元素nums[i]
是否已经被使用过,避免重复排列。current.push(nums[i])
:将当前元素加入current
中,形成一个部分排列。used[i] = true
:标记当前元素已被使用。backtrack()
:递归进行下一步选择。current.pop()
:回溯时,撤销当前元素的选择。used[i] = false
:恢复状态,表示当前元素未被使用。
时间复杂度
- 排列的个数:对于长度为
n
的数组,排列的总数为n!
(阶乘)。 - 递归深度:递归的深度为
n
,每一层都需要对当前选择进行遍历。 - 因此,时间复杂度是 O(n!),其中
n
是数组的长度。
空间复杂度
- 结果数组
res
存储所有排列,空间复杂度为O(n * n!)
。 - 递归栈的深度为
n
,所以额外空间复杂度为O(n)
。
优化
对于没有重复元素的排列,回溯算法已经是最优的解决方法。每次递归只考虑当前未被使用的元素,并在每一层递归时做出选择,这样可以避免生成重复的排列。
总结
全排列问题是回溯算法的经典应用,要求从一组元素中生成所有可能的排列。回溯的基本思想是逐步构建排列,遇到选择时递归深入,遍历完所有可能的选择后通过回溯撤销选择,返回上一步继续尝试其他选择。由于全排列问题要求生成所有可能的排列,时间复杂度通常为 O(n!)
,其中 n
为数组的大小。
子集问题&全排列问题常见题目
下面我将列出 子集问题 和 排列问题 的各 5 个经典前端 JS 算法题目,并提供相应的代码解析。
子集问题(回溯)
1. 子集(Subsets)
题目描述:
给定一个整数数组 nums
,返回所有可能的子集(幂集)。可以包括空集和数组本身。
// 示例
// 输入: nums = [1, 2, 3]
// 输出: [
// [1],
// [2],
// [3],
// [1,2],
// [1,3],
// [2,3],
// [1,2,3],
// []
// ]
解法解析:
使用回溯算法枚举所有子集。
function subsets(nums) {
let res = [];
let current = [];
function backtrack(start) {
res.push([...current]); // 存储当前子集
for (let i = start; i < nums.length; i++) {
current.push(nums[i]);
backtrack(i + 1); // 从下一个位置继续选择
current.pop(); // 回溯
}
}
backtrack(0);
return res;
}
console.log(subsets([1, 2, 3]));
时间复杂度:O(2^n)
,其中 n
是数组长度。
空间复杂度:O(n)
,递归栈的最大深度。
2. 子集 II(Subsets II)
题目描述:
给定一个整数数组 nums
,其中可能包含重复元素,返回所有唯一的子集(幂集)。
// 示例
// 输入: nums = [1, 2, 2]
// 输出: [
// [1],
// [1, 2],
// [1, 2, 2],
// [2],
// [2, 2],
// []
// ]
解法解析:
为了避免重复子集,可以在每一层递归中对相同元素进行去重处理。通过 i > start
来跳过重复元素。
function subsetsWithDup(nums) {
let res = [];
nums.sort((a, b) => a - b); // 排序,确保相同元素相邻
let current = [];
function backtrack(start) {
res.push([...current]);
for (let i = start; i < nums.length; i++) {
if (i > start && nums[i] === nums[i - 1]) continue; // 跳过重复元素
current.push(nums[i]);
backtrack(i + 1);
current.pop();
}
}
backtrack(0);
return res;
}
console.log(subsetsWithDup([1, 2, 2]));
时间复杂度:O(2^n)
,与子集数量相关。
空间复杂度:O(n)
,递归栈的最大深度。
3. 组合(Combinations)
题目描述:
给定一个整数 n
和一个整数 k
,返回 1 到 n
中所有可能的 k
个数的组合。
// 示例
// 输入: n = 4, k = 2
// 输出: [
// [2, 4],
// [3, 4],
// [1, 2],
// [1, 3],
// [1, 4],
// [2, 3]
// ]
解法解析:
可以用回溯法生成从 1 到 n
中选择 k
个数的组合。
function combine(n, k) {
let res = [];
let current = [];
function backtrack(start) {
if (current.length === k) {
res.push([...current]);
return;
}
for (let i = start; i <= n; i++) {
current.push(i);
backtrack(i + 1); // 下一个选择
current.pop(); // 回溯
}
}
backtrack(1);
return res;
}
console.log(combine(4, 2));
时间复杂度:O(C(n, k))
,即从 n
中选择 k
个元素的组合数。
空间复杂度:O(k)
,递归栈的最大深度。
4. 组合总和(Combination Sum)
题目描述:
给定一个无重复的整数数组 candidates
和一个目标值 target
,找出所有组合,使得这些组合的和为 target
。
// 示例
// 输入: candidates = [2, 3, 6, 7], target = 7
// 输出: [
// [2, 2, 3],
// [7]
// ]
解法解析:
使用回溯算法,允许重复选择相同的数字,因此每个数字可以多次选。
function combinationSum(candidates, target) {
let res = [];
let current = [];
function backtrack(start, target) {
if (target === 0) {
res.push([...current]);
return;
}
if (target < 0) return;
for (let i = start; i < candidates.length; i++) {
current.push(candidates[i]);
backtrack(i, target - candidates[i]); // 允许重复选当前元素
current.pop(); // 回溯
}
}
backtrack(0, target);
return res;
}
console.log(combinationSum([2, 3, 6, 7], 7));
时间复杂度:O(2^n)
,最坏情况下,需要遍历所有子集。
空间复杂度:O(target)
,递归栈的最大深度。
5. 划分为若干子集(Partition to K Equal Sum Subsets)
题目描述:
给定一个整数数组 nums
和一个整数 k
,判断能否将 nums
划分为 k
个非空子集,且每个子集的和相等。
// 示例
// 输入: nums = [4, 3, 2, 3, 5, 4, 1], k = 4
// 输出: true
解法解析:
使用回溯算法,尝试将数字分配到 k
个子集,确保每个子集的和相等。
function canPartitionKSubsets(nums, k) {
const totalSum = nums.reduce((a, b) => a + b, 0);
if (totalSum % k !== 0) return false;
const target = totalSum / k;
let visited = new Array(nums.length).fill(false);
function backtrack(start, kLeft, currentSum) {
if (kLeft === 0) return true;
if (currentSum === target) return backtrack(0, kLeft - 1, 0);
for (let i = start; i < nums.length; i++) {
if (visited[i] || currentSum + nums[i] > target) continue;
visited[i] = true;
if (backtrack(i + 1, kLeft, currentSum + nums[i])) return true;
visited[i] = false;
}
return false;
}
return backtrack(0, k, 0);
}
console.log(canPartitionKSubsets([4, 3, 2, 3, 5, 4, 1], 4));
时间复杂度:O(2^n)
,最坏情况下需要遍历所有子集。
空间复杂度:O(n)
,递归栈的最大深度。
排列问题(回溯)
1. 全排列(Permutations)
题目描述:
给定一个没有重复数字的整数数组 nums
,返回所有这些数字的排列。
// 示例
// 输入: nums = [1, 2, 3]
// 输出: [
// [1, 2, 3],
// [1, 3, 2],
// [2, 1, 3],
// [2, 3, 1],
// [3, 1, 2],
// [3, 2, 1]
// ]
解法解析:
通过回溯算法生成所有排列。
function permute(nums) {
let res = [];
let current = [];
let used = Array(nums.length).fill(false);
function backtrack() {
if (current.length === nums.length) {
res.push([...current]);
return;
}
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue;
current.push(nums[i]);
used[i]
= true;
backtrack();
current.pop();
used[i] = false;
}
}
backtrack();
return res;
}
console.log(permute([1, 2, 3]));
时间复杂度:O(n!)
,生成所有排列。
空间复杂度:O(n)
,递归栈深度。
2. 全排列 II(Permutations II)
题目描述:
给定一个包含重复数字的整数数组 nums
,返回所有不重复的排列。
// 示例
// 输入: nums = [1, 1, 2]
// 输出: [
// [1, 1, 2],
// [1, 2, 1],
// [2, 1, 1]
// ]
解法解析:
为避免重复排列,排序数组,确保相同的元素在递归时相邻,然后跳过重复的元素。
function permuteUnique(nums) {
let res = [];
nums.sort((a, b) => a - b);
let current = [];
let used = Array(nums.length).fill(false);
function backtrack() {
if (current.length === nums.length) {
res.push([...current]);
return;
}
for (let i = 0; i < nums.length; i++) {
if (used[i] || (i > 0 && nums[i] === nums[i - 1] && !used[i - 1])) continue;
current.push(nums[i]);
used[i] = true;
backtrack();
current.pop();
used[i] = false;
}
}
backtrack();
return res;
}
console.log(permuteUnique([1, 1, 2]));
时间复杂度:O(n!)
,生成所有排列。
空间复杂度:O(n)
,递归栈深度。
3. 组合总和 III(Combination Sum III)
题目描述:
找出所有 k
个数字的排列,使得它们的和为 n
。
// 示例
// 输入: k = 3, n = 7
// 输出: [[1, 2, 4]]
解法解析:
通过回溯,选择不同的数来组成和为 n
的排列。
function combinationSum3(k, n) {
let res = [];
let current = [];
function backtrack(start, target) {
if (current.length === k && target === 0) {
res.push([...current]);
return;
}
for (let i = start; i <= 9; i++) {
current.push(i);
backtrack(i + 1, target - i); // 防止重复元素
current.pop();
}
}
backtrack(1, n);
return res;
}
console.log(combinationSum3(3, 7));
时间复杂度:O(C(n, k))
,从 n
中选择 k
个元素的组合数。
空间复杂度:O(k)
,递归栈的最大深度。
4. 排列组合(Permutations and Combinations)
题目描述:
给定一个整数数组 nums
和一个整数 k
,返回所有长度为 k
的排列。
// 示例
// 输入: nums = [1, 2, 3], k = 2
// 输出: [
// [1, 2],
// [1, 3],
// [2, 1],
// [2, 3],
// [3, 1],
// [3, 2]
// ]
解法解析:
使用回溯法生成排列。每次递归时选择当前未选择的元素。
function permute(nums) {
let res = [];
let current = [];
let used = Array(nums.length).fill(false);
function backtrack() {
if (current.length === nums.length) {
res.push([...current]);
return;
}
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue;
current.push(nums[i]);
used[i] = true;
backtrack();
current.pop();
used[i] = false;
}
}
backtrack();
return res;
}
console.log(permute([1, 2, 3]));
5. 排列总和(Permutation Sum)
题目描述:
给定一个数组和一个目标值,找出所有可以排列成目标值的数字组合。
// 示例
// 输入: nums = [2, 3, 5], target = 8
// 输出: [[3, 5], [5, 3]]
解法解析:
通过回溯选取元素进行排列,直到找到和为 target
的组合。
function permutationSum(nums, target) {
let res = [];
let current = [];
function backtrack(target) {
if (target === 0) {
res.push([...current]);
return;
}
for (let i = 0; i < nums.length; i++) {
if (target - nums[i] >= 0) {
current.push(nums[i]);
backtrack(target - nums[i]);
current.pop();
}
}
}
backtrack(target);
return res;
}
console.log(permutationSum([2, 3, 5], 8));
总结
这些题目都可以通过回溯算法来解决,关键是通过递归进行深度优先搜索,尽可能枚举出所有可能的情况。