回溯算法说白了就是穷举法。所有回溯法解决的问题都可以抽象为树形结构,因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
😚表示codetop里的高频题。
0.做题步骤
1.判断是不是用回溯
总体而言就是这道题必须要罗列所有的可能性才能得到答案。
可以解决的问题:对于一维数组有罗列组合,排列
组合问题:N个数里面按一定规则找出k个数的集合
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
排列问题:N个数按一定规则全排列,有几种排列方式
棋盘问题:N皇后,解数独等等
2.画树形图
这里看下面的回溯三部曲模板
3.套对应题型的模板
4.要去重吗?
1.总结
回溯函数里的参数要不要有startindex?
排列有顺序,所以不用startindex,每层都是从0开始搜素。
组合问题中:如果是一个集合的话,就需要startIndex。但如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex。
是不是在叶子节点里找答案?
组合 分割 排列 棋盘都是在叶子节点里找答案。叶子节点就是当收集元素的数组path的大小达到和nums数组一样大的时候,result add然后return。只有子集是在所有节点里找答案。回溯里不用判断return也ok。
判断逻辑复杂的都是另起一个子函数。
组合问题就最简单最基本。
切割问题有两种割法:1.用StringBuilder定义 2.substring截取
子集问题在所有节点里找答案,在回溯开头就要记录所有路径节点。
排列问题i初始都从0开始,而不是startindex;还有要用boolean的used数组记录那些值用过。
棋盘问题就是二维搜索,先for检索row,再检索col,在col里给值递归回溯撤销。
去重的两种类型
1.输入内容只能用一次
1.输入内容只能用一次,树枝去重,确保同一个元素在一条路径(树枝)中不会被重复使用。
去重实现方式:
(1)让递归的时候回溯函数中的startindex从i+1开始就够了。
(2)如果是排列问题不用startindex,那就用used标记判断:used[i - 1] == true就说明同一树枝candidates[i - 1]使用过,continue就完事。
2.输出内容不能重复
2.输出内容不能重复,树层去重,确保递归中同一层重复选择相同的值导致最后结果重复。
去重实现方式:
(1)排序后判断相邻元素:
组合子集切割问题:if (i > startIndex && nums[i] == nums[i-1]) continue。
排列问题:if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue。
(2)不能排序使用hasp
性能分析 没太有用
1.子集问题分析:
时间复杂度:O(2n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2n)
空间复杂度:O(n),递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)
2.排列问题分析:
时间复杂度:O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。
空间复杂度:O(n),和子集问题同理。
3.组合问题分析:
时间复杂度:O(2^n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
空间复杂度:O(n),和子集问题同理。
4.N皇后问题分析:
时间复杂度:O(n!) ,其实如果看树形图的话,直觉上是O(n^n),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是O(n!),n!表示n * (n-1) * … * 1。
空间复杂度:O(n),和子集问题同理。
5.解数独问题分析:
时间复杂度:O(9^m) , m是’.'的数目。
空间复杂度:O(n2),递归的深度是n2
回溯三部曲模板
- 回溯函数模板返回值以及参数:我需要什么,需要返回什么
- 回溯函数终止条件:我什么时候停下
- 回溯搜索的遍历过程:我怎么找答案。先根据for横向遍历摞一格,再根据递归纵向遍历直到叶子节点。每一颗树都是这样。

void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
for循环就是遍历集合区间,就是横向遍历,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归,就是纵向遍历。
看到回溯的题目先想树是个什么样的结构。
2.组合问题 数组
39题 能重复 40题 不能重复,要去重😚
这两题都高频。
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的数字可以无限制重复被选取。
最后要给出的是一个数组,并且组合之间没有相关性。所以只能暴力回溯。
要从 a 中选取组合,a是主体,所以a里的东西构成树的横向和纵向。
横向是for循环,a中有几个元素,横向就是几个。
纵向是调用了回溯几次,就是递归部分,这个没有固定的长度,停下的条件有两个:
1.已经满足了目标或者已经不可能满足了(剪枝操作)。
2.递归到无法继续选择有效数字(本题组合中的数可以无限制被选取所有没有这个条件)。但是,如果不允许数字重复选取,在代码中通过 i + 1来控制每次选择的范围,就不用在递归终止这额外考虑了。
目标是要选a中和为b,所以b决定最后所有组合被选中的条件。
注意:
忘记排序:导致无法提前剪枝,效率大幅降低 。Arrays.sort(candidates);
错误传递start:传i+1会导致数字不能重复使用
浅拷贝问题:未new ArrayList直接add(path)会导致结果被修改。如果result.add(path);这样添加的是path的引用地址,后续path数值变化,result的结果也变化。 所以需要new ArrayList<>(path) 来创建 path 的副本,保存当前path的值。
backtracking(result,list,candidates,target,sum+candidates[i],i+1);去重的时候,sum+candidates[i],因为是对当前数据求和。
39 输入数组可以重复拿
for里必须得是i<candidates.length&&sum<=target。要有后面那个剪枝条件,否则不小心超了就会一直递归导致栈内存爆了StackOverflowError。

40 输入数组的数字在每个组合中只能用一次+解集不能有重复的组合
💥输入数组没说不重复,就要先进行排序Arrays.sort(candidates);。
💥 for里递归式backtracking(candidates,target,sum,i+1);可以保证输入数组的数字在每个组合中只能用一次
💥for里if(i>startindex&&candidates[i] == candidates[i-1]) continue; 可以保证解集不能有重复的组合
其余和39一样。

17题 电话号码的字母组合😚
高频。


22题 括号生成
数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
因为path数组可以直接覆盖,所以不用恢复现场。
有效括号肯定是先左括号再右括号。


组合模板
主函数:
1.在主函数外面 先创建两个变量:二维数组result,一维数据path。
2.对a数组进行排序。
3.调用回溯函数。
4.返回结果。
回溯函数:
先if 再for。
if是筛选判断,递归终止条件,add,return。注意add的是new ArrayList<>(path),而不是path。先if就表明了无论递归到哪了,只要满足就记录答案。
for小括号里是横向遍历,遍历a的元素,记得i从index开始;
for大括号里先剪枝再添加节点到path组合里,再递归调用回溯,最后remove前面path里添加的节点,使用path.remove(path.size() - 1)这是list类通用的。
注意递归的index 从i开始表示可以重复取,从i+1开始表示不可以重复取。剪枝就是if不可能是目标组合了,就break。
注意最后的remove代表着回退,其目的是 让当前分支结束后,能够回到父节点状态尝试其他分支。
需要去重:
40题要求 candidates 中的每个数字在每个组合中只能使用一次,candidates的元素是有重复的,最后解集不能包含重复的组合。
如果只是要求candidates的数只能使用一次,那么让递归的时候回溯函数中的index从i+1开始就够了。
但是它还要求有重复的原数组里选出不能重复的解集,这里有两种解决方法:
1.加一个bool型数组used,用来记录同一树枝上的元素是否使用过。
2.剪枝,在for大括号里
if ( i > index && candidates[i] == candidates[i - 1] ) { //i > index是因为没有这个i-1就不合法
continue; //如果同一层里当前元素和前一个元素相同,就跳过当前元素,继续下一次循环。
}
3.切割问题 字符串
字符串切割两种方法,
1用StringBuilder 2用substring变换上下限。 感觉substring更简单些,记住它是左闭右开。
判断的条件可以单写一个布尔类型的函数。
131.分割回文串

注意 是截取。
怎么切割,用切割线,也就是递归参数传入startIndex,表示下一轮递归遍历的起始位置。截取子串就是[startIndex, i]
判断一个字符串是否是回文:可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。
方法一用substring
注意substring是左闭右开。
终止条件是到字符遍历到最后一个,也就是处理完了整个字符串。 startindex从0开始,但由于递归是backtracking(s,i+1);所以到最后就是s的长度(+1了)。切割->递归后的startindex就是上一刀的末尾+1

方法二用StringBuilder
也就是回溯函数里for处理节点变了,用sb可变的字符串当路径结果。

class Solution {
List<List<String>> result=new ArrayList<>();
List<String> path=new ArrayList<>();
public List<List<String>> partition(String s) {
backtracking(s,new StringBuilder(),0);
return result;
}
public void backtracking(String s,StringBuilder sb,int startindex){
if(startindex==s.length()){//在递归到达字符串末尾时,保存当前路径的副本作为
result.add(new ArrayList<>(path));
return;
}
for(int i=startindex;i<s.length();i++){
sb.append(s.charAt(i));//sb是string,所以append
if(check(sb)){
path.add(sb.toString());//path是list,所以是add。里面类是string,如果直接加sb,sb是StringBuilder就是可变的。如果有toString,那就将可变字符串内容转化为不可变字符串后保存了。
backtracking(s,new StringBuilder(), i+1);//递归时传入新的 StringBuilder 是为了:隔离不同层的选择:每层递归处理独立的子串片段;避免跨层污染:防止上层递归修改影响下层处理
path.remove(path.size()-1);
}
}
}
public boolean check(StringBuilder sb){
for(int i=0;i<sb.length()/2;i++){
if(sb.charAt(i)!=sb.charAt(sb.length()-1-i)) return false;
}
return true;
}
}
93. 复原 IP 地址 😚
好题。高频
substring写法

class Solution {
List<String> result= new ArrayList<>();
public List<String> restoreIpAddresses(String s) {
if (s.length() > 12) return result; // 算是剪枝了
backtracking(s,0,0);
return result;
}
public void backtracking(String s,int startindex,int pointnum){
if(pointnum==3){
if(isvalid(s,startindex,s.length()-1)){
result.add(s);
}
return;
}
for(int i=startindex;i<s.length();i++){
if(isvalid(s,startindex,i)){
s=s.substring(0,i+1)+"."+s.substring(i+1);//subString左闭右开,光写左边不写右边就是截取剩余所有的。
pointnum++;
backtracking(s,i+2,pointnum);// 插入逗点之后下⼀个子串的起始位置为i+2
pointnum--;
s=s.substring(0,i+1) + s.substring(i + 2);//删掉逗号
}else{
break;
}
}
}
// 判断字符串s在左闭右闭区间[start, end]所组成的数字是否合法
private boolean isvalid(String s,int startindex,int endindex){
if(startindex>endindex) return false;
if(s.charAt(startindex)=='0' && startindex!=endindex) return false;//单个0合法,但是01这种就不合法
int num=0;
for(int i=startindex;i<=endindex;i++){
if(s.charAt(i)<'0'||s.charAt(i)>'9'){
return false;
}
num=num*10+(s.charAt(i)-'0');
if(num>255) return false;
}
return true;
}
}
4.子集问题

如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。
跟组合差不多:78子集 90子集2要求结果去重 491子集问题特殊去重。
子集模板
class Solution {
List<List<Integer>> result=new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> subsets(int[] nums) {
backtracking(nums,0);
return result;
}
public void backtracking(int[] nums,int startindex){
result.add(new ArrayList<>(path));// 收集子集,要放在终止添加的上面,否则会漏掉自己
if(startindex>=nums.length) return;
for(int i=startindex;i<nums.length;i++){
path.add(nums[i]);
backtracking(nums,i+1);
path.remove(path.size()-1);
}
}
}
78子集😚
高频
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
注意没有终止条件,每次递归都返回。

491 非递减子序列 哈希记录值来在同一层去重
本题求自增子序列,是不能对原数组进行排序的,排完序的数组都是自增子序列了。
因为这题不能排序,所以必须用哈希记录出现过的元素来进行去重。
考的概率很低。


为什么可以用哈希记录一层之内的数后不用清零吗?那下层的数据怎么和上层的区分

5.排列问题
首先排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。
但排列问题需要布尔类型的used数组,记录此时path里都有哪些元素使用了,因为一个排列里一个元素只能使用一次。
46.全排列 输入数组不含重复的 😚
简单基础。高频
排列一定要用used数组,在处理节点的时候和path加/删 一同行动。如果是如果true说明这次path用过这个数了就要continue跳过。

class Solution {
List<List<Integer>> result =new ArrayList<>();
List<Integer> path=new ArrayList<>();
boolean[] used;
public List<List<Integer>> permute(int[] nums) {
if (nums.length == 0){
return result;
}
used= new boolean[nums.length];
backtracking(nums);
return result;
}
public void backtracking(int[] nums){
if(path.size()==nums.length){
result.add(new ArrayList<>(path));
return;
}
for(int i=0;i<nums.length;i++){
if(used[i]) continue;
used[i]=true;//用到哪个,就标注一下。
path.add(nums[i]);
backtracking(nums);
path.remove(path.size()-1);
used[i]=false;//不用了也标注一下。
}
}
}
47.全排列二 输入数组有可能包含重复的,要求结果不重复的。
核心就下面这一行,其余都和46一样。去重别忘了还要used[i-1]== true
if(used[i]||(i>0&&nums[i]== nums[i-1]&&used[i-1]== true)) continue;

class Solution {
List<List<Integer>> result=new ArrayList<>();
List<Integer> path=new ArrayList<>();
boolean[] used;
public List<List<Integer>> permuteUnique(int[] nums) {
Arrays.sort(nums);
if(nums.length==0) return result;
used = new boolean[nums.length];
backtracking(nums);
return result;
}
public void backtracking(int[] nums){
if(path.size()==nums.length){
result.add(new ArrayList<>(path));
return;
}
for(int i=0;i<nums.length;i++){
if(i>0&&nums[i]==nums[i-1]&&used[i-1]==true) continue;
if(used[i]==false){
used[i]=true;
path.add(nums[i]);
backtracking(nums);
used[i]=false;
path.remove(path.size()-1);
}
}
}
}
回溯都是深度遍历。used添加后,递归会返回到第一层,靠的就是used false和path remove那两句。
回到第一层了,used又被重置为全false了,再根据for进行横向遍历摞一位再纵向遍历到叶子节点进行判断。
332. 重新安排行程
即给定一组机票(出发地 → 目的地),从 “JFK” 开始,按字典序返回最小的行程路线。
因为有要求有且只能用一次票,所以要用used标注。
6.棋盘问题
51 N皇后问题 😚
中频
一行只有一个q,一列只有一个q,斜线上也最多一个q。



class Solution {
List<List<String>> result = new ArrayList<>();
public List<List<String>> solveNQueens(int n) {
char[][] chessboard=new char[n][n];
for(char[] c:chessboard){
Arrays.fill(c,'.');
}
backtracking(n,0,chessboard);
return result;
}
public void backtracking(int n,int row,char[][] chessboard){
if(row==n){
result.add(Array2List(chessboard));
return;
}
for(int col=0;col<n;++col){
if(isvalid(row,col,n,chessboard)){
chessboard[row][col] = 'Q';
backtracking(n,row+1,chessboard);
chessboard[row][col]='.';
}
}
}
//将一个二维字符数组 char[][] chessboard 转换为一个 List<String>
public List Array2List(char[][] chessboard) {
List<String> list = new ArrayList<>();
for (char[] c : chessboard) {
list.add(String.copyValueOf(c));//将字符数组 c 转换为一个新的 String 对象。
}
return list;
}
public boolean isvalid(int row,int col,int n,char[][] chessboard){
// 检查列
for(int i=0;i<row;++i){
if(chessboard[i][col]=='Q') return false;
}
// 检查45度对角线
for(int i=row-1,j=col-1;i>=0&&j>=0;i--,j--){
if(chessboard[i][j]=='Q') return false;
}
// 检查135度对角线
for(int i=row-1,j=col+1;i>=0&&j<=n-1;i--,j++){
if(chessboard[i][j]=='Q') return false;
}
return true;
}
}
37 解数独😚
中频
class Solution {
public void solveSudoku(char[][] board) {
backtracking(board);
}
//1. 遍历所有格子 2. 尝试填入数字 1-9。递归尝试,如果递归失败,回溯,撤销填入的数字
public boolean backtracking(char[][] board){
for(int i=0;i<9;i++){
for(int j=0;j<9;j++){
if(board[i][j] !='.') continue;//跳过已填数字的格子
for(char k='1';k<='9';k++){
if(isvalid(i,j,k,board)){
board[i][j]=k;
if(backtracking(board)) return true;/// 如果找到合适一组立刻返回。
board[i][j]='.';//如果找不到合适的,要恢复棋盘状态
}
}
return false;
}
}
return true;
}
/**
* 判断棋盘是否合法有如下三个维度:
* 同行是否重复
* 同列是否重复
* 9宫格里是否重复
*/
private boolean isvalid(int row,int col,char val,char[][] board){
//检测同行有没有重复的
for(int i=0;i<9;i++){
if(board[row][i]==val) return false;
}
//检测同列有没有重复的
for(int i=0;i<9;i++){
if(board[i][col]==val) return false;
}
//检测9宫格里有没有重复的
int startrow=row/3*3;
int startcol=col/3*3;
for(int i=startrow;i<startrow+3;i++){
for(int j=startcol;j<startcol+3;j++){
if(board[i][j]==val) return false;
}
}
return true;
}
}
79 单词搜索😚
高频。
