BFS 与 DFS 套路总结

本贴最后更新于 1520 天前,其中的信息可能已经事过景迁

概述

深度优先遍历和广度优先搜索和广度优先搜索是解决图问题最常见的方式,并且在 leetcode 中有许多相关的变体,但万变不离其宗,其本质结构或者算法框架时固定的,因此本文 BFS 和 DFS 算法的原理总结了对应的算法框架,并提供了几道例题来解决如何使用这些框架。

好,话不多少,我们下边正式开始。

BFS

BFS 算法本质上就是从一个图的起点出发开始搜索找到目标终点完成搜索。

当然在该算法上会有许多变体比如:

比如迷宫,有些格子是围墙不能走,找到从起点到终点的最短距离。

再比如说连连看游戏,两个方块消除的条件不仅仅是图案相同,还得保证两个方块之间的最短连线不能多于两个拐点。你玩连连看,点击两个坐标,游戏是如何判断它俩的最短连线有几个拐点的?

这些问题背景本质上都可以看成图,都可以看做是从起点到终点寻找最短路径的长度。

基于以上认识,我们可以将 BFS 的整个解决分成下边几个步骤:

  1. 起点入队列
  2. 以队列非空为循环条件,进行节点扩散(将所有队列节点出队(同时判断出队节点是否为目标节点),获取其邻接结点)
  3. 判断获取的节点是否已被遍历,未被遍历节点入队。

进而我们可以整理出如下的 BFS 框架

 /**
 * 给定起始节点start和目标节点target,返回其最短路径长度
 **/
 int BFS(Node start,Node target){
     Queue<Node> q; //核心数据结构
     Set<Node> visited: //某些情况下可以通过byte数组来进行代替
     int step = 0; //记录扩散步数
     //起始节点入队列
     q.add(start);
     visited.offer(start);
     while(q not empty) {
         //必须要用sz来保存q.size(),然后扩散sz不能直接使用q.size()
         int sz = q.size();
         //将队列中的节点进行扩散
         for(int i =0 ; i < sz; i++) {
             Node cur = q.poll();
             // 目标节点判断
             if(cur is target) {
                 return step;
            }
             // 邻接结点入队列
             for(Node n:cur.adjs) {
                 //未访问节点入队列
                 if(n is not int visited) {
                     visitd.add(n);
                     q.offer(n);
                }
            }
        }
         // 更新步数
         step++;
    }
 }

看到上边的算法框架可能有些同学会有些疑问,既然已经通过队列判空来作为 BFS 条件,为何为何每次还要加一个 sz 来做一轮扩散??

其实这个不难理解,我们此处通过 sz 来扩散,保证当前节点的所有邻接结点都访问后,步数再加一,如果不进行扩散的话,每次从队列中取出一个元素进行访问后,都会对步长加 1,造成结果偏差。也就是说如果我们在套用 BFS 时,如果不需要步长(step)的话,其实这一步的扩散也是可以不要的。

1. 克隆图问题

首先我们先可以一下克隆图问题

image-20201022185413445

该问题,我们在使用 BFS 进行解决时,发现:在整个遍历过程中,我们压给不需要步长,因此该问题在套用 BFS 框架时,就无需进行扩散。

因此我们可以比较容易的写出下边的解决方案:

   public Node cloneGraph(Node node) {
     if (node == null) {
       return null;
    }
     Queue<Node> queue = new LinkedList<>();
     Map<Node, Node> map = new HashMap<>();
     queue.add(node);
     map.put(node, new Node(node.val));
 
     while (!queue.isEmpty()) {
         //无需扩散,亦可以解决
     // int sz = queue.size();
      // for (int i=0;i
         Node cur = queue.poll();
         for (Node n : cur.neighbors) {
           if (!map.containsKey(n)) {
             map.put(n, new Node(n.val));
             queue.add(n);
          }
           // 建立与邻接节点关系
           map.get(cur).neighbors.add(map.get(n));
        }
       //}
    }
     return map.get(node);
  }
 

当然,即使加上扩散步骤也不影响问题的解决。

2.打开转盘锁

接下来,我们看一个稍微困难的题目。

image-20201023201110375

这个问题粗劣一看好像跟,没有任何关系??

首先我们这样想,如果改题目我们不考虑 死亡数字 这一限制条件,我们会怎么做?

毫无疑问我们可以进行穷举,先从“0000”开始,每次波动一次锁,可以是["1000","9000","0100","0900"..."0009"]共八种情况。我们把每种情况都看做是图的节点,我们会发现所有的情况组合在一起就构成了一个全连接无向图,而对密码的寻找也就变成在 BFS 中对 target 的寻找。很神奇有没有??

接下来我们可以套用模板。写出如下解决代码:

 class Solution {
     public int openLock(String[] deadends, String target) {
     // 记录需要跳过的deadends信息
     Set<String> deadSet = new HashSet<>();
     for (String deadStr : deadends) {
       deadSet.add(deadStr);
    }
     int step = 0;
     // 标记已经访问的字符
     Set<String> visited = new HashSet<>();
     Queue<String> queue = new LinkedList<>();
     queue.add("0000");
     visited.add("0000");
 
     while (!queue.isEmpty()) {
       int sz = queue.size();
       for (int i = 0; i < sz; i++) {
         String cur = queue.poll();
         // 遇到死亡数字结束此次搜寻
         if (deadSet.contains(cur)) {
           continue;
        }
         // 终止条件:找到target
         if (target.equals(cur)) {
           return step;
        }
         // 处理相邻的八种情况
         for (int j = 0; j < 4; j++) {
           String up = plusUp(cur, j);
           if (!visited.contains(up)) {
             visited.add(up);
             queue.add(up);
          }
           String down = plusDown(cur,j);
           if (!visited.contains(down)){
             visited.add(down);
             queue.add(down);
          }
        }
      }
       step ++;
    }
     return -1;
  }
 //向上拨动第j位锁
   private String plusUp(String str, int j) {
     char[] strArray = str.toCharArray();
     if (strArray[j] == '9') {
       strArray[j] = '0';
    } else {
       strArray[j] += 1;
    }
     return new String(strArray);
  }
 //向下波动第j位锁
   private String plusDown(String str, int j) {
     char[] strArray = str.toCharArray();
     if (strArray[j] == '0') {
       strArray[j] = '9';
    } else {
       strArray[j] -= 1;
    }
     return new String(strArray);
  }
 }
 

3.双向 BFS 优化

下载

上边我们通过 BFS 已经能够解决大部分问题,但是对于 BFS 的性能我们还是可以通过一些方法来进行优化。比如我们可以尝试通过双向 BFS 来进行优化

那什么是双向 BFS??

传统的 BFS 是从起点开始向四周进行扩散,而双向 BFS 则是从起点和终点同时进行扩散,直到两者相交在一起结束。

虽然理论上讲两者的最坏时间复杂度都是 O(N),但实际在运行时,确实双向 BFS 的性能会更好一点,这是为什么那??

我们可以借助下面两张图辅助进行理解。

图示中的树形结构,如果终点在最底部,按照传统 BFS 算法的策略,会把整棵树的节点都搜索一遍,最后找到 target;而双向 BFS 其实只遍历了半棵树就出现了交集,也就是找到了最短距离。从这个例子可以直观地感受到,双向 BFS 是要比传统 BFS 高效的。

但是双向 BFS 最大的局限性就是,必须知道终点在哪里,比如第一个克隆图的问题,我们便不能通过双向 BFS 来进行解决。而对二个问题,我们便可以采用。

 int openLock(String[] deadends, String target) {
     Set<String> deads = new HashSet<>();
     for (String s : deadends) deads.add(s);
     // 用集合不用队列,可以快速判断元素是否存在
     Set<String> q1 = new HashSet<>();
     Set<String> q2 = new HashSet<>();
     Set<String> visited = new HashSet<>();
 
     int step = 0;
     q1.add("0000");
     q2.add(target);
 
     while (!q1.isEmpty() && !q2.isEmpty()) {
         // 哈希集合在遍历的过程中不能修改,用 temp 存储扩散结果
         Set<String> temp = new HashSet<>();
 
         /* 将 q1 中的所有节点向周围扩散 */
         for (String cur : q1) {
             /* 判断是否到达终点 */
             if (deads.contains(cur))
                 continue;
             if (q2.contains(cur))
                 return step;
             visited.add(cur);
 
             /* 将一个节点的未遍历相邻节点加入集合 */
             for (int j = 0; j < 4; j++) {
                 String up = plusOne(cur, j);
                 if (!visited.contains(up))
                     temp.add(up);
                 String down = minusOne(cur, j);
                 if (!visited.contains(down))
                     temp.add(down);
            }
        }
         /* 在这里增加步数 */
         step++;
         // temp 相当于 q1
         // 这里交换 q1 q2,下一轮 while 就是扩散 q2
         q1 = q2;
         q2 = temp;
    }
     return -1;
 }

简单来看的话,双向 BFS 还是遵循 BFS 算法框架的,只是不再使用队列,而是使用 HashSet 方便快速判断两个集合是否有交集

DFS

与广度优先搜索不同,深度优先搜索(DFS)类似于树的先序遍历。在搜索时会尽可能的沿着一条所有路径进行搜索,直到该条路径上所有节点搜索完成,然后切换到另一条路径上进行搜索,直到图的所有节点全部都被遍历

因此广度优先搜索整个过程可以分成如下步骤:

  1. 判断终止条件
  2. 对节点进行访问并加入到访问链表中
  3. 以当前节点的邻接结点为起点,通过递归更深层次进行搜索。

即可以简单总结出 DFS 的模板如下:

 Set<Node> visited;
 void DFS(Node start) {
     //结束条件
     if(shoud be end) {
         return;
    }
     visited.add(start);
     //递归向更深次进行遍历
     for(Node n:start.adjs) {
         if(n is not visited){
          DFS(n);  
        }
    }
 }

1.克隆图问题

在 BFS 章节,我们已经通过 BFS 的方法,解决了该问题,但由于问题本质上就是一个对图进行遍历的问题,只不过需要在遍历的过程中进行复制。因而该问题我们也可以通过 DFS 来解决。

套用 DFS 模板可以写出如下代码:

  Map<Node, Node> map = new HashMap<>();
 
   public Node DFS(Node node) {
     // 终止条件
     if (node == null) {
       return node;
    }
     //已经复制过的话,直接返回复制过的节点
     if(map.containsKey(node)) {
       return map.get(node);
    }
     // 标记访问,并创建拷贝节点
     map.put(node, new Node(node.val));
     for (Node n : node.neighbors) {
       //此处不需要进行访问判断,因为即使被访问过也需要加入到邻接结点列表中
       map.get(node).neighbors.add(DFS(n));
    }
     return map.get(node);
  }

总结

从上述内容,我们不难看出 BFS 相对于 DFS 来说两者本质区别在搜索过程中扩散方式不同。BFS 在搜索时,“齐头并进”从而使得在搜索的时候,所有节点对位于同一个层级,进而可以帮助我们在不完全遍历整个节点的情况下找到所有的最短的路径。而 DFS 由于在搜索时使用的是递归堆栈,最差的空间复杂度是 O(logn),要比 BFS 的 O(n)要小得多。两者各有侧重。

参考

  1. https://mp.weixin.qq.com/s/WH_XGm1-w5882PnenymZ7g
  • LeetCode

    LeetCode(力扣)是一个全球极客挚爱的高质量技术成长平台,想要学习和提升专业能力从这里开始,充足技术干货等你来啃,轻松拿下 Dream Offer!

    209 引用 • 72 回帖
  • 工具

    子曰:“工欲善其事,必先利其器。”

    288 引用 • 734 回帖 • 2 关注
  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3190 引用 • 8214 回帖 • 1 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...