前言:
回溯算法三大步骤:
1:递归函数参数返回值
2:终止条件的确定
3:单层递归逻辑
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
回溯法用来解决n个for循环问题
组合问题:
leetcode 77:组合
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
输入:n = 4, k = 2
输出:
[ [2,4], [3,4], [2,3],[1,2], [1,3],[1,4],]
将回溯题目整理成一棵树
以 1234
取1 取2 取3 取4
剩余在234中 剩余在34中 剩余在4中取
1 2 ,1 3 ,1 4 2 3 ,2 4 34
图中列为了一个抽象的树形结构。可以看出这个题目所要求的结点时在叶子结点(这也和我们的递归终止条件有关系)
还有一个需要注意的点:就是我取1的时候,我们会在234中取另一个数,我们取2的时候,只会在34中取,不会回头取,取出(2,1)这样的组合。
这就是通过一个回溯的参数startindex来进行控制的。
class Solution {
List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
backtracing(n,k,1);
return ans;
}
public void backtracing(int n,int k,int startindex){
if(path.size()==k){
ans.add(new ArrayList<>(path));
return;
}
for(int i=startindex;i<=n - (k - path.size()) + 1;++i){
path.add(i);
backtracing(n,k,(i+1));
path.remove(path.size()-1);
}
}
}
需要一个List<List<Integer>> ans来存放最后的答案,
同时需要一个List<Integer> path来存放路径中产生的过程,,如果最后这个path能满足我们的条件(这道题没有条件要求),我们就把这个path加入到ans中。
还有一个点,就是回溯之后,我们需要清理现场。如果说n==4,k==3
当我们取了一个组合1 2 3之后,我们回溯的时候,就要把3弹出去,然后再把4压进来,组成1 2 4。
注意一下这一题的回溯,我们最后需要的是一个二维数组,那path就是一个一维数组
后面的题目有些是要字符串,字符串本身就是一个一维数组,所以,最后答案是一个三维数组,这主要产生的影响就是在回溯清理现场上。
这道题目也可以做一下剪枝操作,如果仔细看这颗树的话,当取4的时候,后面已经没有数字可以取了,所以可以直接返回,不需要再做判断,所以我们在结束条件上:i<=n - (k - path.size()) + 1,节省一部分操作,
其实剪枝处理并不是每一道题目都可以的,只有根据题意进行一些特殊情况的判断。
leetcode 216. 组合总和 III:
找出所有相加之和为 n
的 k
个数的组合,且满足下列条件:
- 只使用数字1到9
- 每个数字 最多使用一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。
这道题就是比上一道题多了一个点:就是输出的组合的和为n,所以,我们需要多来一个tempsum变量,记录当前的组合(path)中的总和。
List<List<Integer>> anscombinationSum3 = new ArrayList<>();
List<Integer> pathcombinationSum3 = new ArrayList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
backtracingcombinationSum3(k,n,0,1);
return anscombinationSum3;
}
public void backtracingcombinationSum3(int k,int n,int tempsum,int startindex){
if(tempsum>n) return;
if(pathcombinationSum3.size()>k) return;
if(pathcombinationSum3.size()==k){
if(tempsum==n) {
anscombinationSum3.add(new ArrayList<>(pathcombinationSum3));
}
return;
}
for(int i=startindex;i<=9;i++){
pathcombinationSum3.add(i);
tempsum += i;
backtracingcombinationSum3(k,n,tempsum,(i+1));
pathcombinationSum3.remove(pathcombinationSum3.size()-1);
tempsum -= i;
}
}
这题终止条件比较容易去想,就是当你的总和等于n时,我们就收集结果。
剪枝操作也很简单:
因为所给的值一定时正整数,所以多遍历一个数字,我们一定总和是加的,所以可以得出 if(tempsum>n) return;
然后我们判断,我们这个当前组合的大小是不是满足k这个思想也可以做一个剪枝:if(pathcombinationSum3.size()>k) return;
leetcode 39 组合总和:
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个。
class Solution {
List<List<Integer>> anscombinationSum = new ArrayList<>();
List<Integer> pathcombinationSum = new ArrayList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backtracingcombinationSum(candidates,target,0,0);
return anscombinationSum;
}
public void backtracingcombinationSum(int[] candidates,int target,int tempsum,int startindex){
if(tempsum>target){
return;
}
if(tempsum==target){
anscombinationSum.add(new ArrayList<>(pathcombinationSum));
return;
}
for(int i=startindex;i<candidates.length;++i){
pathcombinationSum.add(candidates[i]);
tempsum += candidates[i];
backtracingcombinationSum(candidates,target,tempsum,i);
pathcombinationSum.remove(pathcombinationSum.size()-1);
tempsum -= candidates[i];
}
}
}
这道题目其实和组合总和三很像,组合总和3题目要求只能在1~9中挑选数字,那我们递归条件就是for(int i=startindex;i<=9;i++){
组合总和这道题目给了我们一个数组candidates,所以我们只需要从这个数组中去遍历就行,然后再加上一些剪枝操作就行。
leetcode 40 组合总和二
给定一个候选人编号的集合 candidates
和一个目标数 target
,找出 candidates
中所有可以使数字和为 target
的组合。
candidates
中的每个数字在每个组合中只能使用 一次 。
注意:解集不能包含重复的组合。
这道题目和前两道题目有什么不同点呢,就是数组给的元素里面会有重复元素,可最后答案输出的元素却不能包含重复的组合,
所以这里就会有一个去重的操作,就是使用过的数不能再使用:
这里的去重操作是如何操作的呢:
先将给定的数组元素进行排序,然后用了一个visited数组来记录元素是否使用过
class Solution {
List<List<Integer>> anscombinationSum2 = new ArrayList<>();
List<Integer> pathcombinationSum2 = new ArrayList<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
boolean[] visited = new boolean[candidates.length];
backtracingcombinationSum2(candidates,target,0,0,visited);
return anscombinationSum2;
}
public void backtracingcombinationSum2(int[] candidates,int target,int tempsum,int startindex,boolean[] visited){
if(tempsum>target){
return;
}
if(tempsum==target){
anscombinationSum2.add(new ArrayList<>(pathcombinationSum2));
}
for(int i=startindex;i<candidates.length;++i){
if(i>0&&candidates[i]==candidates[i-1]&&!visited[i-1]){
continue;
}
visited[i] = true;
tempsum += candidates[i];
pathcombinationSum2.add(candidates[i]);
backtracingcombinationSum2(candidates,target,tempsum,(i+1),visited);
visited[i] = false;
tempsum -= candidates[i];
pathcombinationSum2.remove(pathcombinationSum2.size()-1);
}
}
}
注意看这一段代码:if(i>0&&candidates[i]==candidates[i-1]&&!visited[i-1]){
continue;
}
这里还有一个点就是代码随想录中提到的两个概念就叫做树枝去重和树层去重
什么意思呢:就拿 candidate:1,1,2 target=2来表示:
这个例子很明显有两个组合:【1,1】和【2】
那怎么解释上面的代码呢:先对数组进行排序,
比如你先从第一个一开始遍历,你把第一个以放到path中,然后visited变成:【true false false】
这个时候,我们可以选第二个1嘛,我们是可以的,因为第一个1和第二个1是不同的元素,不过只是他们数值相同而已。
所以你这个时候带进去代码:visited[i-1] = true,!visited[i-1]=false,这个循环就不会被跳过。
然后当你回溯把第一个1以下的树都遍历完之后,visited[i-1] = false,!visited[i-1] = true,这个时候,如果就会continue,就会跳过循环。
也说明candidate【i-1】被用过了。这一段说实话比较抽象,主要是得对回溯这个过程多了解以下,然后慢慢把代码记下来。
这一段搞好之后,也说明这道题目的去重已经做完了,那这个题目就和前两到题目一样了。
leetcode 17. 电话号码的字母组合
给定一个仅包含数字 2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
输入:digits = "23" 输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
class Solution {
List<String> recletterCombinations = new ArrayList<>();
StringBuilder pathletterCombinations2 = new StringBuilder();
Map<Integer, String> phoneMap = new HashMap<Integer, String>(){{
put(2, "abc");
put(3, "def");
put(4, "ghi");
put(5, "jkl");
put(6, "mno");
put(7, "pqrs");
put(8, "tuv");
put(9, "wxyz");
}};
public List<String> letterCombinations(String digits) {
if(digits.length()==0) return recletterCombinations;
int[] intdigits = new int[digits.length()];
for(int i=0;i<digits.length();++i){
intdigits[i] = digits.charAt(i)-'0';
}
backtracingletterCombinations(intdigits,0);
return recletterCombinations;
}
public void backtracingletterCombinations(int[] intdigits,int startindex){
if(pathletterCombinations2.length()==intdigits.length){
recletterCombinations.add(pathletterCombinations2.toString());
return;
}
for(int i=startindex;i<intdigits.length;++i){
for(int j=0;j<phoneMap.get(intdigits[i]).length();++j){
pathletterCombinations2.append(phoneMap.get(intdigits[i]).charAt(j));
backtracingletterCombinations(intdigits,(i+1));
pathletterCombinations2.deleteCharAt(pathletterCombinations2.length()-1);
}
}
}
}
分析这道题目也是一个组合问题,主要是还涉及到了一些数据的处理:
1:你要把这个电话号码里面的内容变成哈希表,然后再后面的使用中调用
2:题目给的是一个字符串,不过里面只包含数字,所以你得做一些处理,
其它的这一题整个过程就和前两道题目差不多:
这一题还有一一个点需要注意,就是关于这个startindex的使用,前两道题目用这个下标来当成这个分割线,使得之前使用过的元素不会再被使用
不过这道题我看了题解,题解好像不需要使用,不过我自己做的时候还是使用了,就是我startindex用来控制我现在遍历到第几个集合。
分割问题:
分割问题就是类似于将一个字符串按要求切割成我们要求的字符串的组合
还有一个非常需要值得注意的点
leetcode 131分割回文串
答案返回值是一个List<List<String>>
这其实是一个三维数组
leetcode 93复原IP地址
答案返回值是一个List<String>
这其实是一个二维数组,为什么要强调这个呢,主要是在这个回溯回复现场这一步中有很大的不同。
leetcode 131分割回文串
给你一个字符串 s
,请你将 s
分割成一些子串,使每个子串都是
回文串 。返回 s
所有可能的分割方案。
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
class Solution {
List<List<String>> anspartition = new ArrayList<>();
List<String> pathpartition = new ArrayList<>();
public List<List<String>> partition(String s) {
char[] charArray = s.toCharArray();
int len = s.length();
// 预处理
// 状态:dp[i][j] 表示 s[i][j] 是否是回文
boolean[][] dp = new boolean[len][len];
// 状态转移方程:在 s[i] == s[j] 的时候,dp[i][j] 参考 dp[i + 1][j - 1]
for (int right = 0; right < len; right++) {
// 注意:left <= right 取等号表示 1 个字符的时候也需要判断
for (int left = 0; left <= right; left++) {
if (charArray[left] == charArray[right] && (right - left <= 2 || dp[left + 1][right - 1])) {
dp[left][right] = true;
}
}
}
backtracing(s,0,dp);
return anspartition;
}
public void backtracing(String s,int startindex,boolean[][] dp){
if(startindex==s.length()){
anspartition.add(new ArrayList<>(pathpartition));
return;
}
for(int i=startindex;i<s.length();++i){
if(dp[startindex][i]){
pathpartition.add(s.substring(startindex,(i+1)));
}else{
continue;
}
backtracing(s,i+1,dp);
pathpartition.remove(pathpartition.size()-1);
}
}
}
重点看回溯函数:其实回溯函数本质是和组合问题差不了太多,在单层逻辑中我们有一个if判断语句。
重点看恢复现场这一下,pathpartition.remove(pathpartition.size()-1);
我的问题是,像之前的组合问题,我们都是把一个一个数字移进去,所以我们出来的恢复现场的时候我们直接把path数组中最后一个数字移出来就好
不过这道题是一个分割字串,我们放到path数组中的元素可能是一个字串String,不是一个元素,那这个时候我们为什么可以直接用pathpartition.remove(pathpartition.size()-1);这样一个函数把字串移出来呢,这就是我的问题,所以这就得益于我们返回的那个函数是一个三维数组,那path就是一个二维数组,path做remove这样的处理,就是移动一个一维数组,也就是一个String,我们往path中放,不管是放一个字母还是n个字母,都是一个字符串,都是一个整体(一维数组),移动出来的就是一个整体,这就是这道题我觉得和下一道题的最大不同,下一道题(复原IP地址),因为我们往里面放的时候,不仅我们还要多加一个 '.' 我们再移出来的时候,我们还要去考虑下标,举个例子:你往path数组中放了12两个字符,和一个.,那你这时候移出来的就得是一个长度为3的字符串。所以对于下标的处理非常的麻烦
leetcode 93复原IP地址
有效 IP 地址 正好由四个整数(每个整数位于 0
到 255
之间组成,且不能含有前导 0
),整数之间用 '.'
分隔。
- 例如:
"0.1.2.201"
和"192.168.1.1"
是 有效 IP 地址,但是"0.011.255.245"
、"192.168.1.312"
和"192.168@1.1"
是 无效 IP 地址。
给定一个只包含数字的字符串 s
,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s
中插入 '.'
来形成。你 不能 重新排序或删除 s
中的任何数字。你可以按 任何 顺序返回答案。
输入:s = "25525511135" 输出:["255.255.11.135","255.255.111.35"]
public void backtracing(StringBuilder path,int startindex){
if(pointnumber==3){
if(isValid(path,startindex,path.length()-1)){
ans.add(path.toString());
}
return;
}
for(int i=startindex;i<path.length();++i){
if(isValid(path,startindex,i)){
pointnumber++;
path.insert(i+1,'.');
backtracing(path,i+2);
pointnumber--;
path.deleteCharAt(i+1);
}
}
}
鉴于上面说的问题,我们想到的解决办法是,直接在这个原来的字符串上面加点,
然后,至于一些其它的操作,比如记录一下标点符号的个数作为终止条件。
子集问题:
子集问题和排列组合差别具体体现在哪里嘞:
排列组合时在这颗树的叶子结点收集结果,不过子集问题是在路劲上就要收集结果了。
leetcode 78:子集
给你一个整数数组 nums
,数组中的元素 互不相同 。返回该数组所有可能的
子集(幂集)。解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
输入:nums = [1,2,3] 输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
class Solution {
List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> subsets(int[] nums) {
backtracing(nums,0);
return ans;
}
public void backtracing(int[] nums,int startindex){
if(startindex==nums.length+1){
return;
}
ans.add(new ArrayList<>(path));
for(int i=startindex;i<nums.length;++i){
path.add(nums[i]);
backtracing(nums,i+1);
path.remove(path.size()-1);
}
}
}
具体体现在代码中是怎么样的呢:就是我们对于终止条件的判断,在组合排列问题的时候,我们需要去算path的长度是否等于原来数组的长度,或者题目要求的个数,不过子集问题,我们不需要,我们往path中添加了什么,我们就收集什么。
leetcode 90:子集II
给你一个整数数组 nums
,其中可能包含重复元素,请你返回该数组所有可能的
子集(幂集)。解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。
class Solution {
List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums);
boolean[] visited = new boolean[nums.length];
backtracing(nums,0,visited);
return ans;
}
public void backtracing(int[] nums,int startindex,boolean[] visited){
ans.add(new ArrayList<>(path));
for(int i=startindex;i<nums.length;++i){
if(i>0&&nums[i-1]==nums[i]&&!visited[i-1]){
continue;
}
visited[i] = true;
path.add(nums[i]);
backtracing(nums,i+1,visited);
visited[i] = false;
path.remove(path.size()-1);
}
}
}
这道题目和前一题的不同也是一个重复元素的利用,所以我们还是老规矩用visited数组来记录一些那些元素用过,那些元素没用过就行。
排列问题:
排列和组合有很大不同:就是排列时有序的,组合时无序的,就和数学集合中的C和A一样
leetcode 46:全排列
给定一个不含重复数字的数组 nums
,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
输入:nums = [1,2,3] 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
class Solution {
List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> permute(int[] nums) {
boolean[] used = new boolean[nums.length];
backtracing(nums,used);
return ans;
}
public void backtracing(int[] nums,boolean[] used){
if(path.size()==nums.length){
ans.add(new ArrayList<>(path));
return;
}
for(int i=0;i<nums.length;++i){
if(used[i]){
continue;
}
used[i] = true;
path.add(nums[i]);
backtracing(nums,used);
used[i] = false;
path.remove(path.size()-1);
}
}
}
看给的例子就能看成,排列时有顺序的分别的:所以我们要做的第一个改变,就是对startindex的改变,我们这个时候已经可以不需要startindex来控制分割线了,所以我们再单层遍历逻辑里面将i的初值赋值为0,然后进行遍历
但是如果单单修改这个一个地方,还是不够的,我们就会出现【111】【222】【333】或者【121】这种不符合规定的排列
那我们怎么处理嘞,就是用一个used数组来记录这个数是否用过了,用过的赋值为true,回溯的时候再变成false。
总结:
每层都是从0开始搜索而不是startIndex
需要used数组记录path里都放了哪些元素了
leetcode:47 全排列 II:
给定一个可包含重复数字的序列 nums
,按任意顺序 返回所有不重复的全排列。
输入:nums = [1,1,2] 输出: [[1,1,2], [1,2,1], [2,1,1]]
class Solution {
List<List<Integer>> ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> permuteUnique(int[] nums) {
boolean[] used = new boolean[nums.length];//用use数组来进行记录path里面放了那些元素
boolean[] visited = new boolean[nums.length];//用visited数组来进行去重操作
Arrays.sort(nums);
backtracing(nums,used,visited);
return ans;
}
public void backtracing(int[] nums,boolean[] used,boolean[] visited){
if(path.size()==nums.length){
ans.add(new ArrayList<>(path));
return;
}
for(int i=0;i<nums.length;++i){
if(used[i]){
continue;
}
if(i>0&&nums[i]==nums[i-1]&&!visited[i-1]){
continue;
}
used[i] = true;
visited[i] = true;
path.add(nums[i]);
backtracing(nums,used,visited);
used[i] = false;
visited[i] = false;
path.remove(path.size()-1);
}
}
}
这道题目和上一题有什么区别嘞,就是给定的元素会重复,并且结果输出的时候不允许重复数组的输出,这又涉及到一个去重的操作:
我们的思路也很简单就是和,组合去重一样的处理方法:用一个visited来记录那些数组被使用过了,然后,在单层搜索逻辑中多加一个if判断语句。