39. 组合总和
给你一个 无重复元素
的整数数组 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 <= candidates.length <= 30
- 2 <= candidates[i] <= 40
- candidates 的所有元素 互不相同
- 1 <= target <= 40
思路
题目中的无限制重复被选取,吓得我赶紧想想 出现0
可咋办(如果有0
,递归到0
时,继续递归每次选个0
,又继续递归,target
不变,会陷入死循环),然后看到下面提示:2 <= candidates[i] <= 40
,我就放心了。
本题和77. 组合【含回溯详解、N叉树类比、剪枝优化】以及216.组合总和III的区别是:本题没有数量要求,可以无限重复(即同一个数字可以无限次选),但是有总和的限制,所以间接的也是有个数的限制。
本题搜索的过程抽象成树形结构如下:
注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target
,就返回!
而在77. 组合【含回溯详解、N叉树类比、剪枝优化】以及216.组合总和III中都可以知道要递归K
层,因为要取k
个元素的组合。
回溯三部曲
1.递归函数参数
首先是题目中给出的参数,集合candidates
, 和目标值target
。
然后这里依然是定义两个变量,二维切片res
存放结果集,切片path
存放符合条件的结果。
此外还可以定义一个int
型的sum
变量来统计单一结果path
里的总和,但这个sum
也可以不用,用target
做相应的减法就可以了,最后如何target==0
就说明找到符合的结果了。
最后本题还需要startIndex
来控制for
循环的起始位置,对于组合问题,什么时候需要startIndex
呢?
我举过例子,如果是一个集合来求组合的话,就需要startIndex
,例如:77. 组合【含回溯详解、N叉树类比、剪枝优化】以及216.组合总和III
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex
,例如:17.电话号码的字母组合
注意以上我只是说求组合的情况,如果是排列问题,又是另一套分析的套路,后面我在讲解排列的时候会重点介绍。
代码如下:
func backtracking(candidates []int,target int,res *[][]int,path *[]int,startIndex int){}
2.递归终止条件
在如下树形结构中:
从叶子节点可以清晰看到,终止只有两种情况,sum
大于target
和sum
等于target
。不用sum
变量的话,对应target < 0
和target == 0
。
sum
等于target
(target ==0
)的时候,需要收集结果,代码如下:
if target < 0 {
return
}
if target == 0 {
*res = append(*res,append([]int(nil),*path...))
return
}
3.单层搜索的逻辑
单层for
循环依然是从startIndex
开始,搜索candidates
集合。
注意本题和77. 组合【含回溯详解、N叉树类比、剪枝优化】以及216.组合总和III的一个区别是:本题元素为可重复选取的。
如何重复选取呢,看代码,注释部分:
for i := startIndex;i < len(candidates);i++ {
*path = append(*path,candidates[i])
// 关键点:不用i+1了,表示下一层递归可以重复读取当前的数
// 此外target - candidates[i]作为参数进行值传递,隐含了target的回溯
backtracking(candidates,target - candidates[i],res,path,i)
*path = (*path)[0:len(*path) - 1] // 回溯
}
按照关于回溯法模板,不难写出如下Go
完整代码:
版本一
func combinationSum(candidates []int, target int) [][]int {
if len(candidates) == 0 {
return nil
}
res := make([][]int,0)
path := make([]int,0)
backtracking(candidates,target,&res,&path,0)
return res
}
func backtracking(candidates []int,target int,res *[][]int,path *[]int,startIndex int){
if target < 0 {
return
}
if target == 0 {
*res = append(*res,append([]int(nil),*path...))
return
}
for i := startIndex;i < len(candidates);i++ {
*path = append(*path,candidates[i])
// 关键点:不用i+1了,表示下一层递归可以重复读取当前的数
// 此外target - candidates[i]作为参数进行值传递,隐含了target的回溯
backtracking(candidates,target - candidates[i],res,path,i)
*path = (*path)[0:len(*path) - 1]
}
}
剪枝优化
在这个树形结构中:
以及上面的版本一的代码大家可以看到,对于sum
已经大于target
,即target
减到小于0
的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断target < 0
的话就返回。
其实如果已经知道下一层的target
会小于0
,就没有必要进入下一层递归了。
那么可以在for
循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的target(就是本层的 target - candidates[i])
已经小于0
,就可以结束本轮for
循环的遍历,这是在横向剪枝,上面版本一是纵向遇到递归终止条件后才返回。
如图:
for
循环剪枝代码如下:
for i := startIndex;i < len(candidates) && target - candidates[i] >= 0;i++ {}
整体代码如下:(注意注释的部分)
func combinationSum(candidates []int, target int) [][]int {
if len(candidates) == 0 {
return nil
}
res := make([][]int,0)
path := make([]int,0)
sort.Ints(candidates) // 排序,为了横向剪枝,不剪枝也能AC,但是效率慢点
backtracking(candidates,target,&res,&path,0)
return res
}
func backtracking(candidates []int,target int,res *[][]int,path *[]int,startIndex int){
if target < 0 {
return
}
if target == 0 {
*res = append(*res,append([]int(nil),*path...))
return
}
// target - candidates[i] >= 0为横向剪枝
for i := startIndex;i < len(candidates) && target - candidates[i] >= 0;i++ {
*path = append(*path,candidates[i])
// 关键点:不用i+1了,表示下一层递归可以重复读取当前的数
// 此外target - candidates[i]作为参数进行值传递,隐含了target的回溯
backtracking(candidates,target - candidates[i],res,path,i)
*path = (*path)[0:len(*path) - 1]
}
}
总结
本题和我们之前讲过的77.组合、216.组合总和III
有两点不同:
- 组合没有数量要求
- 元素可无限重复选取
针对这两个问题,我都做了详细的分析。
并且给出了对于组合问题,什么时候用startIndex
,什么时候不用,并用17.电话号码的字母组合
做了对比。
最后还给出了本题的剪枝优化,这个优化如果是初学者的话并不容易想到。
在求和问题中,排序之后加剪枝是常见的套路!
可以看出写的文章都会大量引用之前的文章,就是要不断作对比,分析其差异,然后给出代码解决的方法,这样才能彻底理解题目的本质与难点。