子集问题

思路
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
 
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));
总结
这些题目都可以通过回溯算法来解决,关键是通过递归进行深度优先搜索,尽可能枚举出所有可能的情况。


![[网络安全]sqli-labs Less-3 解题详析](https://img-blog.csdnimg.cn/4b036559dabe4e0080b2cc07300aa89c.png#pic_center)
















