39. 组合总和
思路
题目中的无限制重复被选取,提示:1 <= candidates[i] <= 200。
本题和77.组合 (opens new window),216.组合总和III (opens new window)的区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
本题搜索的过程抽象成树形结构如下:
注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
回溯三部曲
- 递归函数参数
这里依然是定义两个全局变量,二维数组result存放结果集,数组path存放符合条件的结果。
首先是题目中给出的参数,集合candidates, 和目标值target。
target做相应的减法就可以了,最后如果 target==0 就说明找到符合的结果了。
本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?
卡哥举过例子,如果是一个集合来求组合的话,就需要startIndex,例如:77.组合 (opens new window),216.组合总和III (opens new window)。
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:17.电话号码的字母组合(opens new window)
注意以上只是说求组合的情况,如果是排列问题,又是另一套分析的套路,后面我在讲解排列的时候会重点介绍。
代码如下:
//结果集
List<List<Integer>> res = new ArrayList<>();
//记录路径
List<Integer> path = new ArrayList<>();
void backtracking(int[] candidates, int target,int startIndex)
- 递归终止条件
在如下树形结构中:
从叶子节点可以清晰看到,终止只有两种情况,sum大于target等于target。
sum等于target的时候,需要收集结果,代码如下:
if (target == 0){// 找到了数字和为 target 的组合res.add(new ArrayList<>(path));return;
}
if (target < 0){return;
}
- 单层搜索的逻辑
单层for循环依然是从startIndex开始,搜索candidates集合。
注意本题和77.组合 (opens new window)、216.组合总和III (opens new window)的一个区别是:本题元素为可重复选取的。
如何重复选取呢,看代码,注释部分:
for (int i = startIndex; i < candidates.length; i++) {if(target-candidates[i] <0){break;}// 如果 sum + candidates[i] > target 就终止遍历path.add(candidates[i]);backtracking(candidates,target-candidates[i],i);path.remove(path.size() - 1);// 回溯,移除路径 path 最后一个元素
}
#剪枝优化
在这个树形结构中:
以及上面的版本一的代码可以看到,对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。
其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。
如图:
for循环剪枝代码如下:
if(target-candidates[i] <0){break;}// 如果 sum + candidates[i] > target 就终止遍历
整体代码如下:(注意注释的部分)
class Solution {//结果集List<List<Integer>> res = new ArrayList<>();//记录路径List<Integer> path = new ArrayList<>();public List<List<Integer>> combinationSum(int[] candidates, int target) {Arrays.sort(candidates); // 先进行排序,利于之后递归循环进行剪枝操作backtracking(candidates,target,0);return res;}void backtracking(int[] candidates, int target,int startIndex){if (target == 0){// 找到了数字和为 target 的组合res.add(new ArrayList<>(path));return;}if (target < 0){return;}for (int i = startIndex; i < candidates.length; i++) {if(target-candidates[i] <0){break;}// 如果 sum + candidates[i] > target 就终止遍历path.add(candidates[i]);backtracking(candidates,target-candidates[i],i);path.remove(path.size() - 1);// 回溯,移除路径 path 最后一个元素}}
}
40.组合总和II
思路
这道题目和39.组合总和 (opens new window)如下区别:
- 本题candidates 中的每个数字在每个组合中只能使用一次。
- 本题数组candidates的元素是有重复的,而39.组合总和 (opens new window)是无重复元素的数组candidates
最后本题和39.组合总和 (opens new window)要求一样,解集不能包含重复的组合。
本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合。
用set或者map去重,这么做很容易超时!
所以要在搜索的过程中就去掉重复组合。
这个去重为什么很难理解呢,所谓去重,其实就是使用过的元素不能重复选取。
都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成没有彻底理解去重的根本原因。
那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
整体代码如下:
class Solution {//结果集List<List<Integer>> res = new ArrayList<>();//记录路径List<Integer> path = new ArrayList<>();public List<List<Integer>> combinationSum2(int[] candidates, int target) {Arrays.sort(candidates);//为了将重复的数字都放到一起,所以先进行排序backtracking(candidates,target,0);return res;}void backtracking(int[] candidates, int target,int startIndex){if (target<0){return;}if (target == 0){res.add(new ArrayList<>(path));return;}for (int i = startIndex; i < candidates.length; i++) {if (target - candidates[i] < 0){break;}//正确剔除重复解的办法//跳过同一树层使用过的元素if(i > startIndex && candidates[i] == candidates[i-1]){continue;}path.add(candidates[i]);backtracking(candidates,target-candidates[i],i+1);path.remove(path.size() - 1);}}
}
在上述中,卡哥说的使用一个used数据来标记该结点是否被使用过了。理解起来稍微有点抽象,但其实也可以不用 used 数组标记,只需要通过:
if(i > startIndex && candidates[i] == candidates[i-1]){continue;
}
来判断是否使用过该结点。具体的还是需要加深理解,具体见代码。
131.分割回文串
思路
本题这涉及到两个关键问题:
- 切割问题,有不同的切割方式
- 判断回文
回溯究竟是如何切割字符串呢?
我们来分析一下切割,其实切割问题类似组合问题。
例如对于字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个.....。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段.....。
所以切割问题,也可以抽象为一棵树形结构,如图:
对于每一层遍历,先切割单个单个的字符,切割完成后判断 startIndex 是否 >= 给出的字符串长度,为true则结束当前递归,然后在逐渐切割多个。
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。
#回溯三部曲
- 递归函数参数
全局变量数组path存放切割后回文的子串,二维数组result存放结果集。 (
本题递归函数参数还需要 startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
代码如下:
List<List<String>> res = new ArrayList<>();
LinkedList<String> path = new LinkedList<>();// 放已经回文的子串
void backtracking(String s,int startIndex)
- 递归函数终止条件
从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。
那么在代码里什么是切割线呢?
在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。
所以终止条件代码如下:
// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
if (startIndex >= s.length()){res.add(new ArrayList<>(path));return;
}
- 单层搜索的逻辑
来看看在递归循环中如何截取子串呢?
在for (int i = startIndex; i < s.size(); i++)
循环中,我们 定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在 LinkedList<String> path 中,path用来记录切割过的回文子串。
代码如下:
for (int i = startIndex; i < s.length(); i++) {if (!isPalindrome(s,startIndex,i)){//如果当前不是回文子串continue;// 如果不是则直接跳过}//如果是回文子串// 获取[startIndex,i+1)在s中的子path.add(s.substring(startIndex,i+1));//字符串截取是左闭右开的backtracking(s,i+1);// 寻找i+1为起始位置的子串path.removeLast();// 回溯过程,弹出本次已经添加的子串
}
注意切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为i + 1。
#判断回文子串
最后我们看一下回文子串要如何判断了,判断一个字符串是否是回文。
可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。
那么判断回文的C++代码如下:
boolean isPalindrome(String str,int left,int right){for (int i = left,j = right; i < j; i++,j--) {if (str.charAt(i) != str.charAt(j)){return false;}}return true;
}
整体代码如下(注释):
class Solution {List<List<String>> res = new ArrayList<>();LinkedList<String> path = new LinkedList<>();public List<List<String>> partition(String s) {backtracking(s,0);return res;}void backtracking(String s,int startIndex){// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了if (startIndex >= s.length()){res.add(new ArrayList<>(path));return;}for (int i = startIndex; i < s.length(); i++) {if (!isPalindrome(s,startIndex,i)){//如果当前不是回文子串continue;// 如果不是则直接跳过}//如果是回文子串// 获取[startIndex,i+1)在s中的子path.add(s.substring(startIndex,i+1));//字符串截取是左闭右开的backtracking(s,i+1);// 寻找i+1为起始位置的子串path.removeLast();// 回溯过程,弹出本次已经添加的子串}}boolean isPalindrome(String str,int left,int right){for (int i = left,j = right; i < j; i++,j--) {if (str.charAt(i) != str.charAt(j)){return false;}}return true;}
}
以上为我做题时候的相关思路,自己的语言组织能力较弱,很多都是直接抄卡哥的,有错误望指正。