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
思路
这一题很像三数之和的一般化。三数之和,可以通过双指针完成,但这里的一个有效解的长度是未知的,意味着并不能固定指针个数,而要动态增添指针。
第一眼的思路
我第一遍写,与前一道题相似,使用递归完成,基本思路是
1 | combinationSum(candidates, target) = |
在上述伪代码中,用 *
表示取两个二维数组的所有项的所有组合。如 [[1], [2]] * [[3]] = [[1,3], [2,3]]
,特殊地,
[[1]] * [] = []
,[[1]] * [[]] = [[1]]
,这里,将 []
看作这个运算的 0,将 [[]]
看作这个运算的单位1,可以帮助理解这个 *
运算符;
用 +
表示并集,此时 []
为并集运算的单位1,任意数组与 []
取并集仍然等于它自己。
则上述递归式表示对于给定的 candidates
和 target
,组合总和就是以下两个解集的并集
- 不包括第一项的
candidates
和target
的组合总和的解集 - 单独包括第一项的二维数组
*
不包括第一项的candidates
和target - 第一项
的组合总和的解集的运算结果
还是很晦涩……,以 candidates = [1,3]
和 target = 4
为例,简化方法名为 getCS
:
1 | getCS([1,3], 4) |
上述的伪代码里,getCS([4], 0)
表示从 [4]
这个数组中取个 0
,很显然取不到,但是根据题意可以清楚,当算式简化为取 0
的时候,说明前面拆解的各项已经等于 target
,此项的结果不应该否认前面算式的结果,所以应该假设 getCS(x, 0) = [[]]
,这里的 x
可以是任意数组,只有这个式子等于 *
运算的单位1,才能保证不对前面的结果产生影响。
根据语意也可知,getCS([], y)
在 y ≠ 0
时,应该返回 []
,表示无法组合,继而否定前面所有的 *
运算结果。
上述本是一个合理的运算过程,但是,这个算法过于 人类,对于组合数稍微增加的用例,其计算量会陡然增加,从而在运算时超时。极端的用例子是,[1,1,1,1,1,1,1...1]
共 40 个 1,计算对于 target = 30
的组合总和,肉眼看过去,解集中有且只有一个解,即取 30 个 1,但是代入上述的算法中,可以发现,算法算了无数次的 getCS([1,...,1], 30)
这类的值,毫无意义。所以不出意外地,这个算法超时了。
正解思路
之后我换用一种类似遍历树的思路,将一个值之后的其他所有值看作它的分叉,则将此题的 candidates
转换为了一个可以被广度遍历或者深度遍历的东西,当然,实际我并没有真的做这样的转换,因为没有必要。
具体做法是:
- 首先将
candidates
做升序排序,方便后面循环取值 - 设置一个解集
ans
,存储遍历找到的解 - 接着设置一个当前遍历路径的数组
current
和 当前遍历路径的总和sum
- 显然
current
和sum
总是会一起变动,所以,可以写两个方法统一操作数组的值进出 - 接下来使用一个
head
指针,依次将candidates
中的每一项看作是树的根节点,对每棵树做如下的循环操作- 将当前节点加入遍历数组
current
中,将计算后的sum
与target
做判断- 若
sum
大于target
,则说明当前遍历数组不能满足题意,由于candidates
是递增的,故当前项之后的所有项也不会满足题意,所以当前项的前一项也要替换为其下一项① - 若
sum
小于target
,则说明当前遍历路径可以继续,则对遍历指针进行递增操作即可。递增的后的指针有可能超出candidates
的长度,此时需要将当前项与前一项一起删除,并取前一项的下一项继续遍历 - 若两者相等,则将当前遍历路径增加到解集中。然后本应将当前项替换为下一项进行继续的遍历,但是仍然由于
candidates
是递增的,当前遍历数组如果恰好满足题意,则当前项之后的项替换当前项后的遍历数组,一定不满足题意,故直接删除当前项和前一项,取前一项的下一项继续遍历
- 若
- 当
head
指针超过candidates
的长度后,循环结束
- 将当前节点加入遍历数组
- 最后返回解集
①:这里的取下一项是指,遍历数组删除一项或多项后,取最后删除的元素在 candidates
中的下一个元素作为下一项。其中,与最后删除的元素值相等的元素应该直接跳过再取下一项,避免重复。因此,遍历数组 current
不仅存放元素,还有其对应的在 candidates
中的下标,即遍历数组 current
的大概格式为:
1 | const current: Array<{ |
如果在遍历数组删除元素时,遍历数组清空,则意味着以当前 head
指针指向的元素作为树的有效路径被遍历完,则应该将 head
递增,这也是上述算法第 5 步可以跳出循环的原因。
例子
仍然以 candidates = [1,3]
和 target = 4
为例,
遍历的第一次循环,head
指向 1
,遍历路径的和也为 1
,递增遍历指针,然后 3
加入遍历数组。
此时的遍历数组为
1 | current = [ |
遍历数组的和等于 target
,解集增加解 [1,3]
。
然后按算法,将当前项与前一项移除遍历数组,此时遍历数组清空,应取删除的最后一个元素 1
的下一项 3
重新加入遍历数组。
当然这次的循环是断然找不到解的,所以最后的解集为 [[1,3]]
解
1 | /** |
这道题也挺有意思,虽然题意与上一道组合总和题相似,但是因为运算超时的原因,逼着我不得不使用非递归的方法实现