39. 组合总和
本题是 集合里元素可以用无数次,那么和组合问题的差别 其实仅在于 startIndex上的控制
题目链接/文章讲解:https://programmercarl.com/0039.%E7%BB%84%E5%90%88%E6%80%BB%E5%92%8C.html
视频讲解:https://www.bilibili.com/video/BV1KT4y1M7HJ
第一印象:
这个集合可以用无数次,我觉得startIndex每次应该都是从集合的头开始遍历吧,那就不需要startIndex了啊。 我先试试。
不对 仍然需要startIndex。比如235,第一个分支选2,第二个分支选3.
2下面的集合应该是235,3下面的集合应该是35,如果3下面的集合还是235,就会出现重复的组合,没有意义了。但是因为没有结果集数量上的限制,所以终止条件应该是判断这个路径上sum的事情。
返回值和参数:
返回值回溯算法void,参数int[] candidates, int target, int startIndex
还需要路径的和sum
终止条件:
如果sum > target 就停了, == 就收获, < target说明还能继续加,树的深度就继续加深,往下搜索,直到 > 或者 ==。
sum > target 也算剪枝吗?
答: 不算,是终止条件🌶
单层递归逻辑:
这个比较常规,就是path添添删删,sum加加减减,startIndex和之前的不一样,因为可以元素重复,所以下一次递归的起点应该是startIndex本身开始。
传参应该是
backtracking(candidates, target, startIndex, sum);
但是运行结果我发现,还是会把重复的情况输出,好奇怪,我看看怎么事r。
可不重复么,递归传的参数还是startIndex,应该传 i 才对。 这次就对了
backtracking(candidates, target, i, sum);
我自己做出来了!!!!!!!
看完题解的思路:
看看题解吧
- 一个集合搜索需要startIndex, 多个集合搜索不需要startIndex。 这件事情只是对于组合来说,排列又是另一种情况, 我还没学。‘
- 剪枝的操作我没想到, 如下!
对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。
其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。
那么可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的sum(就是本层的 sum +
candidates[i])已经大于target,就可以结束本轮for循环的遍历。
就会变成这样的
那么for循环的终止条件就是
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)
这种判断,是在for循环的头还是尾,我就会纠结一下。我想想啊
一个for循环就是一个节点
当在2的分支取2的时候,进入for循环,path添,sum加(是4 == target),再去做递归…… 会在终止条件里收获结果。
在2的分支取3,进入for循环,但其实这个时候sum加(是5 > target),再进入递归的话就会在终止条件里返回。而改正之后的代码,在进入for循环的判断的时候,先sum加和试一试发现是5 ,那么就没必要再去递归它了,相当于树里少了一个节点(少了一个for循环)。
这个剪枝emmmm彳亍。
为什么求和问题剪枝,要先排序数组?
其实答案很简单,比如251,target是3,已经取了2,这个时候取5,发现已经是7,没必要再去搜索了,于是break,但这个break不仅仅是break了2-5这个节点,还把2-1这个也break掉了(因为2-2 2-5 2-1是for循环的形式去搜索的)。所以要先排序,125,如果1-2就超过target,那么1-5肯定超过,所以就可以break了!!
实现时的困难:
除了递归的时候传参把 i 写成了 startIndex,都不难。
我加入剪枝才做之后发现,这个数组必须先排序,卡哥说在求和问题中,排序之后加剪枝是常见的套路! 为啥啊 我要去看视频了。
ok懂了,答案写到上面剪枝的那里吧。
感悟:
自己做出来真爽啊, 画树形图真有用
代码:
class Solution {
LinkedList<Integer> path = new LinkedList<>();
List<List<Integer>> result = new ArrayList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates); // 先进行排序
backtracking(candidates, target, 0, 0);
return result;
}
private void backtracking(int[] candidates, int target, int startIndex, int sum) {
//终止条件
if (sum > target) return;
if(sum == target) {
result.add(new ArrayList<>(path));
return;
}
//单层递归逻辑
for (int i = startIndex; i < candidates.length; i++) {
if (sum + candidates[i] > target) break;
path.add(candidates[i]);
sum += candidates[i];
backtracking(candidates, target, i, sum);
//回溯
path.removeLast();
sum -= candidates[i];
}
}
}
40.组合总和II
本题开始涉及到一个问题了:去重。
注意题目中给我们 集合是有重复元素的,那么求出来的 组合有可能重复,但题目要求不能有重复组合。
题目链接/文章讲解:https://programmercarl.com/0040.%E7%BB%84%E5%90%88%E6%80%BB%E5%92%8CII.html
视频讲解:https://www.bilibili.com/video/BV12V4y1V73A
第一印象:
去重啊 去重的问题我在哈希那就不太懂。比如 1 1 7,taget 是8,那么17 17 就是重复的组合。
我悟了,肯定要先排序,否则杂乱无章没法去重。比如1 1 4 5. 第一个1正常处理,遍历到第二个1的时候,发现和前面一个一样的数字,就可以跳过了。 想到这就会产生疑问:不会落下一些情况吗?
这个时候画出树形图:
我们细看,两个圈内的内容是不是一样的,也就是说,这就是重复的产生!!!其实第一个1已经把第二个1搜索到的组合包含了。
如果心里没底,画个 22246, target=6 的图。发现第一个2 也给第二三个2的组合都包含了。所以我坚定了我的想法。其实这个检查和前一个数字是否一样的,一样就跳过的操作在之前做题遇到过,所以我才有印象。
参数和返回值:
返回值回溯void,参数因为在一个集合内 要有startIndex
void backtracking(int[] candidates, int target, int startIndex, int sum)
终止条件:
和之前的题一样。
//终止条件
if (sum > target) return;
if (sum == target) {
result.add(new ArrayList<>(path));
}
单层递归逻辑:
最开始整个数组排序,原始集合 比如1 1 2 2 4 8. 在 1 那里,startIndex是 0。startIndex之后如果有相同的数字就要跳过,所以1的分支下面是2 2 4 8.
同样的,这个集合startIndex是 2(数组下标),也是startIndex之后如果有相同的数字就要跳过,2的分支下面是4 8.
这时候就会考虑到如果集合末尾的元素一样怎么办,1 6 6这样呢。
1的分支下面是6 6。 6 6集合startIndex是 1, 也是同样的startIndex之后如果有相同的数字就要跳过。这个时候for循环的里的 i 就会变成3,下标就越界了。所以要再 跳过相同startIndex元素 的过程后判断下标是否越界,需要break的,不然就会去搜索越界的地方了,看一眼代码就知道。
本来之前的题使用for循环的终止条件去控制不越界,但是因为在for循环内我们要操作这个 i ,就可能越界了,所以要多一层判断。
剪枝也是和上一道题一样的剪枝。
反正我自己是做出来通过了。
//单层地柜逻辑
for (int i = startIndex; i < candidates.length; i++) {
//如果这个数字和上一个一样,往后找. 当前集合的第一个元素不用找
while (i != startIndex && i < candidates.length && candidates[i] == candidates[i - 1]) {
i++;
}
//越界的话就要手动break,不然添添删删的时候就处理越界下标了。
if (i == candidates.length) break;
//剪枝
if (sum + candidates[i] > target) break;
path.add(candidates[i]);
sum += candidates[i];
//递归
backtracking(candidates, target, i + 1, sum);
//回溯
path.removeLast();
sum -= candidates[i];
}
看完题解的思路:
卡哥提出的概念是,在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。强调一下,树层去重的话,需要对数组排序!
这道题画出来之后就知道是同意树层上不能重复,但是同一树枝上可以重复,因为是两个元素嘛。 确实是要排序噢。
哎卧槽,他处理的方式使用used数组,和我不一样啊。我怎么觉得我更牛逼
但是这里跳过重复元素用continue才是最好的,直接i++ 并且不会越界啊
//单层地柜逻辑
for (int i = startIndex; i < candidates.length; i++) {
//用continue啊
if (i != startIndex && candidates[i] == candidates[i - 1]) {
continue;
}
// if (i == candidates.length) break;
//剪枝
if (sum + candidates[i] > target) break;
path.add(candidates[i]);
sum += candidates[i];
//递归
backtracking(candidates, target, i + 1, sum);
//回溯
path.removeLast();
sum -= candidates[i];
}
我觉得used数组方式太麻烦了逻辑也混乱,而且他也给出了直接用startIndex去重的方式,就是我这个,我还是我牛逼。
实现时的困难:
//用continue啊
if (i != startIndex && candidates[i] == candidates[i - 1]) {
continue;
}
我把这里的 i - 1写成了i-- 于是当时死循环了。
感悟:
我又做出来一道题!!
代码:
class Solution {
LinkedList<Integer> path = new LinkedList<>();
List<List<Integer>> result = new ArrayList<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
backtracking(candidates, target, 0, 0);
return result;
}
private void backtracking(int[] candidates, int target, int startIndex, int sum) {
//终止条件
if (sum > target) return;
if (sum == target) {
result.add(new ArrayList<>(path));
}
//单层地柜逻辑
for (int i = startIndex; i < candidates.length; i++) {
//用continue啊
if (i != startIndex && candidates[i] == candidates[i - 1]) {
continue;
}
//越界的话就要手动break,不然添添删删的时候就处理越界下标了。
// if (i == candidates.length) break;
//剪枝
if (sum + candidates[i] > target) break;
path.add(candidates[i]);
sum += candidates[i];
//递归
backtracking(candidates, target, i + 1, sum);
//回溯
path.removeLast();
sum -= candidates[i];
}
}
}
131.分割回文串
本题较难,大家先看视频来理解 分割问题,明天还会有一道分割问题,先打打基础。
https://programmercarl.com/0131.%E5%88%86%E5%89%B2%E5%9B%9E%E6%96%87%E4%B8%B2.html
视频讲解:https://www.bilibili.com/video/BV1c54y1e7k6
第一印象:
这个感觉不是组合问题了,是回溯应用的分割问题吧。直接看视频学习一下吧。他还说本题较难,我好想回家下棋啊。
看完题解的思路:
切割问题其实类似于组合问题:
组合问题: 选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个…。
切割问题: 切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段…。
也可以抽象为一棵树:
这个过程比较熟悉,但是我觉得对字符串的操作,也就是图里红线的问题,对我来说是最难的。
返回值和参数:
回溯返回值void,参数除了字符串,因为在一个集合里切割,要有startIndex,和组合问题是一样的。
private void backtracking(String s, int startIndex) {
终止条件:
在这个树里面可以看出来,当能切到字符串的最末端,就是一个结果,要收集。也就是红线能到最后,因为不是回文的情况会return。
我想到这就会觉得很难,因为要操作字符串,我不知道怎么搞,但其实题解里很简单,就是startIndex就是这个红线,它代表下一次从哪切。
合理的
if (startIndex >= s.length()) {
result.add(new ArrayList(path));
return;
}
单层递归逻辑:
在for (int i = startIndex; i < s.size(); i++)循环中,我们定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在 path中,path用来记录切割过的回文子串。
如何判断回文串?是字符串那部分的题,用双指针就可以了。
这道题的优化提到了动态规划,超过我的能力了,先不看了。
for (int i = startIndex; i < s.length(); i++) {
//如果是回文子串,则记录
if (isPalindrome(s, startIndex, i)) {
String str = s.substring(startIndex, i + 1);
deque.addLast(str);
} else {
continue;
}
//起始位置后移,保证不重复
backTracking(s, i + 1);
deque.removeLast();
}
比如aab,传入的startIndex是0,那么第一次判断回文串返回true。
substring [0,1), 取的是a 啊,这也对不上啊。而且第一刀为什么切在 |aab, 不应该是a|ab吗。
悟了,判断回文的函数是左闭右闭,传入startIndex是0之后,判断[0,0]这个区间是不是回文的,这里就是数组[0] 的意思。
我也不知道我刚才是怎么想的……
再有一个疑难的地方就是,像 a | ab | 这样的情况,切到ab之后发现不是回问,所以continue,i++ 之后,for循环不再进入了,到不了递归的那一行。
所以这个节点就不存在。
不然的话收集结果集只看startIndex是否到达末尾, a | ab | 这样的情况也是到达末尾了的。
而 a | a | b |这种情况,判断出是回文, 进入递归,backTracking(s, i + 1);, 传入的 i = size(), 会在终止条件那里收获结果。
实现时的困难:
主要还是逻辑混乱
对字符串操作的不熟悉。
还有 i 出现的位置变多了,我也开始乱了,什么时候左闭右闭,什么时候左闭右开。
感悟:
直接粘贴代码随想录里的感悟:
这道题目在leetcode上是中等,但可以说是hard的题目了,但是代码其实就是按照模板的样子来的。
那么难究竟难在什么地方呢?
我列出如下几个难点:
切割问题可以抽象为组合问题
如何模拟那些切割线
切割问题中递归如何终止
在递归循环中如何截取子串
如何判断回文
我们平时在做难题的时候,总结出来难究竟难在哪里也是一种需要锻炼的能力。
一些同学可能遇到题目比较难,但是不知道题目难在哪里,反正就是很难。其实这样还是思维不够清晰,这种总结的能力需要多接触多锻炼。
本题我相信很多同学主要卡在了第一个难点上:就是不知道如何切割,甚至知道要用回溯法,也不知道如何用。也就是没有体会到按照求组合问题的套路就可以解决切割。
如果意识到这一点,算是重大突破了。接下来就可以对着模板照葫芦画瓢。
但接下来如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了。
关于模拟切割线,其实就是index是上一层已经确定了的分割线,i是这一层试图寻找的新分割线
除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1。
所以本题应该是一道hard题目了。
可能刷过这道题目的录友都没感受到自己原来克服了这么多难点,就把这道题目AC了,这应该叫做无招胜有招,人码合一。
代码:
class Solution {
List<List<String>> lists = new ArrayList<>();
Deque<String> deque = new LinkedList<>();
public List<List<String>> partition(String s) {
backTracking(s, 0);
return lists;
}
private void backTracking(String s, int startIndex) {
//如果起始位置大于s的大小,说明找到了一组分割方案
if (startIndex >= s.length()) {
lists.add(new ArrayList(deque));
return;
}
for (int i = startIndex; i < s.length(); i++) {
//如果是回文子串,则记录
if (isPalindrome(s, startIndex, i)) {
String str = s.substring(startIndex, i + 1);
deque.addLast(str);
} else {
continue;
}
//起始位置后移,保证不重复
backTracking(s, i + 1);
deque.removeLast();
}
}
//判断是否是回文串
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;
}
}