文章目录
40. 组合总和 II
给定一个候选人编号的集合 candidates
和一个目标数 target
,找出 candidates
中所有可以使数字和为 target
的组合。
candidates
中的每个数字在每个组合中只能使用 一次 。
注意:解集不能包含重复的组合。
示例 1:
go
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]
示例 2:
go
输入: candidates = [2,5,2,1,2], target = 5,
输出:
[
[1,2,2],
[5]
]
提示:
- 1 <= candidates.length <= 100
- 1 <= candidates[i] <= 50
- 1 <= target <= 30
思路
这道题目和39.组合总和 如下区别:
- 本题
candidates
中的每个数字在每个组合中只能使用一次,而39.组合总和
同一个元素可以选取无限次。 - 本题数组
candidates
的元素是有重复的,而39.组合总和
是无重复元素的数组candidates
- 最后本题和
39.组合总和
要求一样,解集不能包含重复的组合。
本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合。
一些同学可能想了:我把所有组合求出来,再用set
或者map
去重,这么做很容易超时!
所以要在搜索的过程中就去掉重复组合。
很多同学在去重的问题上想不明白,其实很多题解也没有讲清楚,反正代码是能过的,感觉是那么回事,稀里糊涂的先把题目过了。
这个去重为什么很难理解呢,所谓去重,其实就是使用过的元素不能重复选取。 这么一说好像很简单!
都知道组合问题可以抽象为树形结构,那么"使用过"在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过
。没有理解这两个层面上的"使用过" 是造成大家没有彻底理解去重的根本原因。
那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的"使用过",同一树枝上的都是一个组合里的元素,不用去重。注意:同一层取不同数是要去构造不同的路径了(不同组合),而同一树枝上则是同一路径(组合)的不同元素
为了理解去重我们来举一个例子,candidates = [1, 1, 2], target = 3
,(方便起见candidates
已经排序了)
强调一下,树层去重的话,需要对数组排序!
选择过程树形结构如图所示:
可以看到图中,每个节点相对于 39.组合总和 我多加了used
切片,这个used
切片下面会重点介绍。
回溯三部曲
1.递归函数参数
与39.组合总和
套路相同,不过此题还需要加一个bool
型切片used
,用来记录同一层的某个元素是否使用过。
这个集合去重的重任就是used
来完成的。
代码如下:
go
func backtracking(candidates []int,target int,res *[][]int,
path *[]int,startIndex int,used []bool) {}
2.递归终止条件
与39.组合总和
相同,终止条件为 target < 0
和 target == 0
。
代码如下:
go
if target < 0 { // 这个条件其实可以省略
return
}
if target == 0 {
*res = append(*res,append([]int(nil),*path...))
return
}
target < 0
这个条件其实可以省略,因为在递归单层遍历的时候,会有剪枝的操作,下面会介绍到。
3.单层搜索的逻辑
这里与39.组合总和
最大的不同就是要去重了。
前面我们提到:要去重的是"同一树层上的使用过"
,如何判断同一树层上元素(相同的元素)是否使用过了呢。
如果candidates[i] == candidates[i - 1] 并且 used[i - 1] == false
,就说明:前一个树枝,使用了candidates[i - 1]
,也就是说同一树层使用过candidates[i - 1]
此时for
循环里就应该做continue
的操作。
这块比较抽象,如图:
我在图中将used
的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]
相同的情况下:
- 如果used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- 如果used[i - 1] == false,说明同一树层candidates[i - 1]使用过
可能有的朋友想,为什么 used[i - 1] == false
就是同一树层呢,因为同一树层,used[i - 1] == false
才能表示,当前取的 candidates[i]
是从 candidates[i - 1]
回溯而来的。
而 used[i - 1] == true
,说明是进入下一层递归,取下一个数,所以是树枝上,如图所示:
对照上面的图,如果将第二个1
看成1'
,可选集合则是[1,1',2]
,看成第0
层。开始递归,第一层选1
,第二层选1'
,第三层选2
,得到[1,1',2]
。回溯回到第二层,横向选2
,得到[1,2]
,又要回溯上去了,回到第一层,选1'
,然后如果继续递归到第二层,选2
,会得到[1',2]
,而实际上以1'
开头,递归下去选它之后的数的组合,都包含在同层前一个树枝,以1
开头去选1'
之后的数的情况中了,所以是同层需要去重直接以1'
开头的情况的。
这块去重的逻辑很抽象,网上搜的题解基本没有能讲清楚的,如果大家之前思考过这个问题或者刷过这道题目,看到这里一定会感觉通透了很多!
那么单层搜索的逻辑代码如下:
go
for i := startIndex;i < len(candidates) && target - candidates[i] >= 0;i++ {
if i > 0 && candidates[i] == candidates[i - 1] && !used[i - 1]{
// 同层横向遍历,前一个相同数字没有用过就用后一个数字
// 要对同一树层使用过的元素进行跳过
continue
}
*path = append(*path,candidates[i])
used[i] = true
// 和39.组合总和的区别1:这里是i+1,每个数字在每个组合中只能使用一次
backtracking(candidates,target - candidates[i],res,path,i+1,used)
*path = (*path)[0:len(*path) - 1]
used[i] = false
}
注意target - candidates[i] >= 0
为剪枝操作,在39.组合总和
有讲解过!
回溯三部曲分析完了,整体Go
代码如下:
go
func combinationSum2(candidates []int, target int) [][]int {
if len(candidates) == 0 {
return nil
}
res := make([][]int,0)
path := make([]int,0)
used := make([]bool,len(candidates))
sort.Ints(candidates) // 排序,为了等下方便去重
backtracking(candidates,target,&res,&path,0,used)
return res
}
func backtracking(candidates []int,target int,res *[][]int,path *[]int,startIndex int,used []bool) {
if target < 0 {
return
}
if target == 0 {
*res = append(*res,append([]int(nil),*path...))
return
}
for i := startIndex;i < len(candidates) && target - candidates[i] >= 0;i++ {
if i > 0 && candidates[i] == candidates[i - 1] && !used[i - 1]{
// 同层横向遍历,前一个相同数字没有用过就用后一个数字,是重复的,要去重
continue
}
*path = append(*path,candidates[i])
used[i] = true
backtracking(candidates,target - candidates[i],res,path,i+1,used)
*path = (*path)[0:len(*path) - 1]
used[i] = false
}
}
补充
这里直接用startIndex
来去重也是可以的, 就不用used
切片了。
go
func combinationSum2(candidates []int, target int) [][]int {
if len(candidates) == 0 {
return nil
}
res := make([][]int,0)
path := make([]int,0)
sort.Ints(candidates) // 排序,为了等下方便去重
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++ {
// 要对同一树层使用过的元素进行跳过
// 技巧:i != startIndex说明是同层的后一轮for循环了,优化了used切片
if i != startIndex && candidates[i] == candidates[i - 1] {
continue
}
*path = append(*path,candidates[i])
backtracking(candidates,target - candidates[i],res,path,i+1)
*path = (*path)[0:len(*path) - 1]
}
}
总结
本题同样是求组合总和,但就是因为其数组candidates
有重复元素,而要求不能有重复的组合,所以相对于39.组合总和
难度提升了不少。
关键是去重的逻辑,代码很简单,网上一搜一大把,但几乎没有能把这块代码含义讲明白的,基本都是给出代码,然后说这就是去重了,究竟怎么个去重法也是模棱两可。
所以本文有必要把去重这块彻彻底底的给大家讲清楚,就连"树层去重"和"树枝去重"实际不是业界词汇,这么描述是希望对大家理解有帮助!