前言
回溯算法,其实一点也不难,难的是起步,和写了就忘。
但是,最近玩的一款游戏《超级马里奥:奥德赛》给了我灵感,这款游戏,就是在开始的时候,把所有基础动作都交给你。只要你能在不断练习中,熟悉这些基础操作,那么,对于后期再难的关卡,你都能从容应付。
所以,我认为,只要掌握一套回溯算法的模板,并对着这套模板持续刷题练习,那很快就能掌握回溯的解题思路,从而在日后面对回溯问题的时候,才能从容不迫。
在接下来的内容中,我会总结一套回溯算法的思路,然后在几乎每道题中,都根据这个思路,来思考怎么解决回溯问题。
回溯算法
大部分问题,如果无法解决,那么使用回溯算法,总归能得出部分答案
搜索问题的解,可以通过 遍历 实现。所以很多教程把「回溯算法」称为爆搜(暴力解法)。因此回溯算法用于 搜索一个问题的所有的解 ,通过深度优先遍历的思想实现。
其与动态规划的区别在于:
共同点:
用于求解多阶段决策问题。多阶段决策问题即:
求解一个问题分为很多步骤(阶段);
每一个步骤(阶段)可以有多种选择。
不同点:
动态规划
只需要求我们评估最优解
是多少,最优解对应的具体解是什么并不要求。因此很适合应用于评估一个方案的效果;
回溯算法
可以搜索得到所有的方案
(当然包括最优解),但是本质上它是一种遍历算法,时间复杂度很高。
相关问题:
题型一:排列、组合、子集相关问题
提示:这部分练习可以帮助我们熟悉「回溯算法」的一些概念和通用的解题思路。解题的步骤是:先画图,再编码。去思考可以剪枝的条件, 为什么有的时候用 used 数组,有的时候设置搜索起点 begin 变量,理解状态变量设计的想法。
-
全排列(中等)
-
全排列 II(中等):思考为什么造成了重复,如何在搜索之前就判断这一支会产生重复;
-
组合总和(中等)
-
组合总和 II(中等)
-
组合(中等)
-
子集(中等)
-
子集 II(中等):剪枝技巧同 47 题、39 题、40 题;
-
第 k 个排列(中等):利用了剪枝的思想,减去了大量枝叶,直接来到需要的叶子结点;
-
复原 IP 地址(中等)
题型二:Flood Fill
提示:Flood 是「洪水」的意思,Flood Fill 直译是「泛洪填充」的意思,体现了洪水能够从一点开始,迅速填满当前位置附近的地势低的区域。类似的应用还有:PS 软件中的「点一下把这一片区域的颜色都替换掉」,扫雷游戏「点一下打开一大片没有雷的区域」。
下面这几个问题,思想不难,但是初学的时候代码很不容易写对,并且也很难调试。我们的建议是多写几遍,忘记了就再写一次,参考规范的编写实现(设置 visited 数组,设置方向数组,抽取私有方法),把代码写对。
-
图像渲染(Flood Fill,中等)
-
岛屿数量(中等)
-
被围绕的区域(中等)
-
单词搜索(中等)
说明:**以上问题都不建议修改输入数据,设置 visited 数组是标准的做法。**可能会遇到参数很多,是不是都可以写成成员变量的问题,面试中拿不准的记得问一下面试官又注:岛屿问题,修改输入数据貌似没什么问题,可以减少使用的空间
题型三:字符串中的回溯问题
提示:字符串的问题的特殊之处在于,字符串的拼接生成新对象,因此在这一类问题上没有显示「回溯」的过程,但是如果使用 StringBuilder 拼接字符串就另当别论。
在这里把它们单独作为一个题型,是希望朋友们能够注意到这个非常细节的地方。
- 电话号码的字母组合(中等);
- 字母大小写全排列(中等);
- 括号生成(中等) :这道题广度优先遍历也很好写,可以通过这个问题理解一下为什么回溯算法都是深度优先遍历,并且都用递归来写。
题型四:游戏问题
回溯算法是早期简单的人工智能,有些教程把回溯叫做暴力搜索,但回溯没有那么暴力,回溯是有方向地搜索。「力扣」上有一些简单的游戏类问题,解决它们有一定的难度,大家可以尝试一下。
- 解数独(困难):思路同「N 皇后问题」;
- 祖玛游戏(困难)
- 扫雷游戏(困难)
- N 皇后(困难):其实就是全排列问题,注意设计清楚状态变量,在遍历的时候需要记住一些信息,空间换时间;
其与深度优先搜索
dfs
的区别
dfs
会将所有可能遍历
回溯
虽然也会遍历所有可能,但当其到达结束条件的时候,会进行回溯,即撤销之前的遍历
下图👇可以帮助更好得理解:
回溯算法的框架
回溯算法,说白了,就是一个决策树的遍历过程,我们只需要考虑如下三个问题:
- 选择列表:也就是你当前可以做出的选择(不要选重)
- 结束条件:也就是到达决策树底层,无法在作出选择的条件
- 剪枝条件:看看能不能在中途就判断出下面无效,直接剔除
框架
其核心,就是for循环里面的循环,在递归调用之前“做选择”,在递归调用之后“撤销选择”
回溯算法相关问题
46_全排列
问题
给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例:
输入: [1,2,3]
输出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
思考步骤
代码
是否存在,使用的是contains()
方法:
public class Solution {
public List<List<Integer>> res = new LinkedList<>();
//获取全排列过后的值
public List<List<Integer>>premute(int[] nums) {
//储存路径(已经经过的位置)
LinkedList<Integer> track = new LinkedList<>();
backtrace(track,nums);
return res;
}
//回溯
public void backtrace(LinkedList<Integer>track,int[] nums) {
//终止条件
if (track.size()==nums.length) {
//这里一定要add重新new的,不然res中,所有的引用都是一样的
res.add(new LinkedList(track));
return;
}
for (int num : nums) {
if (track.contains(num)) continue;
//做选择
track.add(num);
//进入下一层决策树
backtrace(track,nums);
//取消选择
track.removeLast();
}
}
public static void main(String[] args) {
Solution solution = new Solution();
int []nums={
1,2,3,4};
List<List<Integer>> premute = solution.premute(nums);
for (List<Integer> list : premute) {
System.out.println(list.toString());
}
}
}
是否存在,使用的是used[]
数组:
List<List<Integer>> res = new ArrayList<>();
public List<List<Integer>> permute(int[] nums) {
int len = nums.length;
if (len==0) return res;
//记录路径,别忘了到边界的时候,撤回
List<Integer> track = new LinkedList<>();
//记录当前位置是否被使用过
boolean[] used = new boolean[len];
dfs(track,0,used,nums);
return res;
}
/**
* 回溯
* @param track
* @param depth 记录深度
* @param used
* @param nums
*/
public void dfs(List<Integer>track,int depth,boolean[] used,int[] nums) {
//结束条件
if (depth==used.length) {
//这里一定要使用新的内存空间,不然,所有答案都会是一样的
res.add(new ArrayList<>(track));
return;
}
for (int i = 0; i < nums.length; i++) {
//用used数组替代contains函数,使得时间复杂度由O(n)->O(1)
if (!used[i]) {
used[i]=true;
track.add(nums[i]);
dfs(track,depth+1,used,nums);
//撤销操作
used[i]=false;
track.remove(track.size()-1);
}
}
}
47_全排列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]]
思考步骤
这道题,和上一道题很像,就是多了一个去处重复结果的约束
如果在结果生成之后,再去结果集中去重,那会很麻烦,毕竟要对比的是数组,不是字符串
所以,要在数组生成的时候进行剪枝
- 选择列表
在nums中进行选择,但是要用used数组记录之前是否选中
- 结束条件
当track.length==nums.length
- 剪枝
有两种剪枝方式:
if(nums[i]==nums[i-1] && uesd[i-1]==false)
if(nums[i]==nums[i-1] && uesd[i-1]==true)
即当前元素和前面一个元素相等,但是前面一个元素没用过(用过)
两种方式都可以剪枝,但是效率不一样
这里要注意,无论使用那种剪枝方法,都只对有序数列有效 所以,在进入DFS之前,需要对数列进行排序
1、if i > 0 and nums[i] == nums[i-1] and check[i-1] == 0
灰色为剪枝部分,蓝色为答案部分:
2、if i > 0 and nums[i] == nums[i-1] and check[i-1] == 1
灰色为剪枝部分,蓝色为答案部分:
能够明显发现第一种能够提前剪枝,减少计算步骤和搜索次数,并且第一种选择了重复元素的第一个,而第二种选择了重复元素的最后一个,虽然答案都相同,我们成年人当然是要选效率更高一些的那一种对吧~
代码
List<List<Integer>> res = new ArrayList<>();
public List<List<Integer>> permuteUnique(int[] nums) {
int len = nums.length;
//这种剪枝方法,只适合排好序的数组
Arrays.sort(nums);
if (len==0) return res;
boolean[] used = new boolean[len];
List<Integer> track = new LinkedList<>();
backTrack(nums,track,used);
return res;
}
public void backTrack(int[] nums,List<Integer>track,boolean[] used) {
//结束条件
if (track.size()==nums.length)