一套代码解决 Combination Sum 系列问题

2020年3月23日 · 2644 字

本文讲解的问题为 Combination Sum 系列问题,共四道:

  • 39. Combination Sum(Medium)
  • 40. Combination Sum II(Medium)
  • 216. Combination Sum III(Medium)
  • 377. Combination Sum IV(Medium)

我们的系列文章已经有三期回溯法的内容了。前面的两篇文章中,我们详细讲解了回溯法的子集问题、排列问题、组合问题这些经典问题,讲解了回溯法的候选集合概念以及剪枝方法:

回溯法的基本方法讲解就到此为止了。本篇文章是习题篇,以 LeetCode 上的 Combination Sum 系列问题为例,用一套代码解决系列问题,对每一题根据题目条件的不同做代码的微调。在回溯法问题中,很多细微条件的不同就会导致代码思路上出现变化。对于 Combination Sum 系列问题,我们要处理的条件有:

  • 有放回 vs 无放回
  • 有重复元素 vs 无重复元素
  • 是否限制组合数量

下面将一一讲解如何微调代码来处理这些条件。

Combination Sum 通用解法

我们先写出一套通用的 Combination Sum 问题解法,后面再根据具体的题目做代码上的调整。

标准版 Combination Sum 问题(LeetCode 上的系列每一道都和它有细微差异):

给定一个数组 candidates 和一个目标数 target,找出 candidates 中所有可以使数字之和为 target 的组合。

注意:

  • 数组 candidates 中无重复元素。
  • 所有数字(包括 target)都是正整数。
  • 组合是无序的,不同顺序的排列应视为同一组合。

Combination Sum 问题和组合(Combination)问题非常相似。掌握了组合问题的解法(可以回顾这篇文章),就可以很轻松地写出 Combination Sum 的代码。在回溯函数中添加一个参数 target,每次选择了一个元素 x,就将 target - x 传入下一个回溯函数中。当 target 为 0 时,我们就找到了一个和为 target 的组合。以下是题解代码:

java
public List<List<Integer>> combinationSum(int[] candidates, int target) {
    Deque<Integer> current = new ArrayDeque<>();
    List<List<Integer>> res = new ArrayList<>();
    backtrack(candidates, 0, target, current, res);
    return res;
}

// 候选集合 candidates[m..N)
void backtrack(int[] candidates, int m, int target, Deque<Integer> current, List<List<Integer>> res) {
    if (target < 0) {
        return;
    } else if (target == 0) {
        res.add(new ArrayList<>(current));
        return;
    }

    // 和 Combinations 问题类似,需要升序
    for (int i = m; i < candidates.length; i++) {
        // 选择数字 candidates[i]
        current.addLast(candidates[i]);
        // 元素 candidates[m..i) 均失效
        backtrack(candidates, i+1, target - candidates[i], current, res);
        // 撤销选择
        current.removeLast();
    }
}

需要注意的是,Combination Sum 并不限制组合中元素的个数,所以回溯法代码中并没有 k 这个参数。请记住这段代码,后面的代码都将在这段代码的基础上进行微调。

问题的各种变种

下面,我们来分别看看 LeetCode 上四道系列题目是如何在标准 Combination Sum 问题上做变化的,又该如何具体解决。

有放回 vs. 无放回

题目:LeetCode 39 - Combination Sum(Medium)

变化之处:数组中的元素可以无限制重复选取。

所谓的可以重复选取,其实可以理解成有放回的抽样。

在标准版问题中,数组元素不可以重复选择,是无放回的。对于数组 candidates,如果当前选择了元素 candidates[i],下一轮的候选集合就会变成 candidates[i+1..N)

对于有放回的变种问题,我们可以将当前元素 candidates[i] “放回”候选集合中,下一轮的候选集合变成 candidates[i..N)。这样,candidates[i] 在后面仍然有可能被选中。

我们只需要在原先的代码中调整递归调用的参数,让下一轮的候选集合由 candidates[i+1..N) 变成 candidates[i..N)。题解代码如下:

java
public List<List<Integer>> combinationSum(int[] candidates, int target) {
    Deque<Integer> current = new ArrayDeque<>();
    List<List<Integer>> res = new ArrayList<>();
    backtrack(candidates, 0, target, current, res);
    return res;
}

// 候选集合 candidates[m..N)
void backtrack(int[] candidates, int m, int target, Deque<Integer> current, List<List<Integer>> res) {
    if (target < 0) {
        return;
    } else if (target == 0) {
        res.add(new ArrayList<>(current));
        return;
    }

    for (int i = m; i < candidates.length; i++) {
        // 选择数字 candidates[i]
        current.addLast(candidates[i]);
        // 代码调整处:递归调用参数
        // 递归调用传递 i 而不是原先的 i+1
        // 这样 candidates[i] 选完后仍然在候选集合里,后续仍然可以再选
        backtrack(candidates, i, target - candidates[i], current, res);
        // 撤销选择
        current.removeLast();
    }
}

有重复元素 vs. 无重复元素

题目:LeetCode 40 - Combination Sum II(Medium)

变化之处:数组中可能含有重复元素。

当数组中含有重复元素的时候,我们需要去除重复的结果。例如,数组为 [1,2,2][1, 2, 2]target 为 3。如果把数组中的两个 22 记为 212_1222_2 的话,[1,21][1, 2_1][1,22][1, 2_2] 就是两个重复的结果,只能保留一个。

我们在前一篇文章中已经讲解过了回溯法中有重复元素的问题,并给出了子集、排列、组合问题的去重策略(要回顾前一篇文章,请点击这里)。Combination Sum 问题的去重策略可以直接参考组合问题的去重策略:

  1. 在回溯之前,预先将数组中的元素排序,保证相等的元素是相邻的;
  2. 当有多个相等元素的时候,只能选择其中第一个。

我们只需要在原先的代码中调整候选元素的遍历。如果 candidates[i] 与前一个元素相等,说明不是相等元素中的第一个,则跳过该元素。题解代码如下:

java
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
    Arrays.sort(candidates);
    Deque<Integer> current = new ArrayDeque<>();
    List<List<Integer>> res = new ArrayList<>();
    backtrack(candidates, 0, target, current, res);
    return res;
}

// 候选集合 candidates[m..N)
void backtrack(int[] candidates, int m, int target, Deque<Integer> current, List<List<Integer>> res) {
    if (target < 0) {
        return;
    } else if (target == 0) {
        res.add(new ArrayList<>(current));
    }

    for (int i = m; i < candidates.length; i++) {
        // 代码调整处:候选集合遍历
        if (i > m && candidates[i] == candidates[i-1]) {
            // 如果 candidates[i] 与前一个元素相等,说明不是相等元素中的第一个,跳过。
            continue;
        }
        // 选择数字 candidates[i]
        current.addLast(candidates[i]);
        // 元素 candidates[m..i) 均失效
        backtrack(candidates, i+1, target - candidates[i], current, res);
        // 撤销选择
        current.removeLast();
    }
}

限制 k-combination

LeetCode 216 - Combination Sum III(Medium)

变化之处:限制结果只能是 k-combination,即由 k 个元素相加得到 target

前面几道题中,组合中元素的数量没有限制。而这道题限制了组合必须正好是 k 个数。这其实回归到了正常的 “nn 中取 kk” 的组合问题,我们需要在回溯函数中添加参数 k,当组合的大小达到 k,并且和为 target 的时候才得到一个结果。

这道题的另一个变化之处是没有输入数组,直接变成在 1 到 9 的数字中选择。这个变化比较好应对,只需要稍微调整递归参数即可。

题解代码如下:

java
public List<List<Integer>> combinationSum3(int k, int n) {
    Deque<Integer> current = new ArrayDeque<>();
    List<List<Integer>> res = new ArrayList<>();
    backtrack(k, 1, n, current, res);
    return res;
}

// 候选集合:整数 [m..9]
// 代码调整处:加入参数 k
void backtrack(int k, int m, int target, Deque<Integer> current, List<List<Integer>> res) {
    if (target < 0) {
        return;
    } else if (target == 0) {
        // 代码调整处:已选集合达到 k 个元素才收集结果
        if (current.size() == k) {
            res.add(new ArrayList<>(current));
        }
        return;
    }
    if (current.size() > k) {
        return;
    }

    // 从候选集合中选择
    for (int i = m; i <= 9; i++) {
        // 选择数字 i
        current.addLast(i);
        // 数字 [m..i) 均失效
        backtrack(k, i+1, target - i, current, res);
        // 撤销选择
        current.removeLast();
    }
}

回溯法 vs. 动态规划

题目:LeetCode 377 - Combination Sum IV(Medium)

变化之处:……我怎么看已经变成另一道题了呢?

这道题其实非常有迷惑性。我们重新读一下题:

给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。

示例:nums = [1, 2, 3]target = 4。所有可能的组合为:

text
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)

请注意,顺序不同的序列被视作不同的组合。因此输出为 7。

“顺序不同的序列被视作不同的组合”,这已经脱离了组合的概念,变成了排列!所以说,这道题实际上是 Permutation Sum 问题,而不是 Combination Sum。既然这样,我们就不能再用 Combination Sum 的代码去套了。

前面我们用一个套路做完了 Combination Sum 的三道题。这第四道题,就是 LeetCode 让你上钩的。这道题根本不是一个回溯法题目,用回溯法做的话,会超出时间限制(TLE)。所以说,我们做题不能形成思维定式,要仔细思考题目的条件。

明白这一点之后,我们会发现,这道题是一个不折不扣的动态规划题目。再定睛一看,这道题实际上就是换零钱问题嘛:

LeetCode 322 - Coin Change(Medium)

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。你可以认为每种硬币的数量是无限的。

示例 :

text
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1

只不过是“数组”变成了“硬币”,target 变成了 amount 而已。那么这道题的解法,参考换零钱问题即可。

总结

Combination Sum 系列问题是回溯法非常好的练习题,既能够巩固我们之前讲解的候选元素、剪枝的概念,又能体会题目的不同变种给代码带来的影响。回溯法问题千变万化,题目的细节条件稍有不同就变成了另一道题(甚至可能从回溯法变成了动态规划,例如系列的第四题)。因此我们在做题的时候,一定要推敲题目的细节。在面试中遇到回溯法题目,也要和面试官沟通好题目的各个条件再开始写代码。

另外预告一下,回溯法问题的讲解暂时告一段落。接下来会写几篇二叉树、DFS/BFS 相关文章,敬请期待~

本文首发于微信公众号:FUTURE CODER 未来开发者, 点此查看原文