39. 组合总和 40.组合总和II 131.分割回文串
39. 组合总和
给定一个无重复元素的数组 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] ]
思路:回溯
题目要求【candidates 中的数字可以无限制重复被选取】
那么在for循环遍历时,需要增加子树被重复选取的情况。递归方法设置startIndex 不用i+1了,表示可以重复读取当前的数.将startIndex 设置为i即可
本题需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?
如果是一个集合来求组合的话,就需要startIndex,例如:77.组合 (opens new window),216.组合总和III (opens new window)。
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:17.电话号码的字母组合(opens new window)
注意以上只是说求组合的情况,如果是排列问题,又是另一套分析的套路时间复杂度O(n * 2^n)
其中 n 是数组 candidates 的长度。在大部分递归 + 回溯的题目中,我们无法给出一个严格的渐进紧界,故这里只分析一个较为宽松的渐进上界。 在最坏的情况下,数组中的每个数都不相同。在递归时,每个位置可以选或不选,如果数组中所有数的和不超过 target,那么 2^n种组合都会被枚举到;在 target 小于数组中所有数的和时,我们并不能解析地算出满足题目要求的组合的数量,但我们知道每得到一个满足要求的组合,需要 O(n) 的时间将其放入答案中,因此我们将 O(2^n) 与 O(n)相乘,即可估算出一个宽松的时间复杂度上界。
空间复杂度 o(target)
除答案数组外,空间复杂度取决于递归的栈深度,在最差情况下需要递归 O(target)层。
代码如下
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backTracking(candidates, target, 0);
return result;
}
List<Integer> path = new ArrayList<>();
List<List<Integer>> result = new ArrayList<>();
public void backTracking(int[] candidates, int target, int startIndex) {
int sum = 0;
for (int i = 0; i < path.size(); i++) {
sum = sum + path.get(i);
}
if (sum == target) {
result.add(new ArrayList<>(path));
return;
}
if(sum > target)
return;
if (startIndex >= candidates.length)
return;
for (int i = startIndex; i < candidates.length; i++) {
path.add(candidates[i]);
backTracking(candidates, target, i);// 关键点:不用i+1了,表示可以重复读取当前的数
path.remove(path.size() - 1);
}
}
减枝优化
在没优化代码中,终止条件存在if(sum > target) return;
意味什么呢? sum的值在大于target,还会进入递归。这时有人肯定会有疑问,进入递归又怎么了,能消耗多少时间复杂度呢?举个例子
有10万个数字,其中有9万个数字对应的sum和都大于target,如果做了限制,可减少大量数字进入递归.对数字进行升序排序,排序后for循环中,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。
代码如下
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates);
backTracking(candidates, target, 0);
return result;
}
List<Integer> path = new ArrayList<>();
List<List<Integer>> result = new ArrayList<>();
int sum = 0;
public void backTracking(int[] candidates, int target, int startIndex) {
if (sum == target) {
result.add(new ArrayList<>(path));
return;
}
if (startIndex >= candidates.length)
return;
for (int i = startIndex; i < candidates.length; i++) {
sum = sum + candidates[i];
if(sum > target){ // 减枝优化
sum = sum-candidates[i];
break;
}
path.add(candidates[i]);
backTracking(candidates, target, i);// 关键点:不用i+1了,表示可以重复读取当前的数
path.remove(path.size() - 1);
sum = sum - candidates[i];
}
}
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]
]
思路:回溯
本题开始涉及到一个问题了:去重。
注意题目中给我们 集合是有重复元素的,那么求出来的 组合有可能重复,但题目要求不能有重复组合。
最直接的思路,求出集合后,对集合去重
但是容易超时,所以要在遍历过程中去重
下面代码是求出集合后去重
时间复杂度O(n * 2^n)其中 n 是数组 candidates 的长度。在大部分递归 + 回溯的题目中,我们无法给出一个严格的渐进紧界,故这里只分析一个较为宽松的渐进上界。 在最坏的情况下,数组中的每个数都不相同。在递归时,每个位置可以选或不选,如果数组中所有数的和不超过 target,那么 2^n种组合都会被枚举到;在 target 小于数组中所有数的和时,我们并不能解析地算出满足题目要求的组合的数量,但我们知道每得到一个满足要求的组合,需要 O(n) 的时间将其放入答案中,因此我们将 O(2^n) 与 O(n)相乘,即可估算出一个宽松的时间复杂度上界。
空间复杂度o(n)
除了存储答案的数组外,我们需要 O(n)的空间存储result、递归中存储当前选择的数的列表、以及递归需要的栈。
代码如下
List<Integer> path = new ArrayList<>();
List<List<Integer>> result = new ArrayList<>();
List<List<Integer>> distinctResult = new ArrayList<>();
int sum = 0;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
backTracking(candidates, target, 0);
HashSet<List<Integer>> set = new HashSet<>(result);
for(List<Integer> list : set){
distinctResult.add(list);
}
return distinctResult;
}
public void backTracking(int[] candidates, int target, int startIndex) {
if (sum == target) {
result.add(new ArrayList<>(path));
return;
}
if (sum > target)
return;
if (startIndex >= candidates.length)
return;
for (int i = startIndex; i < candidates.length; i++) {
sum = sum + candidates[i];
if (sum > target) {
sum = sum - candidates[i];
break;
}
path.add(candidates[i]);
backTracking(candidates, target, i + 1);// 关键点:不用i+1了,表示可以重复读取当前的数
path.remove(path.size() - 1);
sum = sum - candidates[i];
}
}
思路:回溯优化版本
本题开始涉及到一个问题了:去重。
注意题目中给我们 集合是有重复元素的,那么求出来的 组合有可能重复,但题目要求不能有重复组合。
最直接的思路,求出集合后,对集合去重
但是容易超时,所以要在遍历过程中去重
所谓的去重,是指元素不能重复
组合问题可以转换为树型的结构。树型结构存在树枝和树层两个维度,那么应该在哪个维度去重呢?
树枝是一个组合,本题组合元素允许重复,不用去重
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
举例如下【1,1,2】 target = 3,
本题符合条件的集合有【1,2】【1,2】,可以发现在for循环遍历时(同一树层),遍历了两次1.那么如果在此处限制去重,那么最终符合条件的集合只有【1,2】一个集合
所以最终目的是在for循环处,对前后遍历的结点val进行判断,如果结点的val遍历过,则跳过
但是还有前提,数组要有序
代码如下
> 时间复杂度O(n * 2^n)
> 其中 n 是数组 candidates 的长度。在大部分递归 + 回溯的题目中,我们无法给出一个严格的渐进紧界,故这里只分析一个较为宽松的渐进上界。
> 在最坏的情况下,数组中的每个数都不相同。在递归时,每个位置可以选或不选,如果数组中所有数的和不超过 target,那么
> 2^n种组合都会被枚举到;在 target 小于数组中所有数的和时,我们并不能解析地算出满足题目要求的组合的数量,但我们知道每得到一个满足要求的组合,需要 O(n) 的时间将其放入答案中,因此我们将 O(2^n)
> 与 O(n)相乘,即可估算出一个宽松的时间复杂度上界。
> 空间复杂度o(n)
> 除了存储答案的数组外,我们需要 O(n)的空间存储result、递归中存储当前选择的数的列表、以及递归需要的栈。
static List<Integer> path = new ArrayList<>();
static List<List<Integer>> result = new ArrayList<>();
static int sum = 0;
public static List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
backTracking(candidates, target, 0);
return result;
}
public static void backTracking(int[] candidates, int target, int startIndex) {
if (sum == target) {
result.add(new ArrayList<>(path));
return;
}
if (sum > target)
return;
for (int i = startIndex; i < candidates.length; i++) {
if (i > startIndex && candidates[i] == candidates[i - 1]) {
continue;
}
sum = sum + candidates[i];
if (sum > target) {
sum = sum - candidates[i];
break;
}
path.add(candidates[i]);
backTracking(candidates, target, i + 1);
path.remove(path.size() - 1);
sum = sum - candidates[i];
}
}
问题
去重代码有误。我的写法为i > 0,本意想过滤树层重复元素
错误代码如下
if (i > 0 && candidates[i] == candidates[i - 1]) {
continue;
}
但是这种写法,无意中过滤【1,1,2,5,6,7,10】 target = 8 中存在重复元素组合的情况。
比如【1,1,6】组合
1.当 i = 0,元素A为1,调用方法递归,将i + 1,元素B为1加入递归
2.如果判断条件为i > 0 && candidates[i] == candidates[i - 1],那么将跳过B = 1,相当于对树枝存在重复元素的情况也过滤,所以最终答案不存在【1,1,6】组合正确写法:这样会避免过滤树枝的元素
if (i > startIndex && candidates[i] == candidates[i - 1]) {
continue;
}
131.分割回文串
给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。
返回 s 所有可能的分割方案。
示例: 输入: “aab” 输出: [ [“aa”,“b”], [“a”,“a”,“b”] ]
思路:回溯
题目要求每个子串都是回文串。
能想到的方法是双指针法定义左右指针,左指针指向最左边元素,右指针指向左指针右侧元素
1.若左右指针指向元素相同,则右指针向后移动
2.若左右指针指向元素不同,将【左指针,右指针-1】字符串截取,左指针移动到右指针的位置
但该方法存在局限,不能找出字符串所有可能的分割方案,只能找出一种题解描述很清楚
本地有两个问题需要解决
1.如何判断一个字符串是否为回文?
- 定义左右双指针指向字符串两侧。若双指针指向元素不相同,则字符串不是回文。若双指针指向元素相同,则左指针+1,右指针-1比较。直至左指针下标大于右指针。
2.如何切割字符串?
该题目不仅要切割字符串,还要有多种方式切割字符串,很难想到
这种切割字符串,找出符合条件的子字符串。其实很类似从遍历一个集合,从中找出符合某些条件的组合。
因此切割问题类似组合问题
所以也可以将切割的过程比作一棵树
红色的切割线位置,是for循环中,startIndex所在位置
如果切割线startIndex能够移动到字符串最后并切割,那么就是合适的子串集合
回溯法三要素
1.方法入参和返回值:入参String str,int startIndex 返回值void
2.中止条件:切割线startIndex移动到字符串最后切割,path加入result
3.核心逻辑
递归循环中截取字符串。在for循环for(int i = startIndex;i<s.size;i++)中,起始位置为startIndex
每次切割的字符串范围为【startIndex,i】,然后判断字符串是否为回文
是回文则加入path,否则continue跳出该循环
代码如下
// 时间复杂度O(n * 2^n) n表示字符串字符个数
// 空间复杂度o(n)
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) {
if (startIndex >= s.length()) {// 中止条件:分割线startIndex到达字符串最后
result.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i < s.length(); i++) {
String str = s.substring(startIndex, i + 1);// 分割区间【start,i】字符串
if (!isPlalindrome(str)) {
continue;
}
path.add(str);
backTracking(s, i + 1);// 当前字符不能重复切割
path.remove(path.size() - 1);// 回溯
}
}
private boolean isPlalindrome(String str) {
int left = 0;
int right = str.length() - 1;
while (left < right) {
if (str.charAt(left) == str.charAt(right)) {
left++;
right--;
} else {
return false;
}
}
return true;
}
问题
边缘条件判断有误
中止条件:需要让切割线移动到字符串的最后,并完成切割
但我的代码在切割线移动到字符串的最后时,就中止,并没有完成切割
导致结果缺少子集
错误代码
if (startIndex == s.length() -1) {// 中止条件:分割线startIndex到达字符串最后
result.add(new ArrayList<>(path));
return;
}
正确代码
if (startIndex >= s.length()) {// 中止条件:分割线startIndex到达字符串最后
result.add(new ArrayList<>(path));
return;
}