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

Posted Serendipity·y

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构与算法之深入解析“组合总和”的求解思路与算法示例相关的知识,希望对你有一定的参考价值。

一、题目要求

  • 给你一个无重复元素的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的所有不同组合 ,并以列表形式返回,可以按任意顺序返回这些组合。
  • candidates 中的同一个数字可以无限制重复被选取,如果至少一个数字的被选数量不同,则两种组合是不同的。
  • 对于给定的输入,保证和为 target 的不同组合数少于 150 个。
  • 示例 1:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
23 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。
  • 示例 2:
输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]
  • 示例 3:
输入: candidates = [2], target = 1
输出: []
  • 示例 4:
输入: candidates = [1], target = 1
输出: [[1]]
  • 示例 5:
输入: candidates = [1], target = 2
输出: [[1,1]]
  • 提示:
    • 1 <= candidates.length <= 30
    • 1 <= candidates[i] <= 200
    • candidate 中的每个元素都 互不相同
    • 1 <= target <= 500

二、求解算法

① 递归

  • 递归枚举,枚举每个数字可以选多少次。
  • 递归过程如下:
    • 遍历数组中的每一个数字;
    • 递归枚举每一个数字可以选多少次,递归过程中维护一个 target 变量。如果当前数字小于等于 target,就将其加入路径数组 path 中,相应的 target 减去当前数字的值。也就是说,每选一个分支,就减去所选分支的值;
    • 当target == 0时,表示该选择方案是合法的,记录该方案,将其加入 res 数组中。
  • 递归树如下,以 candidates = [2,3,6,7], target = 7 为例:

  • 最终答案为:[[7],[2,2,3]] ,但是可以发现 [[2, 2, 3], [2, 3, 2], [3, 2, 2] 方案重复。为了避免搜索过程中的重复方案,要去定义一个搜索起点,已经考虑过的数,以后的搜索中就不能出现,让我们的每次搜索都从当前起点往后搜索(包含当前起点),直到搜索到数组末尾,这样人为规定了一个搜索顺序,就可以避免重复方案。
  • 如下图所示,处于黄色虚线矩形内的分支都不再去搜索,这样我们就完成去重操作:

  • 递归函数设计:void dfs(vector&c,int u ,int target),变量 u 表示当前枚举的数字下标,target 是递归过程中维护的目标数。
  • 递归边界:
    • if(target < 0) ,表示当前方案不合法,返回上一层;
    • if(target == 0),方案合法,记录该方案。
  • C++ 示例:
class Solution 
public:

    vector<vector<int>>res; // 记录答案
    vector<int>path;        // 记录路径

    vector<vector<int>> combinationSum(vector<int>& candidates, int target) 
        dfs(candidates,0,target);
        return res;
    

    void dfs(vector<int>&c,int u ,int target)
    
        if(target < 0) return ;   // 递归边界
        if(target == 0) 
            res.push_back(path);  // 记录答案
            return ;
        
        for(int i = u; i < c.size(); i++)
            if( c[i] <= target) 
                path.push_back(c[i]);   // 加入路径数组中
                dfs(c,i,target -  c[i]);// 因为可以重复使用,所以还是i
                path.pop_back();        // 回溯,恢复现场
            
        
    
;
  • Java 示例:
class Solution 
    List<List<Integer>> res = new ArrayList<>(); // 记录答案
    List<Integer> path = new ArrayList<>();      // 记录路径

    public List<List<Integer>> combinationSum(int[] candidates, int target) 
        dfs(candidates,0, target);
        return res;
    
    public void dfs(int[] c, int u, int target) 
        if(target < 0) return ;
        if(target == 0) 
            res.add(new ArrayList(path));
            return ;
        
        for(int i = u; i < c.length; i++) 
            if( c[i] <= target) 
                path.add(c[i]);
                dfs(c,i,target -  c[i]);    // 因为可以重复使用,所以还是i
                path.remove(path.size()-1); // 回溯,恢复现场
            
        
    

② 搜索回溯

  • 定义递归函数 dfs(target, combine, idx) 表示当前在 candidates 数组的第 idx 位,还剩 target 要组合,已经组合的列表为 combine,递归的终止条件为 target <= 0 或者 candidates 数组被全部用完。那么在当前的函数中,每次我们可以选择跳过不用第 idx 个数,即执行 dfs(target, combine, idx + 1)。也可以选择使用第 idx 个数,即执行 dfs(target - candidates[idx], combine, idx),注意到每个数字可以被无限制重复选取,因此搜索的下标仍为 idx。
  • 更形象化地说,如果我们将整个搜索过程用一个树来表达,即如下图呈现,每次的搜索都会延伸出两个分叉,直到递归的终止条件,这样就能不重复且不遗漏地找到所有可行解:

  • 当然,搜索回溯的过程一定存在一些优秀的剪枝方法来使得程序运行得更快,而这里只给出了最朴素不含剪枝的写法,因此欢迎各位读者在评论区分享自己的见解。
  • C++ 示例:
class Solution 
public:
    void dfs(vector<int>& candidates, int target, vector<vector<int>>& ans, vector<int>& combine, int idx) 
        if (idx == candidates.size()) 
            return;
        
        if (target == 0) 
            ans.emplace_back(combine);
            return;
        
        // 直接跳过
        dfs(candidates, target, ans, combine, idx + 1);
        // 选择当前数
        if (target - candidates[idx] >= 0) 
            combine.emplace_back(candidates[idx]);
            dfs(candidates, target - candidates[idx], ans, combine, idx);
            combine.pop_back();
        
    

    vector<vector<int>> combinationSum(vector<int>& candidates, int target) 
        vector<vector<int>> ans;
        vector<int> combine;
        dfs(candidates, target, ans, combine, 0);
        return ans;
    
;
  • Java 示例:
class Solution 
    public List<List<Integer>> combinationSum(int[] candidates, int target) 
        List<List<Integer>> ans = new ArrayList<List<Integer>>();
        List<Integer> combine = new ArrayList<Integer>();
        dfs(candidates, target, ans, combine, 0);
        return ans;
    

    public void dfs(int[] candidates, int target, List<List<Integer>> ans, List<Integer> combine, int idx) 
        if (idx == candidates.length) 
            return;
        
        if (target == 0) 
            ans.add(new ArrayList<Integer>(combine));
            return;
        
        // 直接跳过
        dfs(candidates, target, ans, combine, idx + 1);
        // 选择当前数
        if (target - candidates[idx] >= 0) 
            combine.add(candidates[idx]);
            dfs(candidates, target - candidates[idx], ans, combine, idx);
            combine.remove(combine.size() - 1);
        
    

  • 复杂度分析:
    • 时间复杂度:O(S),其中 S 为所有可行解的长度之和,从分析给出的搜索树可以看出时间复杂度取决于搜索树所有叶子节点的深度之和,即所有可行解的长度之和。在这题中,我们很难给出一个比较紧的上界,知道 O(n×2n) 是一个比较松的上界,即在这份代码中,n 个位置每次考虑选或者不选,如果符合条件,就加入答案的时间代价。但是实际运行的时候,因为不可能所有的解都满足条件,递归的时候我们还会用 target - candidates[idx] >= 0 进行剪枝,所以实际运行情况是远远小于这个上界的。
  • 空间复杂度:O(target),除答案数组外,空间复杂度取决于递归的栈深度,在最差情况下需要递归 O(target) 层。

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

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

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

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

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

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

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