【算法-LeetCode】39. 组合总和(回溯;递归)

本文详细介绍了如何使用回溯算法解决LeetCode上的组合总和问题,讨论了去重逻辑和优化策略,包括对输入数组进行排序以及正确处理回溯过程中的起点变量,最终实现正确求解所有目标和的唯一组合。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

39. 组合总和 - 力扣(LeetCode)

文章起笔:2021年10月27日15:13:21

问题描述及示例

给定一个无重复元素的正整数数组 candidates 和一个正整数 target ,找出 candidates 中所有可以使数字和为目标数 target 的唯一组合。

candidates 中的数字可以无限制重复被选取。如果至少一个所选数字数量不同,则两种组合是唯一的。

对于给定的输入,保证和为 target 的唯一组合数少于 150 个。

示例 1:
输入: candidates = [2,3,6,7], target = 7
输出: [[7],[2,2,3]]

示例 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

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/combination-sum
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

我的题解(回溯)

之前刚做过回溯算法(或者说是排列组合问题)的相关总结,所以这道题还是比较容易就可以看出来是要使用回溯算法的。可以先参阅下方博客:

参考:【算法-LeetCode】46. 全排列(回溯算法初体验)_赖念安的博客-优快云博客_leetcode46全排列
参考:【算法-LeetCode】47. 全排列 II(回溯;有重复元素的全排列)_赖念安的博客-优快云博客
参考:【算法-剑指 Offer】38. 字符串的排列(回溯;有重复元素的全排列)_赖念安的博客-优快云博客

当然,在『【算法-LeetCode】46. 全排列(回溯算法初体验)』中也有我目前做的其他回溯相关的题目可供参考。

成功前的尝试

本题可以比较明显地看出是回溯算法在排列组合问题中的应用。核心思路在上面的博客中已经有详细的说明了,这里不再赘述。

有了前面几道题的经验,我比较快地就写出了下面的代码:

/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum = function(candidates, target) {
  let results = [];
  let temp = [];
  backtracking(candidates, 0);
  return results;

  function backtracking(nums, sum) {
    if(sum === target) {
      results.push([...temp]);
      return;
    }
    for(let i = 0; i < nums.length; i++) {
      if(sum > target) {
        continue;
      }
      temp.push(nums[i]);
      backtracking(nums, sum + nums[i]);
      temp.pop();
    }
  }
};


已完成,执行用时:68 ms
输入:
[2,3,4,6,7], 7
[2,3,5], 8
[2], 1
[1], 1
[1], 2

输出:
[[2,2,3],[2,3,2],[3,2,2],[3,4],[4,3],[7]]
[[2,2,2,2],[2,3,3],[3,2,3],[3,3,2],[3,5],[5,3]]
[]
[[1]]
[[1,1]]

预期结果
[[2,2,3],[3,4],[7]]
[[2,2,2,2],[2,3,3],[3,5]]
[]
[[1]]
[[1,1]]

我心里明白这个答案肯定是错的,因为上面的程序没有进行结果的去重操作,仔细观察上面的用例就可以发现。

比如当输入 [2,3,4,6,7], 7 时,输出的结果中,有部分的结果其实是重复的。

所以我们只要在上面那个程序的基础上增加去重的逻辑即可。而这个去重操作的逻辑也正是本题与此前的几个题目最大区别,甚至可以说是唯一的区别。

回溯

枝剪的逻辑

注意,本题中的元素虽说都是唯一的,但是元素却是可以重复取用的,所以本题和之前的『【算法-LeetCode】47. 全排列 II(回溯;有重复元素的全排列)』这道题一样需要考虑去重问题。

同样地,本题其实也是针对『【算法-LeetCode】46. 全排列(回溯算法初体验)』的基础上对某些判断条件做一点调整,而这个调整就是上面说到的关键的去重操作。

一开始我并没有找到合适解决方案,我尝试过加上 used 数组来标识元素的使用情况,并加上 count 数组来记录元素被使用过的累积次数。但是最后都被自己推翻了。

最后我实在是没有思路了,于是就去题解区看了看别人的思路。看到了题友【@liweiwei1419】的去重思路:

参考:回溯算法 + 剪枝(回溯经典例题详解) - 组合总和 - 力扣(LeetCode)

感谢博主分享!

大致看了一下,发现他是用一个 begin 变量来标识当前回溯遍历的过程中该从 nums 数组中的哪个元素开始遍历。我茅塞顿开,发现这种解法确实是比较巧妙地完成了去重的逻辑。

假设我取 [2,3,4,6,7] 中的第一个元素 2 作为结果的第一位,在通过递归找到以 2 开头的所有符合条件的结果后,下一次递归遍历时,我就以 [2,3,4,6,7] 中的第二个元素 3 作为结果的第一位,并且此后的递归就直接从 3 这个位置开始取元素,而不用考虑再取 3 之前的元素了。因为此前在探索以 2 为开头的可能的结果时,由于我们是从后往前遍历 nums 的,所以那些符合条件的结果里必然已经有一个已经包含了 3 这个元素。此时我们直接从 3 及其以后的元素探索可能的结果的话就可以避免找到与之前的某个结果重复的结果了。

不考虑去重的话,整个搜寻过程可以在下面这棵多叉树里体现:

在这里插入图片描述

注意,标★的即为可能的结果,但是这些结果没有去重。

而如果加上遍历时的起点限制,那么就可以达到去重效果。如上图红色标注的部分。

但是当我在递归遍历的逻辑里加入了相应功能的 start 变量后,却还是没法通过全部用例:

var combinationSum = function(candidates, target) {
  let results = [];
  let temp = [];
  let start = 0;
  backtracking(candidates, 0);
  return results;

  function backtracking(nums, sum) {
    if(sum === target) {
      results.push([...temp]);
      // start的更新逻辑错了,我想的是如果获取了某个结果,那么下次
      // 就从上一个结果的末尾元素在 nums 中的下标开始往后搜索,这样就可能漏掉一些结果
      start = nums.indexOf(temp[temp.length - 1]);
      return;
    }
    for(let i = start; i < nums.length; i++) {
      if(sum > target) {
        continue;
      }
      temp.push(nums[i]);
      backtracking(nums, sum + nums[i]);
      temp.pop();
    }
  }
};

在这里插入图片描述

执行结果:解答错误
通过测试用例:57 / 170
输入:[2,7,6,3,5,1], 9
输出:
[[2,2,2,2,1],[2,2,2,3],[2,2,2,1,1,1],[2,2,3,1,1],[2,2,5],[2,2,1,1,1,1,1],[2,7],[2,6,1],[2,3,1,1,1,1],[2,5,1,1],[2,1,1,1,1,1,1,1],[7,1,1],[6,1,1,1],[3,1,1,1,1,1,1],[5,1,1,1,1],[1,1,1,1,1,1,1,1,1]]
预期结果:
[[1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,2],[1,1,1,1,1,1,3],[1,1,1,1,1,2,2],[1,1,1,1,2,3],[1,1,1,1,5],[1,1,1,2,2,2],[1,1,1,3,3],[1,1,1,6],[1,1,2,2,3],[1,1,2,5],[1,1,7],[1,2,2,2,2],[1,2,3,3],[1,2,6],[1,3,5],[2,2,2,3],[2,2,5],[2,7],[3,3,3],[3,6]]

明明现成提供的几个用例都能通过的呀,为什么 [2,7,6,3,5,1], 9 这个用例就无法通过呢?

仔细观察之后,我发现自己的 start 更新逻辑写错了,那样会导致漏掉某些结果。具体过程不便表述,可以在开发者工具中调试观察。

于是我就将 start 作为递归函数的一个参数一层一层传递下去。但是测试之后发现上面那个用例还是无法通过……

已完成执行用时:88 ms
输入[2,7,6,3,5,1], 9
输出
[[2,2,2,2,1],[2,2,2,3],[2,2,2,1,1,1],[2,2,3,1,1],[2,2,5],[2,2,1,1,1,1,1],[2,7],[2,6,1],[2,3,3,1],[2,3,1,1,1,1],[2,5,1,1],[2,1,1,1,1,1,1,1],[7,1,1],[6,3],[6,1,1,1],[3,3,3],[3,3,1,1,1],[3,5,1],[3,1,1,1,1,1,1],[5,1,1,1,1],[1,1,1,1,1,1,1,1,1]]
预期结果
[[1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,2],[1,1,1,1,1,1,3],[1,1,1,1,1,2,2],[1,1,1,1,2,3],[1,1,1,1,5],[1,1,1,2,2,2],[1,1,1,3,3],[1,1,1,6],[1,1,2,2,3],[1,1,2,5],[1,1,7],[1,2,2,2,2],[1,2,3,3],[1,2,6],[1,3,5],[2,2,2,3],[2,2,5],[2,7],[3,3,3],[3,6]]

经过仔细观察,我发现预期结果都是按升序排列的,而我的输出结果中确有部分结果是乱序的,于是我尝试先将 nums 进行排序操作(升序和降序都可以),结果发现终于通过了!

仔细想了想,我的这种做法对 nums 种的元素的顺序有要求,应该是因为我的递归终止条件以及在 for 循环中的那个 if 判断是依赖于 temp 中的累积求和结果的,所以如果元素没有顺序,那么可能会造成有些符合条件的结果会在这些判断中被跳过(也就是被意外地“枝剪”掉了)。

最终题解

几经波折,总算是通过了提交。下面针对一些关键的地方做一点注释:

/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum = function(candidates, target) {
  // 必须先对candidates数组进行排序操作,升序或降序都可以,
  // 这将保证在后续探索结果的时候不会因为sum累积和的问题而跳过某些答案
  candidates.sort((a, b) => a - b);
  let results = [];
  let temp = [];
  backtracking(candidates, 0, 0);
  return results;

  // backtracking封装回溯递归逻辑,其中的nums参数就是待遍历数组candidates,
  // sum用于累积temp中的元素和,start用于指示当前层递归该从哪个元素开始遍历
  function backtracking(nums, sum, start) {
    // 如果累积和恰好等于target,就意味着获得了一个结果,将其存入results并结束后续递归
    if(sum === target) {
      results.push([...temp]);
      return;
    }
    // 开始遍历nums数组,注意开始下标为start
    for(let i = start; i < nums.length; i++) {
      // 如果发现temp中的元素累积和已经超过了target,那就不要再往后递归探索了
      // 这也算是一种枝剪操作,如果不加上,那么程序将一直递归下去,直到溢出
      if(sum > target) {
        continue;
      }
      temp.push(nums[i]);
      // 因为我们将当前遍历的元素加入到了temp中,所以需要更新累积和sum的值,
      // 同时也要告诉下一层递归,要从当前下标 i 开始往后遍历,i之前的元素就不要再取了
      backtracking(nums, sum + nums[i], i);
      temp.pop();
    }
  }
};

提交记录
执行结果:通过
170 / 170 个通过测试用例
执行用时:76 ms, 在所有 JavaScript 提交中击败了93.96%的用户
内存消耗:40 MB, 在所有 JavaScript 提交中击败了87.61%的用户
时间:2021/10/27 16:37	

因为本题中的元素可以重复取用,所以之前做的那几道题中用到的 used 在本题中就不大适合了。

但是本题的核心思路还是之前总结的那套回溯思想,几个题目对照起来思考可以获得更深的体会。

官方题解

更新:2021年7月29日18:43:21

因为我考虑到著作权归属问题,所以【官方题解】部分我不再粘贴具体的代码了,可到下方的链接中查看。

【更新结束】

更新:2021年10月27日15:14:05

参考:组合总和 - 组合总和 - 力扣(LeetCode)

【更新结束】

有关参考

更新:2021年10月27日16:45:17
参考:【算法-LeetCode】46. 全排列(回溯算法初体验)_赖念安的博客-优快云博客_leetcode46全排列
参考:【算法-LeetCode】47. 全排列 II(回溯;有重复元素的全排列)_赖念安的博客-优快云博客
参考:【算法-剑指 Offer】38. 字符串的排列(回溯;有重复元素的全排列)_赖念安的博客-优快云博客
参考:回溯算法 + 剪枝(回溯经典例题详解) - 组合总和 - 力扣(LeetCode)
更新:2021年10月27日15:13:41
参考:如何判断2个数组相等_前端小胖鼠-优快云博客_判断数组相等

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值