算法归纳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 也有局限,因为必须提前知道终点在哪里。
posted @ 2022-03-22 15:24  tensor_zhang  阅读(64)  评论(0编辑  收藏  举报