39. 组合总和本题是 集合里元素可以用无数次,那么和组合问题的差别 其实仅在于 startIndex上的控制
题目链接/文章讲解:代码随想录
视频讲解:带你学透回溯算法-组合总和(对应「leetcode」力扣题目:39.组合总和)| 回溯法精讲!_哔哩哔哩_bilibili
没有理清楚startIndex的控制:直接去除startIndex会导致相同组合被重复记录,保留startIndex会导致“同一个 数字可以 无限制重复被选取”没有实现
解决办法:下一层的startIndex从i开始,之前是从i+1开始
本题和我们之前讲过的77.组合 、216.组合总和III 有两点不同:
- 组合没有数量要求
- 元素可无限重复选取
因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!而在77.组合和216.组合总和III中都可以知道要递归K层,因为要取k个元素的组合。
本题还需要startIndex来控制for循环的起始位置,对于组合问题,
- 如果是一个集合来求组合的话,就需要startIndex,例如:77.组合,216.组合总和III。
- 如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:17.电话号码的字母组合
以上只是求组合的情况,如果是排列问题,又是另一套分析的套路
class Solution {
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new ArrayList<>();
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 ArrayList<>(path));
return;
}
for (int i = startIndex; i < candidates.length; i++){
target -= candidates[i];
path.add(candidates[i]);
backtracking(candidates, target, i);// 不用i+1了,表示可以重复读取当前的数
target += candidates[i];
path.remove(path.size() - 1);
}
}
}
剪枝优化
如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。
那么可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。(建立在输入为有序数组的情况下)在求和问题中,排序之后加剪枝是常见的套路!
// 剪枝优化
class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> res = new ArrayList<>();
Arrays.sort(candidates); // 先进行排序
backtracking(res, new ArrayList<>(), candidates, target, 0, 0);
return res;
}
public void backtracking(List<List<Integer>> res, List<Integer> path, int[] candidates, int target, int sum, int idx) {
// 找到了数字和为 target 的组合
if (sum == target) {
res.add(new ArrayList<>(path));
return;
}
for (int i = idx; i < candidates.length; i++) {
// 如果 sum + candidates[i] > target 就终止遍历
if (sum + candidates[i] > target) break;
path.add(candidates[i]);
backtracking(res, path, candidates, target, sum + candidates[i], i);
path.remove(path.size() - 1); // 回溯,移除路径 path 最后一个元素
}
}
}
40.组合总和II本题开始涉及到一个问题了:去重。
注意题目中给我们 集合是有重复元素的,那么求出来的 组合有可能重复,但题目要求不能有重复组合。
题目链接/文章讲解:代码随想录
视频讲解:回溯算法中的去重,树层去重树枝去重,你弄清楚了没?| LeetCode:40.组合总和II_哔哩哔哩_bilibili
自己做没用到布尔数组used,导致没有区分开树层去重和树枝去重,把树枝去重的情况(合法的)也做了去重,导致出错
这道题目和39.组合总和如下区别:
- 本题candidates 中的每个数字在每个组合中只能使用一次。
- 本题数组candidates的元素是有重复的,而39.组合总和是无重复元素的数组candidates
最后本题和39.组合总和要求一样,解集不能包含重复的组合。
本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合。
需要理解重复指的是我们是要同一树层上使用过,还是同一树枝上使用过呢?
本题要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
树层去重的话,需要对数组排序!
如果candidates[i] == candidates[i - 1]
并且 used[i - 1] == false
,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]。此时for循环里就应该做continue的操作。
因为同一树层,used[i - 1] == false 才能表示,当前取的 candidates[i] 是从 candidates[i - 1] 回溯而来的。而 used[i - 1] == true,说明是进入下一层递归,去下一个数,所以是树枝上
class Solution {
List<List<Integer>> result = new ArrayList<>();// 存放组合集合
List<Integer> path = new ArrayList<>();// 符合条件的组合
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
// 首先把给candidates排序,让其相同的元素都挨在一起。
Arrays.sort(candidates);
boolean[] used= new boolean[candidates.length];
trackingback(candidates, target, 0, used);
return result;
}
public void trackingback(int[] candidates, int target, int startIndex, boolean[] used){
if(target < 0) return;
if(target == 0){
result.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i < candidates.length && candidates[i] <= target; i++){
// used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
// used[i - 1] == false,说明同一树层candidates[i - 1]使用过
// 要对同一树层使用过的元素进行跳过
if(i >= 1 && candidates[i-1] == candidates[i] && used[i-1] == false){
continue;
}
target -= candidates[i];
path.add(candidates[i]);
used[i] = true;
// 和39.组合总和的区别1:这里是i+1,每个数字在每个组合中只能使用一次
trackingback(candidates, target, i + 1, used);
target += candidates[i];
path.remove(path.size() - 1);
used[i] = false;
}
}
}
直接用startIndex来去重也是可以的, 就不用used数组了。这里的 startIndex
表示每层递归的起始索引,当 i > startIndex
时,说明当前元素不是该层的第一个元素,如果和前一个元素相同(candidates[i] == candidates[i - 1]
),则跳过该元素。这样也能保证同一树层的相同元素只被使用一次。
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.分割回文串本题较难,大家先看视频来理解 分割问题,明天还会有一道分割问题,先打打基础。
本题这涉及到两个关键问题:
- 切割问题,有不同的切割方式
- 判断回文
其实切割问题类似组合问题。切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。
例如对于字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个.....。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段.....。
本题递归函数参数还需要startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。
[startIndex, i] 就是要截取的子串
最后判断一个字符串是否是回文。
可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。
class Solution {
List<List<String>> result = new ArrayList<>();
List<String> path = new ArrayList<>();// 放已经回文的子串
public List<List<String>> partition(String s) {
backtracking(s, 0);
return result;
}
public void backtracking(String s, int startIndex){
//如果起始位置大于s的大小,说明找到了一组分割方案
if(startIndex >= s.length()){
result.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i < s.length(); i++){
//如果是回文子串,则记录
if(isPalindrome(s, startIndex, i)){
// 获取[startIndex,i]在s中的子串
String str = s.substring(startIndex, i + 1);
path.add(str);
}else {// 不是回文,跳过
continue;
}
//起始位置后移,保证不重复
backtracking(s, i + 1);// 寻找i+1为起始位置的子串
path.remove(path.size() - 1);// 回溯过程,弹出本次已经添加的子串
}
}
//判断是否是回文串
private boolean isPalindrome(String s, int startIndex, int end) {
for (int i = startIndex, j = end; i < j; i++, j--) {
if (s.charAt(i) != s.charAt(j)) {
return false;
}
}
return true;
}
}
优化
给定字符串"abcde"
, 在已知"bcd"
不是回文字串时, 不再需要去双指针操作"abcde"
而可以直接判定它一定不是回文字串。
具体来说, 给定一个字符串s
, 长度为n
, 它成为回文字串的充分必要条件是s[0] == s[n-1]
且s[1:n-1]
是回文字串。
如果熟悉动态规划这种算法的话, 我们可以高效地事先一次性计算出, 针对一个字符串s
, 它的任何子串是否是回文字串, 然后在我们的回溯函数中直接查询即可, 省去了双指针移动判定(有一定的重复计算)这一步骤.