说回溯之前,得先知道dfs,说dfs得知道什么是搜索算法。
最常见,常用的搜素算法:
- 广度优先搜索(bfs:Breadth First Search)
- 深度优先搜索(dfs: Depth First Search)
这两种算法在经典的N叉树遍历算法上有最熟悉不过的应用。
比如使用队列层次遍历二叉树就基于bfs,递归方式的前中后序遍历就基于dfs。
当然dfs和bfs有除此之外更加复杂的应用场景。
对于回溯,我的理解就是基于dfs,深度优先搜索上的一种应用场景。
它的重要特点,也就是它的名字,回溯,回溯在搜索到某一层的某一分支之后,如果不满足条件后,就回退,撤销选择,尝试别的分支,直到找到符合结果。
回溯具体代码实现,主要通过递归来实现,因回溯的特点,其代码大部分有极高的对称性。
深度搜索的话,其实就相当于把所有可能的分支都要走一遍,所以朴素回溯,其时间复杂度一般是指数级别的,因为回退尝试,产生很多中间重复计算。这个时候,就需要合理的剪枝操作,来优化提高程序运行速度。但是剪枝一般不会影响时间复杂的大的量级。
❝回溯的关键字:回退,撤销选择,递归,dfs,剪枝。
分析回溯类题目,画出递归状态树就行,然后分析如何回退,如何剪枝。时间复杂度计算就分析总的递归的时间
回溯的代码模板:
public void dfs(当前路径, 当前层级,选择列表,结果集) {
// 1. 终止条件
if(符合条件) {
当前路径加入结果集
return;
}
// 2. 处理当前层
cur.add(...);
// 3. 下探,递归
dfs(cur,level + 1, 选择列表,结果集);
// 4. 回退,撤销选择
cur.remove(...);
}
分享两篇我认为比较好的回溯文章:
https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/
https://leetcode-cn.com/problems/subsets/solution/hui-su-si-xiang-tuan-mie-pai-lie-zu-he-zi-ji-wen-t/
回溯的相关题目清单。
- 全排列(中等) 高频
- 全排列 II(中等) 高频
- 组合总和(中等) 高频
- 组合总和 II(中等) 高频
- 组合(中等) 高频
- 子集(中等) 高频
- 子集 II(中等)高频
- 第 k 个排列(中等)
- 复原 IP 地址(中等)
- 图像渲染(Flood Fill,中等)
- 岛屿数量(中等)
- 被围绕的区域(中等)
- 单词搜索(中等) 高频
- 电话号码的字母组合(中等) 高频
- 字母大小写全排列(中等) 高频
- 括号生成(中等) 高频
- N 皇后(困难
- 祖玛游戏(困难)
- 扫雷游戏(困难)
那么就从最基本的全排列题目(leetCode 46),开始,好好理解一下回溯吧。
题目描述:
给定一个 没有重复数字的序列,返回其所有可能的全排列。示例:
输入:[1,2,3]
输出: [1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]
解题思路:
注意,题目已经约束了,待排列的数组没有重复元素。
如果没有思路,刚开始不太熟练的话,可以手画一下递归树,便于理解。后面写的多了,相信心中自有递归树。
盗图一波,图片来源上面的推荐贴。
树的特定就是,每条路径中,已经选择过的元素,就不能再选择。我们可以维护一个布尔数组 visited 来表示 数组中的元素是否已经被访问。并把这个变量带到每一层的递归变量中去。
接下来,套用回溯模板。
dfs(boolean[] visited, List cur, int[] nums, List res) {
1. terminal 终止条件: 当前路径的大小=排列数组的长度,就可以加入到结果集,然后返回backif (cur.size() == nums.length) {
res.add(new ArrayList(cur));return;
}
nums长度的位置上,可以是任何一个数。所以对应递归层数就是nums的长度。for(int i = 0; i // 剪枝,每条路径中,不能选择已选择过的元素if(visited[i] == true) {continue;
}
2. 处理当前层: 添加到当前路径,并标价已选择
cur.add(nums[i]);
visited[i] = true;
3. drill down, 处理下一层
dfs(visted, cur, nums, res);
4. 回退,撤销选择,还原上一层的所有状态。
visited[i] = false;
cur.remove(cur.size() - 1);
}
}
刚开始,很吃力,很正常,因为人脑可能理解递归比较吃力,但是这对计算机来说很容易。所以需要悟!做的多了,就悟了!
感觉像不像盗梦空间,每进入一层梦境,带着的那个陀螺,就像这里的变量,每一层的东西应该是不一样的,应该是副本。如果这块不是副本的话,就需要类似第4步的撤销处理,还原上一层的梦境!
写一遍完整的代码:
public List> permute(int[] nums) {
List> res = new ArrayList<>();if (nums == null || nums.length <= 0) {return res;
}
boolean[] visited = new boolean[nums.length];
dfs(nums, res, new ArrayList<>(), visited);return res;
}
private void dfs(int[] nums, List> res, List cur, boolean[] visited) {if (cur.size() == nums.length) {
res.add(new ArrayList<>(cur));return;
}for (int i = 0; i if (visited[i]) {continue;
}
cur.add(nums[i]);
visited[i] = true;
dfs(nums, res, cur, visited);
cur.remove(cur.size() - 1);
visited[i] = false;
}
}
接下来看全排列的进阶。全排列2(leetCode:47)
题目描述:
给定一个可包含重复数字的序列,返回所有不重复的全排列。
示例:
输入: [1,1,2]
输出:[1,1,2],[1,2,1],[2,1,1]
解题思路:
这道题相比上面的,多了个条件:待排列的数组中会存在重复的数字。但是要返回所有不重复的全排列。
如果按照全排列1的解法,首先第二层会把1的路径再走一遍。这是不合适的,所以得想办法去剪枝。
大多数情况下,第一次接触题目,很难想出如何剪枝,剪枝是个技巧也是个经验。
此时画出状态树,在图上就能很更轻松的找出剪枝思路。
又盗图一波,图来源优秀题解:
https://leetcode-cn.com/problems/permutations-ii/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liwe-2/
图已经很清楚的标注了,应该剪除哪些分支。怎么对应到题解中呢?
还记得 N数之和,全歼灭 中那几道题吗?里面排序已经移动重复元素的手段完全可以学习借鉴。
是不是我们对 待排列的数组 排序后,保证当前层处理中的元素不等于前一个元素即可。
先上代码!
public List> permuteUnique(int[] nums) {
List> res = new ArrayList<>();if (nums == null || nums.length <= 0) {return res;
}
Arrays.sort(nums);
boolean[] visited = new boolean[nums.length];
dfs(nums, res, new ArrayList(), visited);return res;
}
private void dfs(int[] nums, List> res, List cur, boolean[] visited) {if (cur.size() == nums.length) {
res.add(new ArrayList<>(cur));return;
}for (int i = 0; i if (visited[i]) {continue;
}if (i > 0 && nums[i-1] == nums[i] && !visited[i - 1]) {continue;
}
visited[i] = true;
cur.add(nums[i]);
dfs(nums, res, cur, visited);
cur.remove(cur.size() - 1);
visited[i] = false;
}
}
你可能有疑惑,这个关键的剪枝条件 i > 0 && nums[i-1] == nums[i] && !visited[i - 1]
为什么还要i-1元素未被访问过呢?
这是因为同一层的i-1的元素因为回退操作,已经置为未访问了。
如果i-1的元素被访问过了,说明当前层在i-1的下一层,相等是不会重复的。
所以如果不加这个约束剪枝就错了。
本次通过最经典,最能直观表达回溯特点的全排列这两道题目,和大家入了个回溯的门。
如果通过这两道题悟了,趁热打铁,做下括号生成的题目吧。