40. 组合总和 II

40. 组合总和 II

40. 组合总和 II

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次 。

注意:解集不能包含重复的组合。

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

示例 2:

输入: 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来完成的。

代码如下:

func backtracking(candidates []int,target int,res *[][]int,
path *[]int,startIndex int,used []bool) {}

2.递归终止条件
39.组合总和 相同,终止条件为 target < 0target == 0

代码如下:

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'开头的情况的。

这块去重的逻辑很抽象,网上搜的题解基本没有能讲清楚的,如果大家之前思考过这个问题或者刷过这道题目,看到这里一定会感觉通透了很多!

那么单层搜索的逻辑代码如下:

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代码如下:

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切片了。

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.组合总和难度提升了不少。

关键是去重的逻辑,代码很简单,网上一搜一大把,但几乎没有能把这块代码含义讲明白的,基本都是给出代码,然后说这就是去重了,究竟怎么个去重法也是模棱两可。

所以本文有必要把去重这块彻彻底底的给大家讲清楚,就连“树层去重”和“树枝去重”实际不是业界词汇,这么描述是希望对大家理解有帮助!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值