39. 组合总和
本题是 集合里元素可以用无数次,那么和组合问题的差别 其实仅在于 startIndex上的控制
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的数字可以无限制重复被选取。
说明:
- 所有数字(包括 target)都是正整数。
- 解集不能包含重复的组合。
示例 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] ]
题目中的无限制重复被选取,吓得我赶紧想想出现 0 可咋办,然后看到下面提示:1 <= candidates[i] <= 200,我就放心了。
本题和77.组合,216.组合总和Ⅲ的区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
代码随想录算法day19 | 回溯算法part01 | 77. 组合,216.组合总和III,17.电话号码的字母组合-CSDN博客
本题搜索的过程抽象成树形结构如下:
注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
而在 77.组合 和 216.组合总和Ⅲ 中都可以知道要递归 K 层,因为要取 k 个元素的组合。
回溯三部曲
-
递归函数参数
这里依然是定义两个全局变量,二维数组 result 存放结果集,数组 path 存放符合条件的结果。(这两个变量可以作为函数参数传入)
首先是题目中给出的参数,集合 candidates, 和目标值 target。
此外还定义了 int 型的 sum 变量来统计单一结果 path 里的总和,其实这个 sum 也可以不用,用target 做相应的减法就可以了,最后如何 target==0 就说明找到符合的结果了,但为了代码逻辑清晰,这里依然用了 sum。
本题还需要 startIndex 来控制for循环的起始位置,对于组合问题,什么时候需要 startIndex 呢?
我举过例子,如果是一个集合来求组合的话,就需要 startIndex,例如:77.组合 和 216.组合总和Ⅲ。
如果是多个集合取组合,各个集合之间相互不影响,那么就不用 startIndex,例如:17.电话号码的字母组合(代码随想录算法day19 | 回溯算法part01 | 77. 组合,216.组合总和III,17.电话号码的字母组合-CSDN博客)
注意以上我只是说求组合的情况,如果是排列问题,又是另一套分析的套路,后面我在讲解排列的时候会重点介绍。
代码如下:
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new LinkedList<>();
public void backtracking(int[] candidates, int target, int startIndex, int sum)
-
递归终止条件
在如下树形结构中:
从叶子节点可以清晰看到,终止只有两种情况:
- sum大于target
- sum等于target
sum等于target的时候,需要收集结果,代码如下:
if (sum > target) {
return;
}
if (sum == target) {
result.add(path);
return;
}
-
单层搜索的逻辑
单层for循环依然是从 startIndex 开始,搜索candidates集合。
注意本题和 77.组合 、216.组合总和Ⅲ 的一个区别是:本题元素为可重复选取的。
如何重复选取呢,看代码,注释部分:
for (int i = startIndex; i < candidates.size(); i++) {
sum += candidates[i];
path.add(candidates[i]);
backtracking(candidates, target, sum, i); // 关键点:不用i+1了,表示可以重复读取当前的数
sum -= candidates[i]; // 回溯
path.removeLast(); // 回溯
}
按照代码随想录算法 | 回溯算法先导知识 | 题目分类,理论基础-CSDN博客中给出的模板,不难写出如下Java完整代码(无sum版本):
class Solution {
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backtracking(candidates, target, 0);
return result;
}
public void backtracking(int[] candidates, int target, int startIndex){
if(target < 0) return;
if(target == 0){
result.add(new LinkedList<>(path));
return;
}
for(int i = startIndex; i <= candidates.length - 1; i++){
path.add(candidates[i]);
target -= candidates[i];
backtracking(candidates, target, i); // 关键点
target += candidates[i]; // 回溯
path.removeLast(); // 回溯
}
}
}
剪枝优化
在这个树形结构中:
以及上面版本的代码大家可以看到,对于 sum 已经大于 target (target < 0)的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。
其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。
那么可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的 sum(就是本层的 sum + candidates[i])已经大于 target(下一层的target(就是本层的 target - candidates[i])已经小于 0),就可以结束本轮for循环的遍历。
如图:
for循环剪枝代码如下:
for (int i = startIndex; i < candidates.length && sum + candidates[i] <= target; i++)
完整代码不再赘述
- 时间复杂度: O(n * 2^n),注意这只是复杂度的上界,因为剪枝的存在,真实的时间复杂度远小于此
- 空间复杂度: O(target)
总结
本题和我们之前讲过的 77.组合 和 216.组合总和Ⅲ 有两点不同:
- 组合没有数量要求
- 元素可无限重复选取
针对这两个问题,以上都做了详细的分析。
并且给出了对于组合问题,什么时候用 startIndex,什么时候不用,并用17.电话号码的字母组合做了对比。
代码随想录算法day19 | 回溯算法part01 | 77. 组合,216.组合总和III,17.电话号码的字母组合-CSDN博客
最后还给出了本题的剪枝优化,这个优化如果是初学者的话并不容易想到。
在求和问题中,排序之后加剪枝是常见的套路!
可以看出文章都会大量引用之前的文章,不断作对比,分析其差异,然后给出代码解决的方法。这样才能彻底理解题目的本质与难点。
40.组合总和II
本题开始涉及到一个问题了:去重。
注意题目中给我们的集合是有重复元素的,那么求出来的组合就有可能重复,但题目要求不能有重复组合。
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。
说明: 所有数字(包括目标数)都是正整数。解集不能包含重复的组合。
- 示例 1:
- 输入: candidates = [10,1,2,7,6,1,5], target = 8,
- 所求解集为:
[ [1, 7], [1, 2, 5], [2, 6], [1, 1, 6] ]
- 示例 2:
- 输入: candidates = [2,5,2,1,2], target = 5,
- 所求解集为:
[ [1,2,2], [5] ]
这道题目和上一题 39.组合总和 如下区别:
- 本题 candidates 中的每个数字在每个组合中只能使用一次。
- 本题数组 candidates 的元素是有重复的,而 39.组合总和 是无重复元素的数组 candidates
最后本题和 39.组合总和 要求一样,解集不能包含重复的组合。
本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合。
一些同学可能想了:我把所有组合求出来,再用set或者map去重,这么做很容易超时!
所以要在搜索的过程中就去掉重复组合。
很多同学在去重的问题上想不明白,其实很多题解也没有讲清楚,反正代码是能过的,感觉是那么回事,稀里糊涂的先把题目过了。
这个去重为什么很难理解呢,所谓去重,其实就是使用过的元素不能重复选取。 这么一说好像很简单!
都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的:
- 一个维度是同一树枝上使用过
- 一个维度是同一树层上使用过
没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
为了理解去重我们来举一个例子,candidates = [1, 1, 2], target = 3,(方便起见 candidates 已经排序了)
强调一下,树层去重的话,需要对数组排序!
选择过程树形结构如图所示:
可以看到图中,每个节点相对于 39.组合总和 多加了used数组,这个used数组下面会重点介绍。
回溯三部曲
-
递归函数参数
与 39.组合总和 套路相同,此题还需要加一个 bool 型数组 used,用来记录同一树枝上的元素是否使用过。
这个集合去重的重任就是 used 来完成的。
代码如下:
LinkedList<Integer> path = new LinkedList<>();
List<List<Integer>> ans = new ArrayList<>();
boolean[] used;
private void backTracking(int[] candidates, int target, int startIndex)
-
递归终止条件
与 39.组合总和 相同,终止条件为 sum > target
和 sum == target
。
代码如下:
if (sum > target) { // 这个条件其实可以省略
return;
}
if (sum == target) {
ans.add(path);
return;
}
sum > target
这个条件其实可以省略,因为在递归单层遍历的时候,会有剪枝的操作,下面会介绍到。
-
单层搜索的逻辑
这里与 39.组合总和 最大的不同就是要去重了。
前面我们提到:要去重的是“同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。
如果 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++) {
if (sum + candidates[i] > target) {
break;
}
// used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
// used[i - 1] == false,说明同一树层candidates[i - 1]使用过
// 要对同一树层使用过的元素进行跳过
// 出现重复节点,同层的第一个节点已经被访问过,所以直接跳过
if (i > 0 && candidates[i] == candidates[i - 1] && !used[i - 1]) {
continue;
}
used[i] = true;
sum += candidates[i];
path.add(candidates[i]);
// 每个节点仅能选择一次,所以从下一位开始
backTracking(candidates, target, i + 1);
used[i] = false; // 回溯
sum -= candidates[i]; // 回溯
path.removeLast(); // 回溯
}
回溯三部曲分析完了,整体Java代码如下:
class Solution {
LinkedList<Integer> path = new LinkedList<>();
List<List<Integer>> ans = new ArrayList<>();
boolean[] used;
int sum = 0;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
used = new boolean[candidates.length];
// 加标志数组,用来辅助判断同层节点是否已经遍历
Arrays.fill(used, false);
// 为了将重复的数字都放到一起,所以先进行排序
Arrays.sort(candidates);
backTracking(candidates, target, 0);
return ans;
}
private void backTracking(int[] candidates, int target, int startIndex) {
if (sum == target) {
ans.add(new ArrayList(path));
}
for (int i = startIndex; i < candidates.length; i++) {
if (sum + candidates[i] > target) {
break;
}
// 出现重复节点,同层的第一个节点已经被访问过,所以直接跳过
if (i > 0 && candidates[i] == candidates[i - 1] && !used[i - 1]) {
continue;
}
used[i] = true;
sum += candidates[i];
path.add(candidates[i]);
// 每个节点仅能选择一次,所以从下一位开始
backTracking(candidates, target, i + 1);
used[i] = false;
sum -= candidates[i];
path.removeLast();
}
}
}
- 时间复杂度: O(n * 2^n)
- 空间复杂度: O(n)
补充
这里直接用 startIndex 来去重也是可以的, 就不用 used 数组了。
class Solution {
List<List<Integer>> res = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
int sum = 0;
public List<List<Integer>> combinationSum2( int[] candidates, int target ) {
//为了将重复的数字都放到一起,所以先进行排序
Arrays.sort( candidates );
backTracking( candidates, target, 0 );
return res;
}
private void backTracking( int[] candidates, int target, int start ) {
if ( sum == target ) {
res.add( new ArrayList<>( path ) );
return;
}
for ( int i = start; i < candidates.length && sum + candidates[i] <= target; i++ ) {
//正确剔除重复解的办法
//跳过同一树层使用过的元素
if ( i > start && candidates[i] == candidates[i - 1] ) {
continue;
}
sum += candidates[i];
path.add( candidates[i] );
// i+1 代表当前组内元素只选取一次
backTracking( candidates, target, i + 1 );
int temp = path.getLast();
sum -= temp;
path.removeLast();
}
}
}
131.分割回文串
本题较难,后面还会有一道分割问题,先打打基础
给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。
返回 s 所有可能的分割方案。
示例: 输入: "aab" 输出: [ ["aa","b"], ["a","a","b"]
本题这涉及到两个关键问题:
- 切割问题,有不同的切割方式
- 判断回文
相信这里不同的切割方式可以搞懵很多人了。
这种题目,想用for循环暴力解法,可能都不那么容易写出来,所以要换一种暴力的方式,就是回溯。
一些同学可能想不清楚回溯究竟是如何切割字符串呢?
我们来分析一下切割,其实切割问题类似组合问题。
例如对于字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个.....。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段.....。
感受出来了不?
所以切割问题,也可以抽象为一棵树形结构,如图:
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。
回溯三部曲
-
递归函数参数
全局变量数组 path 存放切割后回文的子串,二维数组 result 存放结果集。(这两个参数可以放到函数参数里)
本题递归函数参数还需要 startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
在 39.组合总和 中我们深入探讨了组合问题什么时候需要 startIndex,什么时候不需要 startIndex。
代码如下:
List<List<String>> res = new ArrayList<>();
List<String> cur = new ArrayList<>();
private void backtracking(String s, int start, StringBuilder sb)
-
递归函数终止条件
从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。
那么在代码里什么是切割线呢?
在处理组合问题的时候,递归参数需要传入 startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。
所以终止条件代码如下:
private void backtracking(String s, int start, StringBuilder sb){
//因为是起始位置一个一个加的,所以结束时start一定等于s.length,因为进入backtracking时一定末尾也是回文,所以cur是满足条件的
if (start == s.length()){
//注意创建一个新的copy
res.add(new ArrayList<>(cur));
return;
}
-
单层搜索的逻辑
来看看在递归循环中如何截取子串呢?
在 for(int i = startIndex; i < s.length(); i++)
循环中,我们 定义了起始位置 startIndex,那么 [startIndex, i] 就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在 List<String> cur
中,cur 用来记录切割过的回文子串。
代码如下:
//像前两题一样从前往后搜索,如果发现回文,进入backtracking,起始位置后移一位,循环结束照例移除cur的末位
for (int i = start; i < s.length(); i++){
sb.append(s.charAt(i));
if (check(sb)){
cur.add(sb.toString());
backtracking(s, i + 1, new StringBuilder());
cur.remove(cur.size() -1 );
}
}
注意切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为i + 1。
判断回文子串
最后我们看一下回文子串要如何判断了,判断一个字符串是否是回文。
可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。
那么判断回文的Java代码如下:
//helper method, 检查是否是回文
private boolean check(StringBuilder sb){
for (int i = 0; i < sb.length()/ 2; i++){
if (sb.charAt(i) != sb.charAt(sb.length() - 1 - i)){return false;}
}
return true;
}
如果大家对双指针法有生疏了,传送门:代码随想录算法 | 算法总结——双指针法-CSDN博客
不难写出如下代码:
class Solution {
//保持前几题一贯的格式, initialization
List<List<String>> res = new ArrayList<>();
List<String> cur = new ArrayList<>();
public List<List<String>> partition(String s) {
backtracking(s, 0, new StringBuilder());
return res;
}
private void backtracking(String s, int start, StringBuilder sb){
//因为是起始位置一个一个加的,所以结束时start一定等于s.length,因为进入backtracking时一定末尾也是回文,所以cur是满足条件的
if (start == s.length()){
//注意创建一个新的copy
res.add(new ArrayList<>(cur));
return;
}
//像前两题一样从前往后搜索,如果发现回文,进入backtracking,起始位置后移一位,循环结束照例移除cur的末位
for (int i = start; i < s.length(); i++){
sb.append(s.charAt(i));
if (check(sb)){
cur.add(sb.toString());
backtracking(s, i + 1, new StringBuilder());
cur.remove(cur.size() -1 );
}
}
}
//helper method, 检查是否是回文
private boolean check(StringBuilder sb){
for (int i = 0; i < sb.length()/ 2; i++){
if (sb.charAt(i) != sb.charAt(sb.length() - 1 - i)){return false;}
}
return true;
}
}
- 时间复杂度: O(n * 2^n)
- 空间复杂度: O(n^2)
优化
上面的代码还存在一定的优化空间, 在于如何更高效的计算一个子字符串是否是回文字串。上述代码 check
函数运用双指针的方法来判定对于一个字符串 s,
给定起始下标和终止下标, 截取出的子字符串是否是回文字串。但是其中有一定的重复计算存在:
例如给定字符串"abcde"
, 在已知"bcd"
不是回文字串时, 不再需要去双指针操作"abcde"
而可以直接判定它一定不是回文字串。
具体来说, 给定一个字符串 s
, 长度为 n
, 它成为回文字串的充分必要条件是 s[0] == s[n-1]
且 s[1:n-1]
是回文字串。
大家如果熟悉 动态规划 这种算法的话, 我们可以高效地事先一次性计算出, 针对一个字符串 s
, 它的任何子串是否是回文字串, 然后在我们的回溯函数中直接查询即可, 省去了双指针移动判定这一步骤.
具体参考代码如下:
class Solution {
List<List<String>> result;
LinkedList<String> path;
boolean[][] dp;
public List<List<String>> partition(String s) {
result = new ArrayList<>();
char[] str = s.toCharArray();
path = new LinkedList<>();
dp = new boolean[str.length + 1][str.length + 1];
isPalindrome(str);
backtracking(s, 0);
return result;
}
public void backtracking(String str, int startIndex) {
if (startIndex >= str.length()) {
//如果起始位置大于s的大小,说明找到了一组分割方案
result.add(new ArrayList<>(path));
} else {
for (int i = startIndex; i < str.length(); ++i) {
if (dp[startIndex][i]) {
//是回文子串,进入下一步递归
//先将当前子串保存入path
path.addLast(str.substring(startIndex, i + 1));
//起始位置后移,保证不重复
backtracking(str, i + 1);
path.pollLast();
} else {
//不是回文子串,跳过
continue;
}
}
}
}
//通过动态规划判断是否是回文串,参考动态规划篇 52 回文子串
public void isPalindrome(char[] str) {
for (int i = 0; i <= str.length; ++i) {
dp[i][i] = true;
}
for (int i = 1; i < str.length; ++i) {
for (int j = i; j >= 0; --j) {
if (str[j] == str[i]) {
if (i - j <= 1) {
dp[j][i] = true;
} else if (dp[j + 1][i - 1]) {
dp[j][i] = true;
}
}
}
}
}
}
总结
这道题目在leetcode上是中等,但可以说是hard的题目了,但是代码其实就是按照模板的样子来的。
那么难究竟难在什么地方呢?
以下列出如下几个难点:
- 切割问题可以抽象为组合问题
- 如何模拟那些切割线
- 切割问题中递归如何终止
- 在递归循环中如何截取子串
- 如何判断回文
我们平时在做难题的时候,总结出来难究竟难在哪里也是一种需要锻炼的能力。
一些同学可能遇到题目比较难,但是不知道题目难在哪里,反正就是很难。其实这样还是思维不够清晰,这种总结的能力需要多接触多锻炼。
本题我相信很多同学主要卡在了第一个难点上:就是不知道如何切割,甚至知道要用回溯法,也不知道如何用。也就是没有体会到按照求组合问题的套路就可以解决切割。
如果意识到这一点,算是重大突破了。接下来就可以对着模板照葫芦画瓢。
但接下来如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了。
关于模拟切割线,其实就是 index 是上一层已经确定了的分割线,i 是这一层试图寻找的新分割线
除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入 i + 1。
所以本题应该是一道hard题目了。