题目
<中等> 组合总和
来源:LeetCode.
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,
找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。
你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。
如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个。
示例 1:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。
示例 2:
输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]
示例3:
输入: candidates = [2], target = 1
输出: []
提示:
- 1 < = n < = 30 1 <= n <= 30 1<=n<=30
接下来看一下解题思路:
在看这道题之前先回顾下 回溯算法
回溯算法思想:
回朔法主要在于: 通过枚举法,对所有可能性进行遍历。 枚举的顺序是 一条路走到底,走到底之后,退一步,再向前尝试没走过的路。直到所有路都试过。因此回朔法可以简单的理解为: 走不通就退一步的枚举法就叫回朔法。而这里回退点也叫做回朔点。
回朔关键点 通过分析发现,回朔法实现的三大技术关键点分别是:
- 一条路走到底
- 回退一步
- 另寻他路
关键点的实现 那么如何才能用代码实现上述三个关键点呢?
- for 循环
- 递归
解释如下
for循环的作用在于另寻他路: 可以用 f o r for for 循环可以实现一个路径选择器的功能,该路径选择器可以逐个选择当前节点下的所有可能往下走下去的分支路径。 例如: 像树的遍历一样,走到某个节点,该节点下可能有 i i i 个节点,需要遍历这所有可能的 i i i 个节点。
递归可以实现一条路走到底和回退一步: 一条路走到底: 递归意味着继续向着 f o r for for 给出的路径向下走一步。 如果把递归放在 f o r for for 循环内部,那么 f o r for for 每一次的循环,都在给出一个路径之后,进入递归,也就继续向下走了。直到递归出口(走无可走)为止。 那么这就是一条路走到底的实现方法。 递归从递归出口出来之后,就会实现回退一步。
因此 f o r for for 循环和递归配合可以实现回朔: 当递归从递归出口出来之后。上一层的 f o r for for 循环就会继续执行了。而 f o r for for 循环的继续执行就会给出当前节点下的下一条可行路径。而后递归调用,就顺着这条从未走过的路径又向下走一步。这就是回朔
如上的描述可以用代码总结下:
void dfs() {
// 这条路走到底的条件。也是递归出口
if (回朔点){
// 保存该结果
return;
}
for () {
//逐步选择当前节点下的所有可能route
if (剪枝条件) {
// 剪枝前的操作
// 不继续往下走了,退回上层,换个路再走
return;
}
// 当前路径可能是条可行路径
// 保存当前数据
add();
// 递归发生,继续向下走一步了。
dfs();
// 回朔清理
remove();
// 该节点下的所有路径都走完了,清理堆栈,准备下一个递归。例如弹出当前节点
}
}
思路一:搜索回溯:
对于这类寻找所有可行解的题,都可以尝试用「搜索回溯」
的方法来解决。
定义递归函数
dfs
(
target
,
combine
,
idx
)
\textit{dfs}(\textit{target}, \textit{combine}, \textit{idx})
dfs(target,combine,idx) 表示当前在
candidates
\textit{candidates}
candidates 数组的第
idx
\textit{idx}
idx 位,还剩
target
\textit{target}
target 要组合,已经组合的列表为
combine
\textit{combine}
combine。递归的终止条件为
target
≤
0
\textit{target} \le 0
target≤0 或者
candidates
\textit{candidates}
candidates 数组被全部用完。那么在当前的函数中,每次可以选择跳过不用第
idx
\textit{idx}
idx 个数,即执行
dfs
(
target
,
combine
,
idx
+
1
)
\textit{dfs}(\textit{target}, \textit{combine}, \textit{idx} + 1)
dfs(target,combine,idx+1)。也可以选择使用第
idx
\textit{idx}
idx 个数,即执行
dfs
(
target
−
candidates
[
idx
]
,
combine
,
idx
)
\textit{dfs}(\textit{target} - \textit{candidates}[\textit{idx}], \textit{combine}, \textit{idx})
dfs(target−candidates[idx],combine,idx),注意到每个数字可以被无限制重复选取,因此搜索的下标仍为
idx
\textit{idx}
idx。
class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> result = new ArrayList<>();
List<Integer> combine = new ArrayList<>();
dfs(candidates, target, result, combine, 0);
return result;
}
public void dfs(int[] candidates, int target, List<List<Integer>> result, List<Integer> combine, int idx) {
// 搜索完毕直接返回
if (idx == candidates.length) {
return;
}
// 符合条件保存结果
if (target == 0) {
result.add(new ArrayList<>(combine));
return;
}
// 跳过数组当前元素
dfs(candidates, target, result, combine, idx + 1);
if (target - candidates[idx] >= 0) {
combine.add(candidates[idx]);
// 因为元素可以重复使用,所以传的是 idx
dfs(candidates, target - candidates[idx], result, combine, idx);
combine.remove(combine.size() - 1);
}
}
}
思路二:回溯算法 + 剪枝:
参考回溯经典例题详解
从数组中寻找等于目标值的所有组合,可以使用目标值一次减去数组中的值,这个值可以重复使用,如果最后值为0,则是满足条件的组合,但是根据示例来看是 不计算顺序
的。如:[2, 2, 3], [2, 3, 2], [3, 2, 2] 都算 [2, 2, 3] 一种组合。
根据这个思路,可以画出树形图,然后编码实现。
以输入:candidates = [2, 3, 6, 7], target = 7 为例:
说明:
- 以
t
a
r
g
e
t
=
7
target = 7
target=7 为
根结点
,创建一个分支的时做减法
; - 每一个箭头表示:从父亲结点的数值减去边上的数值,得到孩子结点的数值。边的值就是题目中给出的 c a n d i d a t e candidate candidate 数组的每个元素的值;
- 减到 0 0 0 或者负数的时候停止,即:结点 0 0 0 和负数结点成为叶子结点;
- 所有从根结点到结点
0
0
0 的路径(只能从上往下,没有回路)就是题目要找的一个结果。
这棵树有 4 4 4 个叶子结点的值 0 0 0,对应的路径列表是 [ [ 2 , 2 , 3 ] , [ 2 , 3 , 2 ] , [ 3 , 2 , 2 ] , [ 7 ] ] [[2, 2, 3], [2, 3, 2], [3, 2, 2], [7]] [[2,2,3],[2,3,2],[3,2,2],[7]],而示例中给出的输出只有 [ [ 7 ] , [ 2 , 2 , 3 ] ] [[7], [2, 2, 3]] [[7],[2,2,3]]。即:题目中要求每一个符合要求的解是不计算顺序
的。下面我们分析为什么会产生重复。
分析重复路径产生的原因
产生重复的原因是:在每一个结点,做减法,展开分支的时候,由于题目中说 每一个元素可以重复使用
,考虑了 所有的
候选数,因此出现了重复的列表。
遇到这一类相同元素不计算顺序
的问题,在搜索的时候就需要 按某种顺序搜索
。
具体的做法是:每一次搜索的时候设置 下一轮搜索的起点
b
e
g
i
n
begin
begin,请看下图。
从每一层的第
2
2
2 个结点开始,都不能再搜索产生同一层结点已经使用过的
c
a
n
d
i
d
a
t
e
candidate
candidate 里的元素。
class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> result = new ArrayList<>();
List<Integer> combine = new ArrayList<>();
dfs(candidates, target, result, combine, 0);
return result;
}
public void dfs(int[] candidates, int target, List<List<Integer>> result, List<Integer> combine, int begin) {
// target 为负数和 0 的时候不再产生新的孩子结点
if (target < 0) {
return;
}
// 回溯点,也是递归出口
if (target == 0) {
// 保存结果
result.add(new ArrayList<>(combine));
return;
}
for (int i = begin; i < candidates.length; ++i) {
combine.add(candidates[i]);
// 由于每一个元素可以重复使用,下一轮搜索的起点依然是 i,这里非常容易弄错
dfs(candidates, target - candidates[i], result, combine, i);
// 状态重置
combine.remove(combine.size() - 1);
}
}
}
剪枝优化
如果 target
减去一个数得到负数,那么减去一个更大的树依然是负数,同样搜索不到结果。基于这个想法,可以对输入数组进行排序,添加相关逻辑达到进一步剪枝的目的;
class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> result = new ArrayList<>();
List<Integer> combine = new ArrayList<>();
// 排序是剪枝的前提
Arrays.sort(candidates);
dfs(candidates, target, result, combine, 0);
return result;
}
public void dfs(int[] candidates, int target, List<List<Integer>> result, List<Integer> combine, int begin) {
// 回溯点,也是递归出口
if (target == 0) {
// 保存结果
result.add(new ArrayList<>(combine));
return;
}
for (int i = begin; i < candidates.length; ++i) {
// 剪枝条件
// 前提是候选数组已经有序
if(target - candidates[i] < 0) {
// 不继续往下走了,退回上层,换个路再走
return;
}
combine.add(candidates[i]);
dfs(candidates, target - candidates[i], result, combine, i);
combine.remove(combine.size() - 1);
}
}
}
什么时候使用 used 数组,什么时候使用 begin 变量
- 排列问题,
讲究顺序
(即 [2, 2, 3] 与 [2, 3, 2] 视为不同列表时),需要记录哪些数字已经使用过,此时用used
数组; - 组合问题,
不讲究顺序
(即 [2, 2, 3] 与 [2, 3, 2] 视为相同列表时),需要按照某种顺序搜索,此时使用begin
变量。
复杂度分析
时间复杂度: O ( S ) O(S) O(S),其中 S S S 为所有可行解的长度之和。从分析给出的搜索树可以看出时间复杂度取决于搜索树所有叶子节点的深度之和,即所有可行解的长度之和。在这题中,很难给出一个比较紧的上界,我们知道 O ( n × 2 n ) O(n \times 2^n) O(n×2n)是一个比较松的上界,即在这份代码中,nn 个位置每次考虑选或者不选,如果符合条件,就加入答案的时间代价。但是实际运行的时候,因为不可能所有的解都满足条件,递归的时候我们还会用 $\textit{target} - \textit{candidates}[\textit{idx}] \ge 0¥ 进行剪枝,所以实际运行情况是远远小于这个上界的。
空间复杂度: O ( target ) O(\textit{target}) O(target)。除答案数组外,空间复杂度取决于递归的栈深度,在最差情况下需要递归 O ( target ) O(\textit{target}) O(target) 层。