数据结构与算法之深入解析“组合总和II”的求解思路与算法示例

Posted Serendipity·y

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构与算法之深入解析“组合总和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

二、求解算法

① 回溯法

  • 这道题与【数据结构与算法】之深入解析“组合总和”的求解思路与算法示例 的区别在于:组合总和中的 candidates 中的数字可以无限制重复被选取;而本题中的 candidates 中的每个数字在每个组合中只能使用一次;但它们的相同点是:相同数字列表的不同排列视为一个结果。那么,最重要的就是如何去掉重复的集合。
  • 为了使得解集不包含重复的组合,有以下 2 种方案:
    • 使用哈希表天然的去重功能,但是编码相对复杂;
    • 这里使用和“组合总和”类似的思路:不重复就需要按顺序搜索, 在搜索的过程中检测分支是否会出现重复结果 。
    • 注意:这里的顺序不仅仅指数组 candidates 有序,还指按照一定顺序搜索结果。


  • 我们知道,数组 candidates 有序,也是深度优先遍历过程中实现「剪枝」的前提。将数组先排序的思路来自于这个问题:去掉一个数组中重复的元素。很容易想到的方案是:先对数组升序排序,重复的元素一定不是排好序以后相同的连续数组区域的第 1 个元素。也就是说,剪枝发生在:同一层数值相同的结点第 2、3 … 个结点,因为数值相同的第 1 个结点已经搜索出了包含了这个数值的全部结果,同一层的其它结点,候选数的个数更少,搜索出的结果一定不会比第 1 个结点更多,并且是第 1 个结点的子集。
  • Java 示例:
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.List;

public class Solution 

    public List<List<Integer>> combinationSum2(int[] candidates, int target) 
        int len = candidates.length;
        List<List<Integer>> res = new ArrayList<>();
        if (len == 0) 
            return res;
        

        // 关键步骤
        Arrays.sort(candidates);

        Deque<Integer> path = new ArrayDeque<>(len);
        dfs(candidates, len, 0, target, path, res);
        return res;
    

    /**
     * @param candidates 候选数组
     * @param len        冗余变量
     * @param begin      从候选数组的 begin 位置开始搜索
     * @param target     表示剩余,这个值一开始等于 target,基于题目中说明的"所有数字(包括目标数)都是正整数"这个条件
     * @param path       从根结点到叶子结点的路径
     * @param res
     */
    private void dfs(int[] candidates, int len, int begin, int target, Deque<Integer> path, List<List<Integer>> res) 
        if (target == 0) 
            res.add(new ArrayList<>(path));
            return;
        
        for (int i = begin; i < len; i++) 
            // 大剪枝:减去 candidates[i] 小于 0,减去后面的 candidates[i + 1]、candidates[i + 2] 肯定也小于 0,因此用 break
            if (target - candidates[i] < 0) 
                break;
            

            // 小剪枝:同一层相同数值的结点,从第 2 个开始,候选数更少,结果一定发生重复,因此跳过,用 continue
            if (i > begin && candidates[i] == candidates[i - 1]) 
                continue;
            

            path.addLast(candidates[i]);
            // 调试语句 ①
            // System.out.println("递归之前 => " + path + ",剩余 = " + (target - candidates[i]));

            // 因为元素不可以重复使用,这里递归传递下去的是 i + 1 而不是 i
            dfs(candidates, len, i + 1, target - candidates[i], path, res);

            path.removeLast();
            // 调试语句 ②
            // System.out.println("递归之后 => " + path + ",剩余 = " + (target - candidates[i]));
        
    

    public static void main(String[] args) 
        int[] candidates = new int[]10, 1, 2, 7, 6, 1, 5;
        int target = 8;
        Solution solution = new Solution();
        List<List<Integer>> res = solution.combinationSum2(candidates, target);
        System.out.println("输出 => " + res);
    

  • 打开上面的调试语句,针对输入 int[] candidates = new int[]10, 1, 2, 7, 6, 1, 5; 和 int target = 8; 控制台输出如下:
递归之前 => [1],剩余 = 7
递归之前 => [1, 1],剩余 = 6
递归之前 => [1, 1, 2],剩余 = 4
递归之后 => [1, 1],剩余 = 4
递归之前 => [1, 1, 5],剩余 = 1
递归之后 => [1, 1],剩余 = 1
递归之前 => [1, 1, 6],剩余 = 0
递归之后 => [1, 1],剩余 = 0
递归之后 => [1],剩余 = 6
递归之前 => [1, 2],剩余 = 5
递归之前 => [1, 2, 5],剩余 = 0
递归之后 => [1, 2],剩余 = 0
递归之后 => [1],剩余 = 5
递归之前 => [1, 5],剩余 = 2
递归之后 => [1],剩余 = 2
递归之前 => [1, 6],剩余 = 1
递归之后 => [1],剩余 = 1
递归之前 => [1, 7],剩余 = 0
递归之后 => [1],剩余 = 0
递归之后 => [],剩余 = 7
递归之前 => [2],剩余 = 6
递归之前 => [2, 5],剩余 = 1
递归之后 => [2],剩余 = 1
递归之前 => [2, 6],剩余 = 0
递归之后 => [2],剩余 = 0
递归之后 => [],剩余 = 6
递归之前 => [5],剩余 = 3
递归之后 => [],剩余 = 3
递归之前 => [6],剩余 = 2
递归之后 => [],剩余 = 2
递归之前 => [7],剩余 = 1
递归之后 => [],剩余 = 1
输出 => [[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]]
  • C++ 示例:
// author:rmokerone
#include <iostream>
#include <vector>

using namespace std;

class Solution 

private:
    vector<int> candidates;
    vector<vector<int>> res;
    vector<int> path;
public:
    void DFS(int start, int target) 
        if (target == 0) 
            res.push_back(path);
            return;
        

        for (int i = start; i < candidates.size() && target - candidates[i] >= 0; i++) 
            if (i > start && candidates[i] == candidates[i - 1])
                continue;
            path.push_back(candidates[i]);
            // 元素不可重复利用,使用下一个即i+1
            DFS(i + 1, target - candidates[i]);
            path.pop_back();
        
    

    vector<vector<int>> combinationSum2(vector<int> &candidates, int target) 
        sort(candidates.begin(), candidates.end());
        this->candidates = candidates;
        DFS(0, target);
        return res;
    
;

② 递归 + 回溯(LeetCode 官方解法)

  • 由于需要求出所有和为 target 的组合,并且每个数只能使用一次,因此可以使用递归 + 回溯的方法来解决这个问题。
  • 用 dfs(pos,rest) 表示递归的函数,其中 pos 表示当前递归到了数组 candidates 中的第 pos 个数,而 rest 表示还需要选择和为 rest 的数放入列表作为一个组合;
  • 对于当前的第 pos 个数,有两种方法:选或者不选。如果选了这个数,那么调用 dfs(pos+1,rest−candidates[pos]) 进行递归,注意这里必须满足 rest≥candidates[pos];如果不选这个数,那么调用 dfs(pos+1,rest) 进行递归;
  • 在某次递归开始前,如果 rest 的值为 0,说明找到了一个和为 target 的组合,将其放入答案中。每次调用递归函数前,如果选了那个数,就需要将其放入列表的末尾,该列表中存储了我们选的所有数。在回溯时,如果我们选了那个数,就要将其从列表的末尾删除。
  • 上述算法就是一个标准的递归 + 回溯算法,但是它并不适用于本题。这是因为题目描述中规定了解集不能包含重复的组合,而上述的算法中并没有去除重复的组合,例如当 candidates=[2,2],target=2 时,上述算法会将列表 [2] 放入答案两次。
  • 因此,需要改进上述算法,在求出组合的过程中就进行去重的操作,可以考虑将相同的数放在一起进行处理,也就是说,如果数 x 出现了 y 次,那么在递归时一次性地处理它们,即分别调用选择 0,1,⋯,y 次 x 的递归函数,这样就不会得到重复的组合。具体地:
    • 使用一个哈希映射(HashMap)统计数组 candidates 中每个数出现的次数。在统计完成之后,将结果放入一个列表 freq 中,方便后续的递归使用。列表 freq 的长度即为数组 candidates 中不同数的个数,其中的每一项对应着哈希映射中的一个键值对,即某个数以及它出现的次数。
    • 在递归时,对于当前的第 pos 个数,它的值为 freq[pos][0],出现的次数为 freq[pos][1],那么可以调用:

    • 即选择了这个数 i 次,这里 i 不能大于这个数出现的次数,并且 i×freq[pos][0] 也不能大于 rest。同时,需要将 i 个 freq[pos][0] 放入列表中。
  • 这样一来,就可以不重复地枚举所有的组合了,我们还可以进行什么优化(剪枝)呢?一种比较常用的优化方法是,将 freq 根据数从小到大排序,这样我们在递归时会先选择小的数,再选择大的数。这样做的好处是,当我们递归到 dfs(pos,rest) 时,如果 freq[pos][0] 已经大于 rest,那么后面还没有递归到的数也都大于 rest,这就说明不可能再选择若干个和为 rest 的数放入列表了。此时,就可以直接回溯。
  • C++ 示例:
class Solution 
private:
    vector<pair<int, int>> freq;
    vector<vector<int>> ans;
    vector<int> sequence;

public:
    void dfs(int pos, int rest) 
        if (rest == 0) 
            ans.push_back(sequence);
            return;
        
        if (pos == freq.size() || rest < freq[pos].first) 
            return;
        

        dfs(pos + 1, rest);

        int most = min(rest / freq[pos].first, freq[pos].second);
        for (int i = 1; i <= most; ++i) 
            sequence.push_back(freq[pos].first);
            dfs(pos + 1, rest - i * freq[pos].first);
        
        for (int i = 1; i <= most; ++i) 
            sequence.pop_back();
        
    

    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) 
        sort(candidates.begin(), candidates.end());
        for (int num: candidates) 
            if (freq.empty() || num != freq.back().first) 
                freq.emplace_back(num, 1);
             else 
                ++freq.back().second;
            
        
        dfs(0, target);
        return ans;
    
;
  • Java 示例:
class Solution 
    List<int[]> freq = new ArrayList<int[]>();
    List<List<Integer>> ans = new ArrayList<List<Integer>>();
    List<Integer> sequence = new ArrayList<Integer>();

    public List<List<Integer>> combinationSum2(int[] candidates, int target) 
        Arrays.sort(candidates);
        for (int num : candidates) 
            int size = freq.size();
            if (freq.isEmpty() || num != freq.get(size - 1)[0]) 
                freq.add(new int[]num, 1);
             else 
                ++freq.get(size - 1)[1];
            
        
        dfs(0, target);
        return ans;
    

    public void dfs(int pos, int rest) 
        if (rest == 0) 
            ans.add(new ArrayList<Integer>(sequence));
            return;
        
        if (pos == freq.size() || rest < freq.get(pos)[0]) 
            return;
        

        dfs(pos + 1, rest);

        int most = Math.min(rest / freq.get(pos)[0], freq.get(pos)[1]);
        for (int i = 1; i <= most; ++i) 
            sequence.add(freq.get(pos)[0]);
            dfs(pos + 1, rest - i * freq.get(pos)[0]);
        
        for (int i = 1; i <= most; ++i) 

以上是关于数据结构与算法之深入解析“组合总和II”的求解思路与算法示例的主要内容,如果未能解决你的问题,请参考以下文章

数据结构与算法之深入解析“股票的最大利润”的求解思路与算法示例

数据结构与算法之深入解析“安装栅栏”的求解思路与算法示例

数据结构与算法之深入解析“最长连续序列”的求解思路与算法示例

数据结构与算法之深入解析“路径总和”的求解思路与算法示例

数据结构与算法之深入解析“斐波那契数”的求解思路与算法示例

数据结构与算法之深入解析“连续整数求和”的求解思路与算法示例