【LeetCode 热题 100】39. 组合总和——(解法一)选或不选
Problem: 39. 组合总和
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个。
文章目录
- 整体思路
- 完整代码
- 时空复杂度
- 时间复杂度:O(S) 或 指数级
- 空间复杂度:O(T)
整体思路
这段代码旨在解决一个经典的组合搜索问题:组合总和 (Combination Sum)。问题要求从一个无重复元素的正整数数组 candidates
中,找出所有和为 target
的组合。一个关键的特点是,数组中的同一个数字可以被无限制地重复使用。
该算法采用了 深度优先搜索 (DFS) 结合 回溯 (Backtracking) 的策略来系统地探索所有可能的组合。其核心逻辑步骤如下:
-
算法核心:递归与回溯
- 算法的主体是一个名为
dfs
的递归函数。这个函数负责在candidates
数组中探索所有可能的数字组合。 - “路径”与“结果”:算法使用一个
path
列表来存储当前正在构建的组合(即从根节点到当前节点的路径),以及一个ans
列表来存储所有已找到的、符合条件的最终组合。
- 算法的主体是一个名为
-
DFS函数设计
dfs(i, ..., target, ...)
:这个函数的设计非常关键。i
:代表当前递归层级可以从candidates[i]
开始选择数字。这个参数是为了避免产生重复的组合(例如,有了[2, 2, 3]
之后,就不再需要生成[2, 3, 2]
)。通过只向后(或在原地)选择,我们保证了每个组合中的元素是按非递减顺序被选中的。target
:表示还需要凑齐的数值。每当选择一个数字,target
就会相应减少。
-
递归的终止条件(Base Cases)
- 成功找到组合:
if (target == 0)
。当target
减为 0,说明当前path
中的数字之和恰好等于原始的target
。这是一个有效的组合,因此需要将其拷贝一份(new ArrayList<>(path)
)存入最终结果ans
中。必须拷贝,因为path
本身会在后续的回溯过程中被修改。 - 路径无效:
if (i == n || target < 0)
。当i == n
,表示已经考虑完了所有候选数字,但仍未凑齐target
;当target < 0
,表示当前组合的和已经超出了target
。这两种情况都说明当前路径是无效的,需要终止这条路的探索,直接return
。
- 成功找到组合:
-
探索与回溯(核心选择逻辑)
- 在
dfs
函数中,对于当前候选数字candidates[i]
,我们面临两个选择:
a. 不选择candidates[i]
:直接跳过当前数字,去考虑下一个数字candidates[i+1]
。这通过递归调用dfs(i + 1, ...)
来实现。
b. 选择candidates[i]
:
i. 做选择:将candidates[i]
添加到当前路径path
中。
ii. 继续探索:进行下一步递归调用dfs(i, ..., target - candidates[i], ...)
。注意,这里的第一个参数仍然是i
,而不是i+1
。这正是允许重复使用同一个数字的关键所在。
iii. 撤销选择(回溯):当上述递归调用返回后(意味着所有从“选择candidates[i]
”开始的路径都已探索完毕),必须将刚刚添加的candidates[i]
从path
中移除 (path.removeLast()
)。这个“撤销”操作是回溯的精髓,它使得程序能够返回到上一个状态,去探索其他的可能性(例如,上一个递归层级的不选择分支)。
- 在
完整代码
class Solution {/*** 找出所有和为 target 的组合。candidates 中的数字可以被无限制重复使用。* @param candidates 无重复元素的正整数数组* @param target 目标和* @return 所有有效的组合列表*/public List<List<Integer>> combinationSum(int[] candidates, int target) {// ans: 存储所有符合条件的最终组合List<List<Integer>> ans = new ArrayList<>();// path: 存储当前正在构建的组合(路径)List<Integer> path = new ArrayList<>();// 从索引 0 开始进行深度优先搜索dfs(0, candidates, target, ans, path);return ans;}/*** 深度优先搜索(回溯)辅助函数* @param i 当前考虑的候选数字的索引* @param candidates 候选数字数组* @param target 剩余需要凑齐的目标和* @param ans 结果列表* @param path 当前路径*/private void dfs(int i, int[] candidates, int target, List<List<Integer>> ans, List<Integer> path) {int n = candidates.length;// 递归终止条件 1: 索引越界或目标和变为负数,说明此路不通if (i == n || target < 0) {return;}// 递归终止条件 2: 成功找到一个组合if (target == 0) {// 将当前路径的一个快照(副本)加入结果集// 必须创建新列表,因为 path 会在回溯时被修改ans.add(new ArrayList<>(path));return;}// --- 核心选择逻辑 ---// 选择 1: 不使用当前的 candidates[i],直接考虑下一个数字// 递归地探索不包含当前数字的后续组合dfs(i + 1, candidates, target, ans, path);// 选择 2: 使用当前的 candidates[i]// 2a. 做出选择:将当前数字加入路径path.add(candidates[i]);// 2b. 继续探索:递归调用 dfs。// - 第一个参数仍为 i,表示可以重复使用当前的 candidates[i]。// - target 减去当前数字的值。dfs(i, candidates, target - candidates[i], ans, path);// 2c. 撤销选择(回溯):将刚才加入的数字移除,以便探索其他分支path.removeLast();}
}
时空复杂度
时间复杂度:O(S) 或 指数级
- 时间复杂度分析对于回溯问题通常比较复杂,因为它取决于搜索树的大小。
- 设
N
为candidates
的数量,T
为target
。 - 搜索树的深度最多为
T / min_val
,其中min_val
是candidates
中的最小值。在最坏情况下(例如candidates
中有1),深度可以是T
。 - 在搜索树的每个节点,我们都可能进行分支。
- 因此,总的节点数(即
dfs
的调用次数)是指数级的。一个非常粗略的上界可以是O(N^T)
,但这并不精确。更准确的描述是,时间复杂度与解的数量和搜索空间的大小有关。 - 在每个找到解的叶子节点,我们需要 O(T) 的时间来复制
path
到结果列表中。 - 一个更被接受的表述是,时间复杂度为 O(S),其中
S
是搜索树中节点的总数。这个S
的值是输入N
和T
的指数函数。
空间复杂度:O(T)
- 递归栈深度:空间复杂度的主要部分是递归调用栈的深度。在最坏的情况下,例如
candidates
中有1
,我们需要递归T
次才能使target
减到0
。因此,递归栈的最大深度是 O(T)。 path
列表:path
列表存储了当前路径,其最大长度也与递归深度相关,同样是 O(T)。- 结果列表
ans
:存储最终结果的空间不计入算法的额外辅助空间复杂度。
综合分析:
算法所需的额外空间由递归栈和 path
列表决定。因此,空间复杂度为 O(T)。
参考灵神