回溯算法学习篇:
递归调用一个重要特征,需要返回到上一级直到在根节点的一层可以选择的所有可能性都尝试完成,才结束了整个递归函数。 函数的return
,回到了被调用前的上一个状态,所以有状态重置的特点
回溯算法,很多时候都是一个树型问题,解就在一棵树上,用dfs深度优先遍历在遍历这棵树,路径就是结果集。 接下来的几道题,我都会画出这颗树
最后引用labuladong大佬的一个总结:
解决回溯问题,实际上就是决策树遍历的过程。
你只需要思考三个问题:
1. 路径,就是已经做过的选择
2. 选择列表,就是当前你可以做的选择
3.结束条件,也就是到达决策树的底层, 无法做出其他选择
1.全排列
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
输入: [1,2,3]
输出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
全排列是一个很经典的回溯算法题型,学过高中数学的同学都会学到排列组合这个知识点。
回溯算法大多时候都可以根据题意画出一棵“递归树”
从根节点出发往下进行深度优先遍历,比如[]----->[1]----->[1,2]----->[1,2,3] 走到叶子节点,也就获得了答案的一个解。
在遍历时选择一个未被选择的数字,加入路径path
中
例如: 在根节点上[]
就说明没有一个数字被选择过,可以选1,2,3
,在[1]
这个结点上,就说明已经选择了1
,还有2,3
可以选择。
- 从树可以看出来,答案是在路径上的一个一个添加的, 也是很多人命名临时解集为path的原因。
- 回溯,也就是走到叶子结点后,还可以回到上一个结点,重新做选择,又可以理解为”状态重置“。
代码
class Solution {
//全排列
List<List<Integer>> list = new LinkedList<List<Integer>>();
public List<List<Integer>> permute(int[] nums) {
LinkedList<Integer> track =new LinkedList<Integer>() ;
backtrack(nums,track) ;
return list;
}
private void backtrack(int[] nums , LinkedList<Integer> path){
if(path.size()==nums.length) {
//递归结束条件:已经到达了叶子节点,也就是需要排列的数字都加入路径中其中
list.add(new LinkedList<>(path)) ;
return;
}
for(int i=0 ;i<nums.length;i++){
if(path.contains(nums[i])) continue; //如果已经选择过了,跳过
path.add(nums[i]) ;//如果没有选择过的,就选择
backtrack(nums,path);//继续深度优先遍历
path.removeLast();//回溯到上一步
}
}
}
2.全排列II
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
示例 1:
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
示例 2:
输入:nums = [1,2,3]
输出:
[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
提示:
1 <= nums.length <= 8
-10 <= nums[i] <= 10
两道题的区别就在于47题的数组会出现重复元素, 而重复元素也就会出现相同的解。
解题思路
- 先把递归树画出来
- 考虑需要去处的地方 重点就在于怎么去重
把递归树画出来之后,观察可以得出一个现象:
- 只有在一个结点生成的兄弟分支才需要考虑舍弃相同数字,那我们要做的,就是找到这样的两个兄弟结点,并且删掉其中一个。
那具体怎么用代码实现呢?
标记状态和排序:
先将数组排序,再相邻两个检查。
这是一个在回溯算法常用的小技巧。
排序把相同的数字放在相邻的位置上,我们从0开始遍历数组,每一个结点,都可以检验其前一个数字有没有使用过了。
class Solution {
List<List<Integer>> ans=new ArrayList<>() ;
boolean [] isUsed;
public List<List<Integer>> permuteUnique(int[] nums) {
if(nums.length<1||nums==null) return ans;
isUsed=new boolean[nums.length]; //用一个数组判断nums[i]是否被已经选择了
Arrays.sort(nums);
dfsBacktrace(nums,new LinkedList<Integer>());
return ans ;
}
private void dfsBacktrace(int[] nums,LinkedList<Integer> path) {
if(path.size()==nums.length) {
ans.add(new LinkedList<Integer>(path)) ;
return ;
}
for(int i=0;i<nums.length;i++){
//1.防止元素重复使用,不能使用path.contain,因为数字会有重复
if(isUsed[i]) continue ;
//2.防止兄弟结点重复,第2个条件保证两个数相等了, 第3个条件保证不是同一条路径往下的。 好好体会一下。
if(i>0&& nums[i]==nums[i-1] && !isUsed[i-1])
continue ;
isUsed[i]=true;
path.add(nums[i]) ;
dfsBacktrace(nums,path) ;
path.removeLast();
isUsed[i]=false ; // 递归结束后还要将状态数组复原
}
}
}
3.组合
给定两个整数 n 和 k,返回 1 … n 中所有可能的 k 个数的组合。
示例:
输入: n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
解题思路
- 先画递归树
分析:
从图上看到,
如果n=4,k=2;
则组合开头是1,则在[2,3,4]中找一个数,
如果组合开头是2,则在[3,4]中找一个数。
也就是说,每一轮的搜索起点是不同的,而且是下标递增
我们设置好每一轮的搜索开头,不需要任何状态标记
class Solution {
List<List<Integer>> ans =new ArrayList<>() ;
public List<List<Integer>> combine(int n, int k) {
if(n<1) return ans ;
backtrace (new ArrayDeque <Integer>() , k, n, 1 ) ;
return ans;
}
// start 是这一轮的搜索起点.
private void backtrace(ArrayDeque<Integer> path,int k,int n ,int start ) {
if(path.size() ==k) {
ans.add(new ArrayList<Integer>(path)) ;
return ;
}
for(int i=start;i<=n;i++){
path.add(i) ;
backtrace(path,k,n,i+1 ) ;
path.removeLast() ;
}
}
}
剪枝优化
从上图也可以看出来, 第4条分支根本没有走的必要,也就是多了不必要的步骤。
当数据非常大时,剪枝可能极大地提升程序运行的速度上面的代码是没有剪枝的,有兴趣的同学可以继续学习一下。
借用大佬的一句总结:
组合问题,不考虑元素的排列顺序,因此很多时候需要按照某种顺序进行搜索,这样才能做到不重不漏
4. 组合总和
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的数字可以无限制重复被选取。
输入:candidates = [2,3,6,7], target = 7,
所求解集为:
[
[7],
[2,2,3]
]
输入:candidates = [2,3,5], target = 8,
所求解集为:
[
[2,2,2,2],
[2,3,3],
[3,5]
]
说明: 所有数字(包括 target)都是正整数。 解集不能包含重复的组合。
解题思路:
因为可以重复使用数字,在搜索中的起点可以不变(这一点会在编码中有体现)
但不能有重复的组合。(这是难点)
怎么做到不考虑重复集合呢?
在这里引入一个我理解的搜索起点问题:回溯算法每一轮都可以设置下一轮的搜索起点。
例如: [2,3,5] 中,[2,3,3]和[3,3,2]就视为同一个集合,这就需要我们设置一个搜索起点,按照某种顺序搜索。
如图,因为可以使用重复的数字,我们每一轮的起点都可以重复使用当前的数字。
但是,因为不能使用同样的组合,当我们已经就一个为第一个搜索起点做组合后,下一轮就不能再使用先前已经组合完的的数字。
class Solution {
List<List<Integer>> ans = new LinkedList<>() ;
public List<List<Integer>> combinationSum(int[] candidates, int target) {
if(candidates.length==0 || candidates==null) return ans;
Arrays.sort(candidates) ; // 一定要排列
dfsTrack(0,candidates,target ,new LinkedList<Integer>()) ;
return ans;
}
private void dfsTrack( int start ,int []arr ,int n ,LinkedList<Integer> path ) {
if(n==0){
ans.add(new LinkedList<Integer>(path)) ;
return ;
}
for(int i=start;i<arr.length;i++){
if(n-arr[i]<0) return ;
path.add(arr[i]);
// 注意搜索起点,就是当前数字
dfsTrack(i,arr,n-arr[i],path) ;
path.removeLast();
}
}
}
5.组和总和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]
]
和上一道题的区别在于本题的数组中可以出现相同数字,但要求不能使用重复组合
去重问题:
去重的情况是在兄弟结点产生的,也就是说在同一层的时候需要考虑去重问题。 i>index
就说明是同一层的兄弟结点
这道题毫无疑问需要排序,才能避免使用重复数字
class Solution {
List<List<Integer>> ans = new LinkedList<> () ;
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates) ;
backtrack(0,candidates,target,0,new LinkedList<Integer>()) ;
return ans ;
}
/**
index : 搜索起点
*/
private void backtrack(int index ,int arr[],int target,int sum, LinkedList<Integer>path ) {
if(sum==target) {
ans.add(new LinkedList<Integer>(path) ) ;
return ;
}
for(int i=index ;i<arr.length ;i++) {
if(sum+arr[i] >target) break; //剩下的都不用再搜索了
if(i>index && arr[i] == arr[i-1]) continue;
path.add(arr[i]) ;
backtrack(i+1,arr,target,sum+arr[i],path) ;
path.removeLast() ;
}
}
}
思考:
在递归中,i
和 index
相同,说明这是在一条分支上。 函数return
了,i++ 后 i > index
说明这已经是另一个分支了,就和上一个分支的结点形成兄弟节点。
做完上面的题目,我们就大概知道了回溯算法的基本框架,正如labuladong精辟的总结,我们可以改变的,就是选择列表(路径的搜索起点),终止条件。
其中为了正确选择生成路径,我们通过已经选择了的路径筛选当前选择列表,具体的实现中,我们使用了 状态数组,排序,定义搜索起点。
11.11 日更新:
这次将会更新4道题,
1.经典的8皇后问题
2.力扣的复原ip地址
3.力扣分割回文串
这三道题是我在做完排序组合后又遇到的题目,它们都比前面的题目复杂,需要自己抽象出一个模型。
在做题的过程中,我惊奇的发现我又没有了任何的思路,因此,借助这些题目,让我们更加深入地了解回溯算法吧!
6.复原IP地址
给定一个只包含数字的字符串,复原它并返回所有可能的 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 地址。
示例 1:
输入:s = "25525511135"
输出:["255.255.11.135","255.255.111.35"]
示例 2:
输入:s = "0000"
输出:["0.0.0.0"]
示例 3:
输入:s = "1111"
输出:["1.1.1.1"]
示例 4:
输入:s = "010010"
输出:["0.10.0.10","0.100.1.0"]
示例 5:
输入:s = "101023"
输出["1.0.10.23","1.0.102.3","10.1.0.23","10.10.2.3","101.0.2.3"]
提示:
0 <= s.length <= 3000
s 仅由数字组成
思路
这道题的思路就和前面题目不同,之前都是一个一个数字的添加,这次有可能一加就是一串。
我们可以把添加的思路转换截取,截取每段的ip长度有可能是1,2,3
每轮递归中,我们就尝试去截取一段ip地址。
新思考 | 整理编码细节:
递归终止条件:
- ip地址剩下的长度不够
- ip地址剩下的长度过长
- 完成截取
在每层递归中,我们需要保存的变量: - 当前已经分割的段数
- 本轮的搜索起点
- 临时结果集
题目中的剪枝条件:
5. 大于1位数不能以前导0开头,数字不能超过255
6. 地址段有多余或者不够选
代码实现
思路确定了就比较清晰,这题的细节实现也很繁琐,一不留神数组就越界了。
class Solution {
List<String> list = new ArrayList<String>();
public List<String> restoreIpAddresses(String s) {
if(s.length()<4||s.length()>12) return list ;
char [] ip = s.toCharArray() ;
backtrack(ip,new LinkedList<String>(),0,0);
return list ;
}
void backtrack(char[] ip ,LinkedList<String> path,int split,int start ) {
if(start==ip.length) {
if(split==4) {
list.add(String.join(".", path));
}
return ;
}
if(ip.length-start> (4-split)*3 || ip.length-start<(4-split)) {
return ;
}
for(int i=0;i<3;i++){
if(start+i>=ip.length) break ;
if(isVaildIp(start,i+start,ip) ){
// System.out.println("start="+start+ " i="+i) ;
String tmp = new String(ip,start,1+i);
path.add(tmp) ;
backtrack(ip, path, split+1, start+i+1);
path.removeLast();
}
}
}
private boolean isVaildIp(int start ,int end, char [] ip ){
int n = 1 ,num =0 ;
if(end-start+1>1&&ip[start]=='0') return false ;
for(int i=end;i>=start;i--){
int s = (ip[i]-'0');
s*=n ;
num+=s;
n*=10 ;
}
if(num>255) return false;
return true ;
}
}
String的一个构造函数:
java.lang.String.String(char[] value, int offset, int count)
这个构造函数可以从字符数组中截取一段。 但是它是从offset_idx
开始,截取count
个字符,而不是从idx1
到idx2
。 编码时理想当然了就出错,记录一下。
7.分割回文串
给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。
返回 s 所有可能的分割方案。
示例:
输入: "aab"
输出:
[
["aa","b"],
["a","a","b"]
]
示例 2:
输入:s = "a"
输出:[["a"]]
解题思路:
第一:画递归树!!
递归树也不是随便画的。 我上来就徒手画树,画成了这样:
这个图,其实和回溯算法的决策树完全不一样,我们以前画树的时候,结果都是在叶子节点中生成的,而这个是在每一层中生成的。 而且里面隐含这一个意思: 结果集是添加得来的,不是截取出来的,和题意不符合。
思考:回溯算法的结果,一般都是在路径上添加的,结点保存的,大多都是接下来的选择列表!
所以正确的图应该是这样的:
选择列表: 被截取后剩下的字符串
class Solution {
List<List<String>> list = new LinkedList<List<String>>() ;
public List<List<String>> partition(String s) {
if(s.length()==1) {
LinkedList <String> temp = new LinkedList<>() ;
temp.add(s) ;
list.add(temp ) ;
return list ;
}
backtracker(s.toCharArray(), new LinkedList<String>(), 0 ,s.length()) ;
return list;
}
private void backtracker(char []str, LinkedList<String> path,int start,int len ) {
if(start>=len) {
list.add(new LinkedList<String> (path)) ;
return ;
}
for(int i=0 ;i<len;i++){
if(start+i>len-1) break;
if( !isHuiWen(str,start,start+i))
continue;
String tmp = new String(str, start, i+1);
path.add(tmp);
// System.out.println(tmp);
backtracker(str, path, start + i+1, len);
path.removeLast();
}
}
private boolean isHuiWen(char [] str, int start ,int end) {
if(end==start )return true ;
while(end>start){
if(str[start] != str[end]) return false;
end-- ;
start++ ;
}
return true ;
}
}
8.二进制手表
解题思路:
简单题,相信做了那么多,已经有思路了。
就是几盏灯代表的数字做一个组合,当然还要做合法性检查、
编码细节:
- 终止条件: 灯全部用完的时候
- 选择列表: 一开始做题的时候有点卡住,纠结:什么时候分配给小时,什么时候分配给分钟呢,其实都要分配,而且是无差别的,直接把它们做成数组合并在一起就好了。
代码:
class Solution {
int time [] = new int[] {8,4,2,1,32,16,8,4,2,1};
List<String > res= new LinkedList<String> () ;
public List<String> readBinaryWatch(int num) {
//特判
if(num<1||num>8) return res;
dfsSearch(0,num,0,0);
return res ;
}
/**
* dfs+回溯搜索
* @param start 本次开始搜索的起点
* @param n 剩下亮着的灯
* @param min 分
* @param hour 时
*/
private void dfsSearch(int start ,int n,int min,int hour ) {
if(n==0) {
if(hour>11||min>59) return ;
StringBuilder sb=new StringBuilder();
sb.append(hour) ;
sb.append(':');
if(min/10==0) sb.append(0);
sb.append(min);
res.add(sb.toString()) ;
return ;
}
for(int i=start ;i<time.length ;i++) {
if(i<4)
hour +=time[i];
else
min+=time[i] ;
dfsSearch( i+1, n-1, min ,hour ) ;
if(i<4) hour-=time[i];
else min-=time[i];
}
}
}
9.括号生成
给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。
回文串 是正着读和反着读都一样的字符串。
示例 1:
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
示例 2:
输入:s = "a"
输出:[["a"]]
提示:
1 <= s.length <= 16
s 仅由小写英文字母组成
思路:
括号相关的题目,首先要知道两个性质:
- 从括号的中部开始往前数,左括号的数目肯定大于等于右括号,因为左括号最终将等于右括号。
思考一下,这个图怎么画?参考:大佬画的图
这样一画,基本上编码思路就有了,不多说。
终止条件:
- 生成了不合法的括号
- 已经用完了所有括号
选择列表:
(个人理解):没有严格定义的选择列表。 左右都可以使用。
class Solution {
List<String> ans = new LinkedList<>() ;
public List<String> generateParenthesis(int n) {
backTrack(n,n,new StringBuilder()) ;
return ans;
}
private void backTrack(int left,int right, StringBuilder path) {
if(left==0 && right == 0) {
ans.add(path.toString()) ;
}
if(left>right) return ;
//用一下左括号
if(left>0) {
path.append("(") ;
backTrack(left-1, right, path);
path.deleteCharAt(path.length()-1) ;
}
//用一下右括号
if(right >0 ){
path.append(")") ;
backTrack(left, right-1, path);
path.deleteCharAt(path.length() - 1);
}
}
}
最后的思考:
菜鸡就是容易掉进框架,这道题,有用for循环吗?没有,算法很灵活的。
10.单词搜索
给定一个二维网格和一个单词,找出该单词是否存在于网格中。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
示例:
board =
[
['A','B','C','E'],
['S','F','C','S'],
['A','D','E','E']
]
给定 word = "ABCCED", 返回 true
给定 word = "SEE", 返回 true
给定 word = "ABCB", 返回 false
这是一个二维平面回溯算法搜索的问题,针对每一个点进行dfs搜索,发现无解即回溯到上一个状态
class Solution {
private int dir[][]=new int[][]{{1,0},{-1,0},{0,1},{0,-1}};
boolean marked[][];
private int row,col;
public boolean exist(char[][] board, String word) {
char map [] = word.toCharArray() ;
row=board.length;
col=board[0].length;
marked=new boolean[row][col];
for(int i=0;i<row ;i++) {
for(int j=0;j<col;j++) {
if(dfsSearch(board,map,i,j,0)) return true ;
}
}
return false;
}
private boolean dfsSearch(char [][] board, char[] map , int i ,int j,int index) {
if( board[i][j]!=map[index]) return false ;
if( index==map.length-1 ) return true;
marked[i][j]=true;
for(int t=0;t<4;t++){
//以这个点为中心的上下左右四个坐标
int X=dir[t][0]+i;
int Y=dir[t][1]+j;
//marked数组标记:不允许已经访问过的点被再次访问
if(isVaild(X,Y)&& !marked[X][Y]){
if(dfsSearch(board,map,X,Y,index+1))
return true;
}
}
marked[i][j]=false; //撤销访问记录
return false ;
}
private boolean isVaild(int x,int y){
if(x<row&&x>=0&&y>=0&&y<col) return true;
return false;
}
}
11.被围绕的区域
[to be continue]