39. 组合总和
做了46题之后,大概明白了回溯搜索是怎么回事,紧接着我就回过头来做了这道题(怎么感觉有点回溯那味儿了?哈哈哈可能这就是缘分吧),结果一把过!我分析了一下,这道题的基本框架跟46-全排列差不多,都要搜索所有的情况,但是有一些不一样的地方:
1. 终止的条件不一样:46题中要把给定数组中的数字都用一遍,所以终止条件是一个答案(path)中的元素个数等于给定数组的元素个数(length);而在这里,我们要找到一组目标元素,使得加起来等于目标值,所以最后的终止条件应该跟目标值target有关;
2. 递归部分不一样:这里的元素可以重复使用,所以for循环的部分肯定不一样。
但可以肯定的是,肯定有回溯的过程。
总的思路: 回溯搜索 + 无重复添加(不会反向添加)
1. 终止条件的确定
由于是求和,且每次选取一个数,所以我就想到了,每次将target的值减去选取的nums[i]的值,直到最后这个target小于等于0了,表明再也找不到数了,因此递归终止。
在递归终止的时候,也比较容易想到两种不同的情况。不像46题中那样,条件终止时,找到的集合肯定是满足条件的;在本题中,找到的可能不满足条件,因为全部的加起来小于了目标值target,因此需要判断target的最后值是否为0,如果是0,那么表示加起来等于原来的目标值,这种情况才应该加入最后的结果中;否则,不应该加入。但是终止条件里面,最后都应该返回了。这部分的代码比较简单,也很容易想到,如下:
if (target <= 0) {
if (target == 0) { // 只有目标值最后为0的时候,才加入结果集合
res.emplace_back(path);
}
return; // 终止了就都要返回
}
2. 回溯函数参数的确定以及避免重复
最开始没考虑到重复的问题,我写的backtrack函数如下:
void backtrack(vector<vector<int>>& res, vector<int>& nums, int target) {
if (target <= 0) {
if (target == 0) {
res.emplace_back(path);
}
return;
}
for (int i = 0; i < nums.size(); i++) {
path.push_back(nums[i]);
backtrack(res, nums, target - nums[i]); // 递归的是target减去当前选取的元素的值
path.pop_back();
}
}
这样做,跟46不一样。首先,没有用标记数组判断,因为每个答案中可能包含重复的元素,因此不能用used[i]来进行判断和处理;其次,没有一个显示的回溯过程,其实是把回溯过程写到target-nums[i]里面了,这样做并没有改变target的值,不会影响下一次第一个取nums[i+1];这样做相当于:
for (int i = 0; i < nums.size(); i++) {
path.push_back(nums[i]);
target -= nums[i]);
backtrack(res, nums, target); // 递归的是target减去当前选取的元素的值
path.pop_back();
target += nums[i]);
}
但是我们可以看到,pop_back()依然存在,也是回溯必不可少的一部分。这样做可以得到所有的答案,但是会有重复的(错误的答案)。比如,nums = [2, 3, 5], target = 8;
这个代码的输出结果有:[2, 2, 2, 2], [2, 3, 3], [3, 2, 3], [3, 3, 2], [3, 5], [5, 3]。其中[2, 3, 3], [3, 2, 3], [3, 3, 2]重复, [3, 5], [5, 3]也重复。经过思考,觉得应该是每次选取一个数nums[i]的时候,不应该再考虑它前面的数(索引小于i的那些数),比如在选取5的时候,就不应该考虑它前面的2和3。所以我想到for那里应该不是每次从0开始,因为这样每次都会从最开始选取,因此我想到了添加一个int索引,用来记录当前位置的方式。我在参数列表中添加了一个参数d,其初始值设为0,同时修改for循环的起始从d开始而不是0。同时,递归的时候,让d的递增,表示选取的位置向后移动了。(d++也可以用i代替:backtrack(res, nums, target - nums[i], i); 但似乎要慢点)。
void backtrack(vector<vector<int>>& res, vector<int>& nums, int target, int d) {
if (target <= 0) {
if (target == 0) {
res.emplace_back(path);
}
return;
}
for (int i = d; i < nums.size(); i++) {
path.push_back(nums[i]);
backtrack(res, nums, target - nums[i], d++);
path.pop_back();
}
}
3. 完整的代码
最后整合一下得到了完整的代码,如下。
class Solution {
public:
vector<int> path;
vector<vector<int>> res;
void backtrack(vector<vector<int>>& res, vector<int>& nums, int target, int d) {
if (target <= 0) {
if (target == 0) {
res.emplace_back(path);
}
return;
}
for (int i = d; i < nums.size(); i++) {
path.push_back(nums[i]);
backtrack(res, nums, target - nums[i], d++);
path.pop_back();
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
res.clear();
path.clear();
backtrack(res, candidates, target, 0);
return res;
}
};
4. 剪枝操作
为了修剪不必要的操作,可以先对数组进行排序。为什么要从小到大排序?因为如果排了序,可以在循环的时候就判断,如果当前的都不满足条件(使得target为负了),那么后面的肯定更不复合条件了,因为后面的比当前的还大。当前选的这个加起来都超过了target,那么后面的肯定不需要考虑了,因此直接break掉,实现剪枝。如果不排序,那么就不行,因为后面可能有比当前值小的,说不定加起来就满足情况了,因此必须排序!!
class Solution {
public:
vector<int> path;
vector<vector<int>> res;
void backtrack(vector<vector<int>>& res, vector<int>& nums, int target, int d) {
if (target == 0) { // 由于进入更深层的时候,小于 0 的部分被剪枝,因此递归终止条件值只判断等于 0 的情况
res.emplace_back(path);
return;
}
for (int i = d; i < nums.size(); i++) {
if (target - nums[i] < 0) { // 重点理解这里剪枝。直接在这里判断一手,如果当前的使得target为负了,后面的都不需要考虑了。
break; // 直接跳出来!
}
path.push_back(nums[i]);
backtrack(res, nums, target - nums[i], d++);
path.pop_back();
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
res.clear();
path.clear();
sort(candidates.begin(), candidates.end()); // 首先对数组排序
backtrack(res, candidates, target, 0);
return res;
}
};
? 什么时候使用 used 数组,什么时候使用d(begin)变量
这里从LeetCode的一个解析中看到,感觉说得很有道理。简单总结一下:
- 排列问题,讲究顺序(即 [2, 2, 3] 与 [2, 3, 2] 视为不同列表时),需要记录哪些数字已经使用过,此时用 used 数组;
- 组合问题,不讲究顺序(即 [2, 2, 3] 与 [2, 3, 2] 视为相同列表时),需要按照某种顺序搜索,此时使用 begin 变量。
总结
总的来说,此题比46略微复杂一点,关键是如何避免重复。