【39】组合总和

1. 题目描述

image.png

2. 题目链接

https://leetcode.cn/problems/combination-sum/

3. 思路

对于这类寻找所有可行解的题,我们都可以尝试用「搜索回溯」的方法来解决。

  • 我们定义递归函数【39、40、216】组合总和 - 图2表示当前在【39、40、216】组合总和 - 图3数组的第【39、40、216】组合总和 - 图4位,还剩【39、40、216】组合总和 - 图5要组合,已经组合的列表为【39、40、216】组合总和 - 图6

  • 递归的终止条件为【39、40、216】组合总和 - 图7或者【39、40、216】组合总和 - 图8数组被全部用完。

  • 在当前的函数中,每次我们可以选择跳过不用第【39、40、216】组合总和 - 图9个数,即执行【39、40、216】组合总和 - 图10。也可以选择使用第【39、40、216】组合总和 - 图11个数,即执行【39、40、216】组合总和 - 图12,注意这里必须满足【39、40、216】组合总和 - 图13。由于每个数字可以被无限制重复选取,因此搜索的下标仍为【39、40、216】组合总和 - 图14

如果我们将整个搜索过程用一个树来表达,即如下图呈现,每次的搜索都会延伸出两个分叉(选择当前元素或不选择当前元素),直到递归的终止条件,这样我们就能不重复且不遗漏地找到所有可行解:
image.png

4. 代码实现

  1. public List<List<Integer>> combinationSum(int[] candidates, int target) {
  2. if (candidates == null || candidates.length == 0) {
  3. return new ArrayList<>();
  4. }
  5. List<List<Integer>> ans = new ArrayList<>();
  6. dfs(target, new ArrayList<>(), 0, candidates, ans);
  7. return ans;
  8. }
  9. private void dfs(int target, List<Integer> combine, int idx, int[] candidates, List<List<Integer>> ans) {
  10. // 满足条件
  11. if (target == 0) {
  12. ans.add(new ArrayList<>(combine));
  13. return;
  14. }
  15. if (idx >= candidates.length) {
  16. return;
  17. }
  18. // 选择当前数字
  19. if (target - candidates[idx] >= 0) {
  20. combine.add(candidates[idx]);
  21. dfs(target - candidates[idx], combine, idx, candidates, ans);
  22. // 回溯
  23. combine.remove(combine.size() - 1);
  24. }
  25. // 跳过当前数字
  26. dfs(target, combine, idx + 1, candidates, ans);
  27. }
  • 时间复杂度:【39、40、216】组合总和 - 图16,其中【39、40、216】组合总和 - 图17为所有可行解的长度之和。从搜索树中可以看出时间复杂度取决于搜索树所有叶子节点的深度之和,即所有可行解的长度之和。在本题中,我们很难给出一个比较紧的上界,我们知道【39、40、216】组合总和 - 图18是一个比较松的上界,即【39、40、216】组合总和 - 图19个位置每次考虑选或者不选,如果符合条件,就加入答案的时间代价。但实际运行时,因为不可能所有的解都满足条件,且递归时还会用【39、40、216】组合总和 - 图20进行剪枝,所以实际运行情况是远小于这个上界的。
  • 空间复杂度:【39、40、216】组合总和 - 图21。空间复杂度取决于递归的栈深度,由于数组中的元素最小为【39、40、216】组合总和 - 图22,因此在最差情况下需要递归【39、40、216】组合总和 - 图23层。

    【40】组合总和Ⅱ

    1. 题目描述

    image.png

    2. 题目链接

    https://leetcode.cn/problems/combination-sum-ii/

    3. 思路

    这道题与第 39 题的区别在于:

  • 第 39 题:【39、40、216】组合总和 - 图25中的数字可以无限制重复被选取;

  • 第 40 题:【39、40、216】组合总和 - 图26中的每个数字在每个组合中只能使用一次,且【39、40、216】组合总和 - 图27包含重复元素。

因此,第 39 题的思路并不适用于本题。这是因为题目描述中规定了解集不能包含重复的组合,而上述的算法中并没有去除重复的组合。例如当【39、40、216】组合总和 - 图28【39、40、216】组合总和 - 图29时,上述算法会将列表【39、40、216】组合总和 - 图30放入答案两次。因此,我们需要改进上述算法,在求出组合的过程中就进行去重的操作。

如何去掉重复的集合?
所谓去重,其实就是使用过的元素不能重复选取。 我们知道组合问题可以抽象为树形结构,那么「使用过」在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。由于题目要求元素在同一个组合内是可以重复的,但两个组合之间不能相同。所以,我们要去重的是同一树层上的「使用过」,同一树枝上的都是一个组合里的元素,因此不用去重。

要对树层去重,需要先对数组做升序排序,这样重复的元素一定不是排好序以后相同的连续数组区域的第【39、40、216】组合总和 - 图31个元素。即剪枝发生在:同一树层中,数值相同的节点中,第【39、40、216】组合总和 - 图32个结点,因为数值相同的第【39、40、216】组合总和 - 图33个节点已经搜索出了包含了这个数值的全部结果,那么数值相同的节点就不用再搜索了。

image.png
image.png

4. 代码实现

  1. public List<List<Integer>> combinationSum2(int[] candidates, int target) {
  2. if (candidates == null || candidates.length == 0) {
  3. return new ArrayList<>();
  4. }
  5. List<List<Integer>> ans = new ArrayList<>();
  6. // 排序
  7. Arrays.sort(candidates);
  8. dfs(target, new ArrayList<>(), 0, candidates, ans);
  9. return ans;
  10. }
  11. private void dfs(int target, List<Integer> combine, int idx, int[] candidates, List<List<Integer>> ans) {
  12. // 满足条件
  13. if (target == 0) {
  14. ans.add(new ArrayList<>(combine));
  15. return;
  16. }
  17. for (int i = idx; i < candidates.length; i++) {
  18. // 大剪枝,因为candidates数组是顺序的,如果当前已经不满足了,则直接break
  19. if (target - candidates[i] < 0) {
  20. break;
  21. }
  22. // 小剪枝:跳过同一树层数值相同的元素
  23. if (i > idx && candidates[i] == candidates[i - 1]) {
  24. continue;
  25. }
  26. combine.add(candidates[i]);
  27. // 因为元素不可以重复使用,这里递归传递下去的是 i + 1 而不是 i
  28. dfs(target - candidates[i], combine, i + 1, candidates, ans);
  29. combine.remove(combine.size() - 1);
  30. }
  31. }

这里重点解释下:i > idx && candidates[i] == candidates[i - 1] 是如何避免重复的。这个方法最重要的作用是能够让同一层级不出现相同的元素。即:

  1. 1
  2. / \
  3. 2 2
  4. / \
  5. 5 5

这种情况不会发生,但是却允许了不同层级之间的重复。即:

  1. 1
  2. /
  3. 2
  4. /
  5. 2

这种情况确是允许的。那它是怎么做到的呢?

首先【39、40、216】组合总和 - 图36是用于判定当前元素是否和前一个元素相同的语句,但它没有判断是同一树层中的元素重复,还是同一树枝中的元素重复。因此,它无法允许第二个例子里的情况,因为当例二中第二个【39、40、216】组合总和 - 图37出现时,它就和上一个【39、40、216】组合总和 - 图38相同了。

为了只过滤同一树层中的相同元素,那么就要用【39、40、216】组合总和 - 图39来保证,因为在一个 for 循环中,所有被遍历到的数都是属于同一个树层的。我们要让一个树层中只出现一个【39、40、216】组合总和 - 图40,那么就放过第一个出现重复的【39、40、216】组合总和 - 图41,但不放过后面出现的【39、40、216】组合总和 - 图42。第一个出现的【39、40、216】组合总和 - 图43就是【39、40、216】组合总和 - 图44,第二个出现的【39、40、216】组合总和 - 图45就是【39、40、216】组合总和 - 图46

如下示例为:candidates = [1, 2, 2, 5],target = 8,层级结构如下图所示:
image.png
从图中可以看到,在纵向遍历的过程中,始终有【39、40、216】组合总和 - 图48(因为递归的入参中【39、40、216】组合总和 - 图49【39、40、216】组合总和 - 图50,隐含地选择了下一个元素),而在 for 循环横向遍历的过程中【39、40、216】组合总和 - 图51是递增的,因此同一树层中,从第二个元素开始,始终有【39、40、216】组合总和 - 图52