算法归纳2-穷举-递归->回溯(模板)-广度优先(BFS模板)-双向BFS
1,回溯框架
- 要学回溯,先要学会递归,递归可以通过学树的深度优先遍历来学。
- 解决递归问题,需要思考的问题:
- 1,递归函数的输入是什么
- 2,递归函数在哪里进行递归调用(比如树的前中后序的调用位置,本质是后面的递归是否需要之前处理的信息)
- 3,递归调用前后需要对数据进行什么处理
- 4,结束条件、返回值
- 解决一个回溯问题,实际上就是一个决策树的遍历过程。你只需要思考 3 个问题:
- 1、路径:也就是已经做出的选择。
- 2、选择列表:也就是你当前可以做的选择。
- 3、结束条件:也就是到达决策树底层,无法再做选择的条件,结束条件有两类:
- 一类是触底的,此类完成了某一支路上所有中间过程的遍历,是结果的一部分。
- 一类是未触底的,此为未彻底走完某一支路,而是在中间由于限制条件提前退出,不是结果的一部分。
- 回溯比二叉树递归难的地方就在于:二叉树的路径(就节点数值的排序数组)和选择列表是确定的(就左右子树);而回溯问题往往需要自己从题目中抽象出路径和选择列表,并选择合适的数据结构(往往也是数组)。回溯问题的关键就在于构建什么样的路径和选择列表,掌握了思想后,这直接关系到编程的难度。
- 框架1-前后选择不独立
List<List<***>> result = new ArrayList<>();
public void backtrack(路径, 选择列表):
if(满足结束条件):
if(当前路径满足条件):
result.add(new 路径) // result = new 路径
return
for(选择: 选择列表):
做选择
backtrack(路径, 选择列表)
撤销选择
- 框架2-前后选择独立-并且选择是选与不选的二选一(例如子集)
private List<List<***>> result = new ArrayList<>();
private LinkedList<***> 路径 = new LinkedList<>();
public void backtrack(路径, int[] nums, index):
if(满足结束条件):
if(当前路径满足条件):
result.add(new 路径) // result = new 路径
return
for(int i=index; i<nums.length; i++):
//选择路径展开
//选择添加
路径.add(nums[i]);
backtrack(路径, int[] nums, i+1);
路径.removeLast();
//不选择添加就什么也不做
- 选择列表选项较少,且不随递归层数发生变化的话(前面的选择不影响后面的选择->前面的选择不影响选择列表,前后选择是独立的),选择列表的遍历可以展开(就和树的递归遍历很像了,树中每个节点都是有可能会有左右两个子节点,这一点是不会发生变化的);但是如果选择列表会随递归层数发生变化(前面的选择会影响后面的选择->前面的选择会影响选择列表,前后选择不独立),就老老实实for循环吧,此时选择列表的变化也是做选择的一部分,并且递归调用后需要进行撤销。
- 一般题目都会有 前后选择独立和不独立两种思考方式,需要比较一下两种方式哪种好做,或者觉着思路复杂时试着换个角度。比如:
- 2212. 射箭比赛中的最大得分:从所中区域考虑,选择列表为射(+1)或不射(0),独立,并且时间复杂度固定0(2^12).
- 698. 划分为k个相等的子集:从球(数目N)的视角看,选择列表为桶(数目K),独立(球选择桶,前面的球选过了,后面的球还能选),时间复杂度O(N^K);从桶的角度考虑,选择列表为球,不独立(桶选择球,前面的桶选过了,后面的桶就不能选了),时间复杂度O(K*2^N)。
- 原则:宁可多做几次选择,也不要给太大的选择空间;宁可「二选一」选 k 次,也不要 「k 选一」选一次。(多想想如何设计路径和选择列表,可以使得回溯过程是做二选一的选择的)
- 一般题目都会有 前后选择独立和不独立两种思考方式,需要比较一下两种方式哪种好做,或者觉着思路复杂时试着换个角度。比如:
2,回溯+剪枝
- 如果回溯写出来一直超时,那肯定是存在了大量的重复计算,此时就应考虑剪枝。
- 剪枝的精髓在于使用一个数据结构将中间结果存储下来(通常使用哈希表将之前的递归条件和结果保存下来),这样下次递归时先通过传入的递归条件查表,如果已有结果就直接返回以避免大量重复计算
- 保存递归条件会用到的技巧有:位图,
3,广度优先
- BFS出现的常见场景:在一幅「图」中找到从起点 start 到终点 target 的最近距离,BFS 算法问题其实都是在干这个事儿。
- 走迷宫,有的格子是围墙不能走,从起点到终点的最短距离是多少
- 两个单词,要求通过某些替换,把其中一个变成另一个,每次只能替换一个字符,最少要替换几次
- 连连看游戏,两个方块消除的条件不仅仅是图案相同,还得保证两个方块之间的最短连线不能多于两个拐点
- 。。。
- 问题本质上就是一幅「图」,让你从一个起点,走到终点,问最短路径。这就是 BFS 的本质。
- BFS和DFS的区别:
- DFS 是线,BFS 是面;DFS 是单打独斗,BFS 是集体行动。
- DFS的空间复杂度低,时间复杂度高,并且掌握思想后递归代码好写。
- BFS的时间复杂度低,空间复杂度高,有些问题用迭代不好写。
- 一般都是最短距离用BFS,其他基本都是DFS。
- 掌握思想后,关键点在于:如何定义图,想明白:
- 图中的顶点是什么(实际的一个点/正儿八经的求最短距离时,或者一种状态/最少替换次数、最少移动次数);
- 一个顶点有几条边以及边又是什么(实际的点与点之间的连接/正儿八经的求最短距离时,或者一种状态通过一步变化到其他状态的所有可能形式/最少替换次数、最少移动次数);有些时候,不同顶点对应的边会有区别(如:边的数目),可以使用数组将所有可能的情况提前缓存出来。
- 模板
// 计算从起点 start 到终点 target 的最近距离
public int BFS(Node start, Node target) {
LinkedList<Node> q; // 核心数据结构
HashSet<Node> visited; // 避免走回头路(和树不同,因为树是单向的,图是可以回头的)
q.add(start); // 将起点加入队列
visited.add(start);
int step = 0; // 记录扩散的步数(或者说是向外扩散的层数)
while (q.size()!=0) {
int sz = q.size();
/* 将当前队列中的所有节点向四周扩散(一层层的向外扩) */
for (int i = 0; i < sz; i++) {
Node cur = q.removeFirst();
/* 划重点:这里判断是否到达终点(或者任何其他形式的终止条件) */
if (cur == target){
return step;
}
/* 将 cur 的相邻节点加入队列,cur.adj()就是相邻节点的集合可以通过steps等进行代替 */
for (Node x : cur.adj()) {
if (x not in visited) {
q.add(x);
visited.add(x);
}
}
}
/* 划重点:更新步数在这里 */
step++;
}
}
4,双向BFS
- BFS 算法还有一种稍微高级一点的优化思路:双向 BFS,可以进一步提高算法的效率。
- 传统的 BFS 框架就是从起点开始向四周扩散,遇到终点时停止;而双向 BFS 则是从起点和终点同时开始扩散,当两边有交集的时候停止。
- 不过,双向 BFS 也有局限,因为必须提前知道终点在哪里。
行动是治愈恐惧的良药,而犹豫拖延将不断滋养恐惧。