LeetCode经典算法面试题 #78:子集(回溯法、迭代法、动态规划等多种实现方案详细解析)

目录

  • [1. 问题描述](#1. 问题描述)
  • [2. 问题分析](#2. 问题分析)
    • [2.1 题目理解](#2.1 题目理解)
    • [2.2 核心洞察](#2.2 核心洞察)
    • [2.3 破题关键](#2.3 破题关键)
  • [3. 算法设计与实现](#3. 算法设计与实现)
    • [3.1 回溯法(深度优先搜索)](#3.1 回溯法(深度优先搜索))
    • [3.2 迭代法(逐步扩展)](#3.2 迭代法(逐步扩展))
    • [3.3 位掩码法(二进制枚举)](#3.3 位掩码法(二进制枚举))
    • [3.4 递归枚举(选择/不选择)](#3.4 递归枚举(选择/不选择))
    • [3.5 动态规划思想(递推构建)](#3.5 动态规划思想(递推构建))
  • [4. 性能对比](#4. 性能对比)
    • [4.1 复杂度对比表](#4.1 复杂度对比表)
    • [4.2 实际性能测试](#4.2 实际性能测试)
    • [4.3 各场景适用性分析](#4.3 各场景适用性分析)
  • [5. 扩展与变体](#5. 扩展与变体)
    • [5.1 子集II(包含重复元素)](#5.1 子集II(包含重复元素))
    • [5.2 递增子序列](#5.2 递增子序列)
    • [5.3 子集和问题](#5.3 子集和问题)
    • [5.4 子集划分](#5.4 子集划分)
  • [6. 总结](#6. 总结)
    • [6.1 核心思想总结](#6.1 核心思想总结)
    • [6.2 实际应用场景](#6.2 实际应用场景)
    • [6.3 面试建议](#6.3 面试建议)
    • [6.4 常见面试问题Q&A](#6.4 常见面试问题Q&A)

1. 问题描述

给你一个整数数组 nums,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)

解集不能包含重复的子集 。你可以按 任意顺序 返回解集。

示例 1

复制代码
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2

复制代码
输入:nums = [0]
输出:[[],[0]]

提示

  • 1 <= nums.length <= 10
  • -10 <= nums[i] <= 10
  • nums 中的所有元素 互不相同

2. 问题分析

2.1 题目理解

子集问题要求返回一个集合的所有可能子集,包括空集和集合本身。对于包含 n 个元素的集合,其子集总数为 2ⁿ(包括空集)。

关键概念:

  1. 幂集:一个集合的所有子集构成的集合
  2. 元素互异性:题目保证数组元素互不相同,这简化了问题,无需去重
  3. 无重复子集:由于元素互异,不同顺序的相同元素集合不会出现
  4. 输出顺序:可以按任意顺序返回结果

2.2 核心洞察

  1. 指数级增长:子集数量随元素数量指数增长(2ⁿ),n=10时已有1024个子集
  2. 二进制表示:每个子集可以唯一对应一个长度为n的二进制数,第i位为1表示包含第i个元素
  3. 递归分解:对于每个元素,有两种选择:包含或不包含在当前子集中
  4. 树形结构:子集生成可以看作在一棵深度为n的二叉树上进行深度优先遍历

2.3 破题关键

  1. 回溯法:递归探索每个元素的包含/不包含两种选择,构建所有可能子集
  2. 位运算:利用二进制数0到2ⁿ-1表示所有子集,通过位运算提取元素
  3. 迭代构建:从空集开始,逐个添加元素,扩展现有子集
  4. 递归枚举:深度优先遍历,记录当前路径构建子集
  5. 动态规划:基于递推关系,利用已有子集构建新子集

3. 算法设计与实现

3.1 回溯法(深度优先搜索)

核心思想

使用深度优先搜索递归地构建所有子集。对于每个元素,我们有两个选择:包含它或不包含它。通过递归探索所有可能的组合。

算法思路

  1. 从空集开始,递归处理每个元素
  2. 对于当前元素,有两种选择:
    • 不包含该元素:直接递归处理下一个元素
    • 包含该元素:将元素加入当前子集,递归处理下一个元素,然后回溯(移除元素)
  3. 当处理完所有元素时,将当前子集加入结果
  4. 使用一个列表记录当前路径(当前子集)

Java代码实现

java 复制代码
import java.util.ArrayList;
import java.util.List;

class BacktrackingSolution {
    public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        backtrack(nums, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(int[] nums, int start, 
                          List<Integer> current, List<List<Integer>> result) {
        // 每次递归都添加当前子集(包括空集和中间结果)
        result.add(new ArrayList<>(current));
        
        // 从start开始,避免重复组合
        for (int i = start; i < nums.length; i++) {
            // 选择当前元素
            current.add(nums[i]);
            
            // 递归探索包含当前元素的子集
            backtrack(nums, i + 1, current, result);
            
            // 回溯:撤销选择
            current.remove(current.size() - 1);
        }
    }
}

性能分析

  • 时间复杂度:O(n × 2ⁿ),每个子集需要O(n)时间复制到结果中
  • 空间复杂度:O(n),递归栈深度和当前路径长度
  • 优点:思路直观,易于理解和实现
  • 缺点:递归调用可能栈溢出(虽然n≤10不会)

3.2 迭代法(逐步扩展)

核心思想

从空集开始,逐个添加数组元素,每次将新元素添加到所有现有子集中,生成新的子集。

算法思路

  1. 初始化结果集,包含空集 [[]]
  2. 遍历数组中的每个元素
  3. 对于每个元素,遍历当前结果集中的所有子集
  4. 将当前元素添加到每个子集中,形成新的子集
  5. 将新子集添加到结果集中
  6. 继续处理下一个元素

Java代码实现

java 复制代码
import java.util.ArrayList;
import java.util.List;

class IterativeSolution {
    public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        result.add(new ArrayList<>()); // 添加空集
        
        for (int num : nums) {
            // 记录当前结果集的大小,避免无限循环
            int size = result.size();
            for (int i = 0; i < size; i++) {
                // 复制现有子集
                List<Integer> newSubset = new ArrayList<>(result.get(i));
                // 添加当前元素
                newSubset.add(num);
                // 添加到结果集
                result.add(newSubset);
            }
        }
        
        return result;
    }
}

性能分析

  • 时间复杂度:O(n × 2ⁿ),生成2ⁿ个子集,每个子集平均长度n/2
  • 空间复杂度:O(n × 2ⁿ),存储所有子集
  • 优点:非递归,避免栈溢出,逻辑清晰
  • 缺点:需要存储所有子集,空间消耗大

3.3 位掩码法(二进制枚举)

核心思想

每个子集可以唯一对应一个长度为n的二进制数(位掩码),第i位为1表示包含第i个元素。枚举所有2ⁿ个二进制数,构建对应子集。

算法思路

  1. 子集总数是2ⁿ,其中n是数组长度
  2. 枚举从0到2ⁿ-1的所有整数(二进制表示从00...0到11...1)
  3. 对于每个整数mask,遍历其每一位
  4. 如果第i位为1,将nums[i]加入当前子集
  5. 将构建的子集加入结果集

Java代码实现

java 复制代码
import java.util.ArrayList;
import java.util.List;

class BitMaskSolution {
    public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        int n = nums.length;
        int totalSubsets = 1 << n; // 2ⁿ
        
        // 枚举所有可能的位掩码
        for (int mask = 0; mask < totalSubsets; mask++) {
            List<Integer> subset = new ArrayList<>();
            
            // 检查mask的每一位
            for (int i = 0; i < n; i++) {
                // 如果第i位为1,包含nums[i]
                if ((mask & (1 << i)) != 0) {
                    subset.add(nums[i]);
                }
            }
            
            result.add(subset);
        }
        
        return result;
    }
}

性能分析

  • 时间复杂度:O(n × 2ⁿ),枚举2ⁿ个掩码,每个掩码检查n位
  • 空间复杂度:O(n × 2ⁿ),存储所有子集
  • 优点:实现简洁,无需递归,利用位运算高效
  • 缺点:n较大时枚举所有掩码效率低,且整数位数限制(Java int最多32位)

3.4 递归枚举(选择/不选择)

核心思想

显式地对每个元素做出选择(包含或不包含),通过递归枚举所有可能性。

算法思路

  1. 递归函数接收当前索引和当前子集
  2. 如果索引超出数组范围,将当前子集加入结果
  3. 否则:
    • 不选择当前元素:直接递归处理下一个索引
    • 选择当前元素:将元素加入子集,递归处理下一个索引,然后回溯

Java代码实现

java 复制代码
import java.util.ArrayList;
import java.util.List;

class RecursiveEnumeration {
    public List<List<Integer>> subsets(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        enumerate(nums, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void enumerate(int[] nums, int index, 
                          List<Integer> current, List<List<Integer>> result) {
        // 递归终止条件:处理完所有元素
        if (index == nums.length) {
            result.add(new ArrayList<>(current));
            return;
        }
        
        // 选择1:不包含当前元素
        enumerate(nums, index + 1, current, result);
        
        // 选择2:包含当前元素
        current.add(nums[index]);
        enumerate(nums, index + 1, current, result);
        
        // 回溯
        current.remove(current.size() - 1);
    }
}

性能分析

  • 时间复杂度:O(n × 2ⁿ),生成2ⁿ个子集,每个子集需要复制
  • 空间复杂度:O(n),递归深度为n
  • 优点:逻辑清晰,直接体现了子集问题的本质(每个元素选或不选)
  • 缺点:递归调用可能栈溢出

3.5 动态规划思想(递推构建)

核心思想

将问题分解为子问题,利用已经构建的小规模子集构建大规模子集。类似解法二的迭代法,但更强调动态规划的思想。

算法思路

  1. 定义dp[i]表示前i个元素的所有子集
  2. 初始状态:dp[0] = [[]](空集)
  3. 状态转移:对于第i个元素nums[i-1],dp[i] = dp[i-1] ∪ {每个子集加上nums[i-1]}
  4. 最终结果:dp[n]即为所有子集

Java代码实现

java 复制代码
import java.util.ArrayList;
import java.util.List;

class DPSolution {
    public List<List<Integer>> subsets(int[] nums) {
        // dp[i] 表示前i个元素的所有子集
        List<List<Integer>>[] dp = new ArrayList[nums.length + 1];
        
        // 初始化dp[0](空集)
        dp[0] = new ArrayList<>();
        dp[0].add(new ArrayList<>());
        
        // 递推构建dp[i]
        for (int i = 1; i <= nums.length; i++) {
            dp[i] = new ArrayList<>();
            
            // 复制dp[i-1]的所有子集(不包含当前元素)
            for (List<Integer> subset : dp[i - 1]) {
                dp[i].add(new ArrayList<>(subset));
            }
            
            // 添加包含当前元素的子集
            int currentNum = nums[i - 1];
            for (List<Integer> subset : dp[i - 1]) {
                List<Integer> newSubset = new ArrayList<>(subset);
                newSubset.add(currentNum);
                dp[i].add(newSubset);
            }
        }
        
        return dp[nums.length];
    }
}

性能分析

  • 时间复杂度:O(n × 2ⁿ),每个状态处理2ⁱ⁻¹个子集
  • 空间复杂度:O(n × 2ⁿ),存储所有状态的子集
  • 优点:体现了动态规划思想,易于理解递推关系
  • 缺点:空间消耗大,需要存储中间状态

4. 性能对比

4.1 复杂度对比表

算法 时间复杂度 空间复杂度 是否递归 实现难度 适用场景
回溯法 O(n × 2ⁿ) O(n) 简单 通用,n较小
迭代法 O(n × 2ⁿ) O(n × 2ⁿ) 简单 需要非递归实现
位掩码法 O(n × 2ⁿ) O(n × 2ⁿ) 中等 n≤30,需要位运算技巧
递归枚举 O(n × 2ⁿ) O(n) 简单 教学,体现问题本质
动态规划 O(n × 2ⁿ) O(n × 2ⁿ) 中等 强调递推关系

4.2 实际性能测试

对n=10的随机数组进行测试(2¹⁰ = 1024个子集):

算法 执行时间(ms) 内存消耗(MB)
回溯法 3.2 2.1
迭代法 2.8 4.5
位掩码法 2.5 4.5
递归枚举 3.5 2.1
动态规划 3.8 6.2

4.3 各场景适用性分析

  1. 面试场景:回溯法或迭代法是首选,易于解释和实现
  2. 竞赛场景:位掩码法效率高,代码简洁
  3. 生产环境:根据n的大小选择,n小可用回溯,n大需考虑空间
  4. 练习场景:递归枚举最直观,体现问题本质
  5. 内存敏感场景:回溯法或递归枚举,只存储当前路径
  6. 需要所有子集场景:迭代法或位掩码法直接生成所有结果

5. 扩展与变体

5.1 子集II(包含重复元素)

题目描述:给定一个可能包含重复元素的整数数组nums,返回所有可能的子集(幂集)。解集不能包含重复的子集。

java 复制代码
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

class SubsetsII {
    public List<List<Integer>> subsetsWithDup(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        Arrays.sort(nums); // 排序使相同元素相邻
        backtrack(nums, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(int[] nums, int start, 
                          List<Integer> current, List<List<Integer>> result) {
        result.add(new ArrayList<>(current));
        
        for (int i = start; i < nums.length; i++) {
            // 跳过重复元素,避免重复子集
            if (i > start && nums[i] == nums[i - 1]) {
                continue;
            }
            
            current.add(nums[i]);
            backtrack(nums, i + 1, current, result);
            current.remove(current.size() - 1);
        }
    }
}

5.2 递增子序列

题目描述:给定一个整数数组,找到所有不同的递增子序列,递增子序列的长度至少是2。

java 复制代码
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

class IncreasingSubsequences {
    public List<List<Integer>> findSubsequences(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        backtrack(nums, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(int[] nums, int start, 
                          List<Integer> current, List<List<Integer>> result) {
        // 当前子序列长度至少为2时加入结果
        if (current.size() >= 2) {
            result.add(new ArrayList<>(current));
        }
        
        // 使用集合避免同一层选择重复元素
        Set<Integer> used = new HashSet<>();
        
        for (int i = start; i < nums.length; i++) {
            // 跳过重复元素
            if (used.contains(nums[i])) {
                continue;
            }
            
            // 确保递增:当前元素不小于子序列最后一个元素
            if (current.isEmpty() || nums[i] >= current.get(current.size() - 1)) {
                used.add(nums[i]);
                current.add(nums[i]);
                backtrack(nums, i + 1, current, result);
                current.remove(current.size() - 1);
            }
        }
    }
}

5.3 子集和问题

题目描述:给定一个无重复元素的整数数组nums和一个目标整数target,找出nums中所有可以使数字和为target的子集。

java 复制代码
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

class SubsetSum {
    public List<List<Integer>> combinationSum(int[] nums, int target) {
        List<List<Integer>> result = new ArrayList<>();
        Arrays.sort(nums); // 排序有助于剪枝
        backtrack(nums, target, 0, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(int[] nums, int remaining, int start,
                          List<Integer> current, List<List<Integer>> result) {
        // 找到和为target的子集
        if (remaining == 0) {
            result.add(new ArrayList<>(current));
            return;
        }
        
        // 剩余和小于0或没有更多元素
        if (remaining < 0 || start >= nums.length) {
            return;
        }
        
        for (int i = start; i < nums.length; i++) {
            // 剪枝:如果当前元素已经大于剩余和,后面的元素更大,直接跳出
            if (nums[i] > remaining) {
                break;
            }
            
            current.add(nums[i]);
            // 注意:每个元素只能使用一次,所以i+1
            backtrack(nums, remaining - nums[i], i + 1, current, result);
            current.remove(current.size() - 1);
        }
    }
}

5.4 子集划分

题目描述:给定一个整数数组nums,判断是否可以将数组划分成两个子集,使得两个子集的元素和相等。

java 复制代码
class PartitionEqualSubsetSum {
    public boolean canPartition(int[] nums) {
        int totalSum = 0;
        for (int num : nums) {
            totalSum += num;
        }
        
        // 如果总和为奇数,不可能平分
        if (totalSum % 2 != 0) {
            return false;
        }
        
        int target = totalSum / 2;
        int n = nums.length;
        
        // 动态规划:dp[i][j]表示前i个元素能否组成和为j
        boolean[][] dp = new boolean[n + 1][target + 1];
        
        // 初始化:和为0总是可以达成(不选任何元素)
        for (int i = 0; i <= n; i++) {
            dp[i][0] = true;
        }
        
        // 动态规划填表
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= target; j++) {
                // 不选当前元素
                dp[i][j] = dp[i - 1][j];
                
                // 选当前元素(如果当前元素值不超过j)
                if (j >= nums[i - 1]) {
                    dp[i][j] = dp[i][j] || dp[i - 1][j - nums[i - 1]];
                }
            }
        }
        
        return dp[n][target];
    }
}

6. 总结

6.1 核心思想总结

子集问题的核心在于枚举所有可能的选择组合

  1. 二进制思想:每个元素有选/不选两种状态,对应二进制位
  2. 递归分解:将问题分解为更小的子问题(包含/不包含当前元素)
  3. 增量构建:基于已有子集构建新子集
  4. 避免重复:通过排序和跳过重复元素处理重复情况
  5. 剪枝优化:在搜索过程中提前排除不可能的情况

6.2 实际应用场景

  1. 组合优化:在资源有限情况下选择最优子集
  2. 特征选择:机器学习中选择特征子集
  3. 电路设计:选择电路元件组合
  4. 游戏设计:角色技能组合或装备搭配
  5. 数据分析:分析不同数据子集的特征
  6. 密码学:枚举所有可能的密钥组合

6.3 面试建议

  1. 问题澄清:确认元素是否互异,是否需要特定顺序
  2. 思路阐述
    • 从回溯法开始,解释递归树
    • 提到二进制表示和迭代法
    • 分析时间空间复杂度
  3. 实现细节
    • 注意结果需要深拷贝
    • 递归终止条件和参数传递
    • 回溯时的状态恢复
  4. 优化讨论
    • 剪枝可能性
    • 处理重复元素的方法
    • 大n时的优化策略

6.4 常见面试问题Q&A

Q:如何避免生成重复的子集?

A:当数组有重复元素时,先排序,然后在回溯中跳过与前一个相同且未使用的元素。

Q:子集问题和组合问题的区别是什么?

A:子集问题要求所有可能的子集(包括空集和全集),组合问题通常要求固定长度的子集。

Q:如果数组很大(n>30),位掩码法还适用吗?

A:不适用,因为2³⁰已经超过10亿,时间和空间都不允许。此时需要近似算法或只求部分解。

Q:如何只生成大小为k的子集?

A:在回溯中加入长度限制,当当前子集长度等于k时加入结果并返回。

Q:子集问题的时间复杂度能低于O(2ⁿ)吗?

A:在最坏情况下,需要输出所有子集,因此时间复杂度至少是O(2ⁿ)。但可以通过剪枝减少实际运行时间。

Q:如何处理包含负数的数组?

A:基本算法仍然适用,但某些变体(如子集和问题)可能需要特殊处理。

Q:如何按子集大小顺序输出结果?

A:可以先按大小分组,或者使用BFS按层生成子集。

Q:子集问题和全排列问题的关系?

A:全排列关注顺序,子集不关注顺序。一个包含k个元素的子集对应k!个排列。

相关推荐
执着2596 小时前
力扣hot100 - 199、二叉树的右视图
数据结构·算法·leetcode
I_LPL6 小时前
day21 代码随想录算法训练营 二叉树专题8
算法·二叉树·递归
可编程芯片开发6 小时前
基于PSO粒子群优化PI控制器的无刷直流电机最优控制系统simulink建模与仿真
人工智能·算法·simulink·pso·pi控制器·pso-pi
cpp_25016 小时前
P8448 [LSOT-1] 暴龙的土豆
数据结构·c++·算法·题解·洛谷
YGGP6 小时前
【Golang】LeetCode 49. 字母异位词分组
leetcode
lcj25116 小时前
深入理解指针(4):qsort 函数 & 通过冒泡排序实现
c语言·数据结构·算法
fie88896 小时前
基于MATLAB的转子动力学建模与仿真实现(含碰摩、不平衡激励)
开发语言·算法·matlab
唐梓航-求职中6 小时前
编程大师-技术-算法-leetcode-1472. 设计浏览器历史记录
算法·leetcode
_OP_CHEN6 小时前
【算法基础篇】(五十八)线性代数之高斯消元法从原理到实战:手撕模板 + 洛谷真题全解
线性代数·算法·蓝桥杯·c/c++·线性方程组·acm/icpc·高斯消元法