39.组合总和
给你一个 无重复元素 的整数数组 candidates
和一个目标整数 target
,找出 candidates
中可以使数字和为目标数 target
的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates
中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target
的不同组合数少于 150
个。
示例 1:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,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
输出: []
提示:
1 <= candidates.length <= 30
2 <= candidates[i] <= 40
candidates
的所有元素 互不相同1 <= target <= 40
Related Topics
-
数组
-
回溯
思路
题目中的无限制重复被选取,吓得我赶紧想想 出现0 可咋办,然后看到下面提示:1 <= candidates[i] <= 200,我就放心了。
本题和77.组合 (opens new window),216.组合总和III (opens new window)的区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
本题搜索的过程抽象成树形结构如下:
注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
而在77.组合 (opens new window)和216.组合总和III (opens new window)中都可以知道要递归K层,因为要取k个元素的组合。
回溯三部曲
- 递归函数参数
这里依然是定义两个全局变量,二维数组result存放结果集,数组path存放符合条件的结果。(这两个变量可以作为函数参数传入)
首先是题目中给出的参数,集合candidates, 和目标值target。
此外我还定义了int型的sum变量来统计单一结果path里的总和,其实这个sum也可以不用,用target做相应的减法就可以了,最后如何target==0就说明找到符合的结果了,但为了代码逻辑清晰,我依然用了sum。
本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?
我举过例子,如果是一个集合来求组合的话,就需要startIndex,例如:77.组合 (opens new window),216.组合总和III (opens new window)。
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:17.电话号码的字母组合(opens new window)
注意以上我只是说求组合的情况,如果是排列问题,又是另一套分析的套路,后面我在讲解排列的时候会重点介绍。
代码如下:
private List<List<Integer>> res = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
/**
* @Description
* @Param candidates 条件数组
* @Param target 目标值
* @Param sum 当前和
* @Param startIndex 起始数字
* @Return
* @Author 君君
* @Date 2024/7/14 2:06
*/
private void travel(int[] candidates, int target, int sum, int startIndex) {
- 递归终止条件
在如下树形结构中:
从叶子节点可以清晰看到,终止只有两种情况,sum大于target和sum等于target。
sum等于target的时候,需要收集结果,代码如下:
//终止条件
if (sum == target) {
res.add(new ArrayList<>(path));
} else if (sum > target) {
return;
}
- 单层搜索的逻辑
单层for循环依然是从startIndex开始,搜索candidates集合。
注意本题和77.组合 (opens new window)、216.组合总和III (opens new window)的一个区别是:本题元素为可重复选取的。
如何重复选取呢,看代码,注释部分:
for (int i = startIndex; i < candidates.length; i++) {
sum = sum + candidates[i];
path.add(candidates[i]);
//在这里传入当前在第几个数字处,就是已经遍历过2了,所以将2跳过
travel(candidates, target, sum, i);
//回溯
sum = sum - candidates[i];
//回溯
path.remove(path.size() - 1);
}
完整代码
public List<List<Integer>> combinationSum(int[] candidates, int target) {
travel(candidates, target, 0, 0);
return res;
}
private List<List<Integer>> res = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
/**
* @Description
* @Param candidates 条件数组
* @Param target 目标值
* @Param sum 当前和
* @Param startIndex 起始数字
* @Return
* @Author 君君
* @Date 2024/7/14 2:06
*/
private void travel(int[] candidates, int target, int sum, int startIndex) {
//终止条件
if (sum == target) {
res.add(new ArrayList<>(path));
} else if (sum > target) {
return;
} else {
for (int i = startIndex; i < candidates.length; i++) {
sum = sum + candidates[i];
path.add(candidates[i]);
//在这里传入当前在第几个数字处,就是已经遍历过2了,所以将2跳过
travel(candidates, target, sum, i);
sum = sum - candidates[i];
path.remove(path.size() - 1);
}
}
}
剪枝优化
在这个树形结构中:
以及上面的版本一的代码大家可以看到,对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。
其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。
那么可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。
如图:
for循环剪枝代码如下:
for (int i = startIndex; i < candidates.length && sum + candidates[i] <= target; i++)
或者
for (int i = startIndex; i < candidates.length ; i++) {
if(sum+candidates[i] > target)
{
break;
}
sum = sum + candidates[i];
path.add(candidates[i]);
//在这里传入当前在第几个数字处,就是已经遍历过2了,所以将2跳过
travel(candidates, target, sum, i);
sum = sum - candidates[i];
path.remove(path.size() - 1);
}
40.组合总和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
Related Topics
-
数组
-
回溯
思路
这道题目和39.组合总和 (opens new window)如下区别:
- 本题candidates 中的每个数字在每个组合中只能使用一次。
- 本题数组candidates的元素是有重复的,而39.组合总和 (opens new window)是无重复元素的数组candidates
最后本题和39.组合总和 (opens new window)要求一样,解集不能包含重复的组合。
本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合。
一些同学可能想了:我把所有组合求出来,再用set或者map去重,这么做很容易超时!
所以要在搜索的过程中就去掉重复组合。
很多同学在去重的问题上想不明白,其实很多题解也没有讲清楚,反正代码是能过的,感觉是那么回事,稀里糊涂的先把题目过了。
这个去重为什么很难理解呢,所谓去重,其实就是使用过的元素不能重复选取。 这么一说好像很简单!
都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
为了理解去重我们来举一个例子,candidates = [1, 1, 2], target = 3,(方便起见candidates已经排序了)
强调一下,树层去重的话,需要对数组排序!
选择过程树形结构如图所示:
选择过程树形结构如图所示:
可以看到图中,每个节点相对于 39.组合总和 (opens new window)我多加了used数组,这个used数组下面会重点介绍。
回溯三部曲
- 递归函数参数
与39.组合总和 (opens new window)套路相同,此题还需要加一个bool型数组used,用来记录同一树枝上的元素是否使用过。
这个集合去重的重任就是used来完成的。
代码如下:
- 递归终止条件
与39.组合总和 (opens new window)相同,终止条件为 sum > target
和 sum == target
。
代码如下:
sum > target
这个条件其实可以省略,因为在递归单层遍历的时候,会有剪枝的操作,下面会介绍到。
- 单层搜索的逻辑
这里与39.组合总和 (opens new window)最大的不同就是要去重了。
前面我们提到:要去重的是“同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。
如果candidates[i] == candidates[i - 1]
并且 used[i - 1] == false
,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]。
此时for循环里就应该做continue的操作。
这块比较抽象,如图:
我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:
- used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- used[i - 1] == false,说明同一树层candidates[i - 1]使用过
可能有的录友想,为什么 used[i - 1] == false 就是同一树层呢,因为同一树层,used[i - 1] == false 才能表示,当前取的 candidates[i] 是从 candidates[i - 1] 回溯而来的。
而 used[i - 1] == true,说明是进入下一层递归,去下一个数,所以是树枝上,如图所示:
这块去重的逻辑很抽象,网上搜的题解基本没有能讲清楚的,如果大家之前思考过这个问题或者刷过这道题目,看到这里一定会感觉通透了很多!
那么单层搜索的逻辑代码如下:
for (int i = startIndex; i < candidates.length; i++) {
//这里是进行剪枝操作,如果当前和要开始遍历的层的节点值>target的话,就不用进行下去了.
if (sum + candidates[i] > target) {
break;
}
//如果当前相同元素已经遍历过,则跳过本次循环
//这里详细解释一下
//i>0:是为了防止空指针异常,
//candidates[i]==candidates[i-1]这里使用来判断当前数是否与上一个数相等
//!used[i-1]用来判断同一树枝或者同一树层是否使用过
//如果为true,说明是同一树枝使用过,所以继续向下遍历
//如果为false,则说明当前是在遍历同一树层
//要对同一树层使用过的元素进行跳过
if (i > 0 && candidates[i] == candidates[i - 1] && !used[i - 1]) {
continue;
}
used[i] = true;
sum = sum + candidates[i];
path.add(candidates[i]);
// 每个节点仅能选择一次,所以从下一位开始
travel(candidates, target, sum, i + 1);
//回溯
used[i] = false;
sum = sum - candidates[i];
path.remove(path.size() - 1);
}
整体代码
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
//初始化数组
used = new boolean[candidates.length];
Arrays.sort(candidates);
Arrays.fill(used, false);
travel(candidates, target, 0, 0);
return res;
}
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
//判断相同元素是否遍历数组
boolean[] used;
public void travel(int[] candidates, int target, int sum, int startIndex) {
if (sum == target) {
res.add(new ArrayList<>(path));
}
for (int i = startIndex; i < candidates.length; i++) {
if (sum + candidates[i] > target) {
break;
}
//如果当前相同元素已经遍历过,则跳过本次循环
//这里详细解释一下
//i>0:是为了防止空指针异常,
//candidates[i]==candidates[i-1]这里使用来判断当前数是否与上一个数相等
//!used[i-1]用来判断同一树枝或者同一树层是否使用过
//如果为true,说明是同一树枝使用过,所以继续向下遍历
//如果为false,则说明当前是在遍历同一树层
//要对同一树层使用过的元素进行跳过
if (i > 0 && candidates[i] == candidates[i - 1] && !used[i - 1]) {
continue;
}
used[i] = true;
sum = sum + candidates[i];
path.add(candidates[i]);
// 每个节点仅能选择一次,所以从下一位开始
travel(candidates, target, sum, i + 1);
//回溯
used[i] = false;
sum = sum - candidates[i];
path.remove(path.size() - 1);
}
}
时间复杂度: O(n * 2^n)
空间复杂度: O(n)
131.分割回文串
给你一个字符串 s
,请你将 s
分割成一些子串,使每个子串都是 回文串 。返回 s
所有可能的分割方案。
示例 1:
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
示例 2:
输入:s = "a"
输出:[["a"]]
提示:
1 <= s.length <= 16
s
仅由小写英文字母组成
Related Topics
字符串
动态规划
回溯
思路
本题这涉及到两个关键问题:
- 切割问题,有不同的切割方式
- 判断回文
相信这里不同的切割方式可以搞懵很多同学了。
这种题目,想用for循环暴力解法,可能都不那么容易写出来,所以要换一种暴力的方式,就是回溯。
一些同学可能想不清楚 回溯究竟是如何切割字符串呢?
我们来分析一下切割,其实切割问题类似组合问题。
例如对于字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个…。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段…。
感受出来了不?
所以切割问题,也可以抽象为一棵树形结构,如图:
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。
回溯三部曲
- 递归函数参数
全局变量数组path存放切割后回文的子串,二维数组result存放结果集。 (这两个参数可以放到函数参数里)
本题递归函数参数还需要startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
在回溯算法:求组合总和(二) (opens new window)中我们深入探讨了组合问题什么时候需要startIndex,什么时候不需要startIndex。
代码如下:
private List<List<String>> res = new ArrayList<>();
private List<String> path = new ArrayList<>();
private void travel(String s, int startIndex){
}
- 递归函数终止条件
从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。
那么在代码里什么是切割线呢?
在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。
所以终止条件代码如下:
//此时分割位置已经超过了字符串的长度,
//说明已经找到了一组分割字符串
if (startIndex >= s.length()) {
res.add(new ArrayList<>(path));
}
- 单层搜索的逻辑
来看看在递归循环中如何截取子串呢?
在for (int i = startIndex; i < s.size(); i++)
循环中,我们 定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在vector<string> path
中,path用来记录切割过的回文子串。
代码如下:
for (int i = startIndex; i < s.length(); i++) {
//首先判断一下本次切割下来的是不是回文字符串
//如果为回文字符串则进行下边操作,如果不是,则退出本次循环
if (isPalindrome(s, startIndex, i)) {
// 获取[startIndex,i]在s中的子串
String temp = s.substring(startIndex, i + 1);
//递归
path.add(temp);
travel(s, i + 1);
//回溯
path.remove(path.size() - 1);
} else {
continue;
}
}
注意切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为i + 1。
判断回文子串
最后我们看一下回文子串要如何判断了,判断一个字符串是否是回文。
可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。
private boolean isPalindrome(String s, int start, int end) {
for (int i = start, j = end; i < end; i++, j--) {
if (s.charAt(i) != s.charAt(j)) {
return false;
}
}
return true;
}
整体代码
public List<List<String>> partition(String s) {
travel(s, 0);
return res;
}
private List<List<String>> res = new ArrayList<>();
private List<String> path = new ArrayList<>();
private void travel(String s, int startIndex) {
//此时分割位置已经超过了字符串的长度,
//说明已经找到了一组分割字符串
if (startIndex >= s.length()) {
res.add(new ArrayList<>(path));
}
for (int i = startIndex; i < s.length(); i++) {
//首先判断一下本次切割下来的是不是回文字符串
//如果为回文字符串则进行下边操作,如果不是,则退出本次循环
if (isPalindrome(s, startIndex, i)) {
// 获取[startIndex,i]在s中的子串
String temp = s.substring(startIndex, i + 1);
//递归
path.add(temp);
travel(s, i + 1);
//回溯
path.remove(path.size() - 1);
} else {
continue;
}
}
}
private boolean isPalindrome(String s, int start, int end) {
for (int i = start, j = end; i < end; i++, j--) {
if (s.charAt(i) != s.charAt(j)) {
return false;
}
}
return true;
}
时间复杂度: O(n * 2^n)
空间复杂度: O(n^2)