1. 关于回溯
回溯/递归,本质上是把问题抽象成一个树型结构,然后用树的思路来解决。树是一种思考工具,我们说到树,是基于树这种数据排列方式来进行数据的处理,而不是呆板地视作一种固定的数据结构再来解决它。
回溯的应用场景
1. 组合问题
2. <待施工>
回溯三要素
1. 目的:穷举整棵树的path,并记录.add(path...)
- 注意:path是引用,无论path属于哪种数据结构,都不能直接result.add(path),需要新建一个list:new ArrayList<>(path)
2. 停止:碰到叶节点停止,或碰到目标节点<剪枝>
3. 停止后要回退
- 若path为LinkedList:path.removeLast();注意可能需要result.add(new ArrayList<>(path));
- 若path为ArrayList: path.remove(path.size()-1)
- 若path为StringBuilder: path.deleteCharAt(index)
2. 例题
1. #lc77组合
给定两个整数
n
和k
,返回范围[1, n]
中所有可能的k
个数的组合。
前置思路
以[1,2,3,4]中取2个数的组合为例,将排列组合结果以树的方式陈列出来,就会发现,需要记录所有长度为k的path,即套娃遍历for循环要进行k次,每次遍历则需要涵盖本层树上的所有节点,即n/n-1/n-2/...个节点
*问:怎么确保遍历的时候不重复收集之前已经录入path的节点?——用startIndex来锚定每次递归开始的位置
三步思路
1. 本次递归需要哪些参数?返回值是什么?
- 一维数组path,用来记录每一种组合方式,即遍历组合结果这颗树里的每条路径
- 二维数组result,储存所有的path,并最终作为答案返回【确定类的返回值】【递归返回值:void】
- int n:每层递归涵盖的节点数
- int k:需要套娃遍历的次数,也是path的长度
- int startIndex:每次递归开始的位置
2. 终止条件?
- 碰到叶节点停止,即path的长度==k时,并把这条path记录进result
3. 本层树递归时的逻辑怎么写?
- 录入path新元素:本层当前节点值
- 递归调用自身:进行【套娃for遍历】,并【修改递归起始值】
- 记得【回退】
代码实现
class Solution {
//定义全局变量path和result
LinkedList<Integer> path = new LinkedList<>();
List<List<Integer>> result = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
backtrack(n,k,1);
return result;
}
public void backtrack(int n, int k, int startIndex){
//终止条件:碰到叶节点,path记录完毕,录入result
if(path.size()==k){
result.add(new ArrayList<>(path));
}
//每层递归:套娃for循环,直到遍历完这一层的每个节点到n为止,并注意移动每次递归时的开始位置
for(int i = startIndex; i<=n; i++){
path.add(i);
backtrack(n,k,i+1); //注意这里以i作为移动标尺而不是startIndex,后者是一个固定值
path.removeLast();
}
}
}
2. #lc216组合总和(同一集合,无重复元素,不可重复)
思路
同上
易错点
注意回溯path时sum也要回退
代码实现
class Solution {
public List<List<Integer>> result = new ArrayList<>();
public LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
backtrack(k,n,0,1); //注意start值
return result;
}
public void backtrack(int k,int n, int sum, int start){
if(path.size()==k && sum==n){
result.add(new ArrayList<>(path));
return;
}
if(path.size()>k || sum>n){
return;
}
for(int i=start; i<=9;i++){
path.add(i);
sum+=i;
backtrack(k,n,sum,i+1);
path.removeLast();
sum-=i; //注意回溯的时候sum也要回退
}
}
}
3. #lc37 组合总和(同一集合,可以重复)
思路
可以无限重复:没有path长度的限制,不需要考虑每次套娃循环的时候前进一位了,直接调用递归函数自己
但是:需要考虑递归的起始位置!<待施工>
代码实现
class Solution {
public List<List<Integer>> result = new ArrayList<>();
public List<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
backtrack(candidates,target,0,0);
return result;
}
public void backtrack(int[]candidates, int target,int sum, int start){
//停止:如果==sum或candidates的遍历超出其长度
if(sum==target){
result.add(new ArrayList<>(path));
return;
}
if(sum > target){
return;
}
//注意:这里仍然需要判定递归的起始位置start,但是递归调用自己的时候不必将起始值+1了,可以从自身开始递归
//每一次做组合,都要考虑起始位置的问题!(如果是多个集合互相搭配则不用)
for(int i=start;i<candidates.length;i++){
sum+=candidates[i];
path.add(candidates[i]);
//这里难道是一直递归自己吗?——对
backtrack(candidates,target,sum,i);
sum-=candidates[i];
path.remove(path.size()-1);
}
}
}
4. #lc 组合总和(同一集合,有重复数值但不能重复)
思路
同上,但此时需要考虑不能重复
易错点
注意:有重复元素就意味着,可能他们会和同一个元素搭配重复!如[1,1,7]中的1分别和7搭配。如何去重?sort+当前值与前一位比较是否重复
注意:在result.add()的时候,不能直接添加path这个<引用>,需要新建一个list new ArrayList<>(path)
代码实现
class Solution {
public List<List<Integer>> result = new ArrayList<>();
public List<Integer> path = new ArrayList<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
backtrack(candidates,target,0,0);
return result;
}
public void backtrack(int[] candidates, int target, int sum, int start){
if(sum==target){
//注意不能直接添加path,这样的话path的结果会随着每次递归的变化而变
result.add(new ArrayList<>(path));
return;
}
if(sum>target){
return;
}
for(int i=start;i<candidates.length;i++){
//把当前值和前一位比较看是否重复;注意如果start=0会有-1的问题
if(i>start && candidates[i]==candidates[i-1]){
continue;
}
sum+=candidates[i];
path.add(candidates[i]);
backtrack(candidates,target,sum,i+1);
sum-=candidates[i];
path.remove(path.size()-1);
}
}
}
5. #lc17 电话号码组合(不同集合,字符串)
前置知识点(字符串)
- 获取字符串长度:str.length()
- 获取字符串某索引位的值:str.charAt(index)
- 将数字字符转为整型数字:'stringnum'-'0' (如'1'-'0'=int 1)
- 用stringbuilder快速拼接字符串:
- 新增:sb.append()
- 删除:sb.deleteCharAt(index)
- 获取长度:sb.length()
思路
- 建立一个map,存放不同数字与26位字母字符之间的映射关系,注意0-1不在里面
- 确定遍历的层数和每层宽度
- 分割digits,测量它的长度,即path的长度,即递归(停止)的层数
- 确定每层递归遍历节点的数量,即每个数字对应的字符数量。注意:这里不是固定为3!
- 进行递归
- 确定参数:一个path存储每次路径——用stringbuilder,一个result存储所有path——list<string>,一个字典strMap存储所有数字和字母的映射关系——string[] 直接用默认index作为key去映射字母value
- 停止条件:digits里的每个数字都遍历到了/path的长度和digits长度一致
- 每次都提取每个数字对应字符串索引位上的字母
- 将提取出来的字母组合进path——用stringbuilder
- 记得回退——用stringbuilder
易错点
注意如果digits为空或者非数字字符串的情况
代码实现
class Solution {
public List<String> result = new ArrayList<>();
public StringBuilder strPath = new StringBuilder();
public List<String> letterCombinations(String digits) {
//注意:这里要设digit为空或非数字字符串的情况
if(digits == null || digits.length() == 0){
return result;
}
String[] strMap = {"","","abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"};
backtrack(digits,strMap,0);
return result;
}
public void backtrack(String digits,String[] strMap, int index){
//停止:index已经遍历到了digits的最后一位
if(index==digits.length()){
result.add(strPath.toString());
return;
}
/**把digits中的每一位提取出来,通过strMap找到对应的字母组,产出str
a.进行索引定位时:用-'0'的ASCII码相减,将数字字符转化为整型数字
b.获取str字母组后:此时获得的是一个多胞胎,要遍历的时候还要再来一遍.charAt(index)
*/
String str = strMap[digits.charAt(index)-'0'];
//再提取str中的每一个字母并拼接之,再递归到下一层,找digits的下一位数字并重复上述动作
for(int i=0; i<str.length();i++){
strPath.append(str.charAt(i));
backtrack(digits,strMap,index+1);
strPath.deleteCharAt(strPath.length()-1);//记得回退,删除最后一个数字
}
}
}