数据结构与算法--图的搜索(深度优先和广度优先)

数据结构与算法--图的搜索(深度优先和广度优先)

有时候我们需要系统地检查每一个顶点或者每一条边来获取图的各种性质,为此需要从图的某个顶点出发,访遍图中其余顶点,且使得每一个顶点只被访问一次,这个过程就称为图的搜索或者图的遍历。如果限制某个顶点只被访问一次?我们可以建立一个布尔数组,在某个顶点第一次被访问时,将该顶点在数组中对应的下标设置为true。图的搜索通常由两种方案——深度优先搜索和广度优先搜索。

深度优先搜索

深度优先搜索(Depth First Search),简称DFS,该方法主要思想是:

  • 从某一个顶点开始,选择一条没有到达过的顶点(布尔数组中对应的值为false)
  • 标记刚选择的顶点为“访问过”(布尔数组中对应的值设置为true)
  • 来到某个顶点,如果该顶点周围的顶点都访问过了,返回到上个顶点
  • 当回退后的顶点依然是上述情况,继续返回

这听起来像是递归。没错,代码确实是递归实现的,并且实现起来特别简单。

package Chap7;

import java.util.Arrays;
import java.util.List;

public class DepthFirstSearch {
    // 用来标记已经访问过的顶点,保证每个顶点值访问一次
    private boolean[] marked;
      // s为搜索的起点
    public DepthFirstSearch(UndiGraph<?> graph, int s) {
        marked = new boolean[graph.vertexNum()];
        dfs(graph, s);
    }

    private void dfs(UndiGraph<?> graph, int v) {
        // 将刚访问到的顶点设置标志
        marked[v] = true;
          // 打印刚访问的顶点,可换成其他操作
        System.out.println(v);
        // 从v的所有邻接点中选择一个没有被访问过的顶点
        for (int w : graph.adj(v)) {
            if (!marked[w]) {
                dfs(graph, w);
            }
        }
    }

    public static void main(String[] args) {
        List<String> vertexInfo = Arrays.asList("v0", "v2", "v3", "v4", "v5");
        int[][] edges = {{0, 1}, {0, 2}, {0, 3},
                {1, 3}, {1, 4},
                {2, 4}};

        UndiGraph<String> graph = new UndiGraph<>(vertexInfo, edges);
        DepthFirstSearch search = new DepthFirstSearch(graph, 0);

    }
}

从代码中看出,深度优先搜索其实就两步:

  • 标记访问过的顶点
  • 递归地访问当前顶点所有没有被标记过的邻居顶点。

在上面的实现中,我们对访问的每个顶点执行了打印操作。打印只是告诉我们搜索的顺序。不过我们很想知道从某个起点开始到另一个顶点的路径。

为此我们用到了一个edgeTo[]的整型数组,这个数组可以记住每个顶点到起点的路径,而不是记录当前顶点到起点的路径。为了做到这一点在由边v-w第一次访问任意w时,将edgeTo[w]设为v,表示v-w是起点s到w的路径上的最后一条已知的边。比如0-2-3-5,表示从0到5的路径,那么edgeTo[5] = 3。同理如果只是到3的路径,那么edgeTo[3] =2, 到2的路径是edgeTo[2] = 0。这样,我们得到的edgeTo[]其实是一棵根结点为起点的树,而且数组里存的是下标的父结点。就像下图一样。

image

edgeTo[1]= 2,而结点1的父结点就是结点2;edgeTo[2] = 0,而顶点2的父结点就是结点0,这和树的双亲表示法有点类似。不存在给edge[0](根结点)赋值的情况,因为此例中我们的起点是顶点0,所以edgeTo[0]保持默认值0。从树中可以看出,起点到顶点5的路径是0-2-3-5。如果我们写一个方法pathTo(),若传入5,只能先获取到edgetTo[5],得到父结点为3,然后根据edgeTo[3]得到父结点2...一直到获取到根结点,可以看到获取的顺序是从叶子结点到根结点,但是真正输出路径的时候是从根结点到叶子结点,所以利用栈(Stack)可以实现这一过程。稍微想一下,先入栈的5被排在了底下,最后入栈的0排在了最顶上,确实是这样的。

现在来实现。

package Chap7;

import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;

public class DepthFirstSearch {
    // 用来标记已经访问过的顶点,保证每个顶点值访问一次
    private boolean[] marked;
    // 起点
    private final int s;
    // 到该顶点的路径上的最后一条边
    private int[] edgeTo;

    public DepthFirstSearch(UndiGraph<?> graph, int s) {
        this.s = s;
        marked = new boolean[graph.vertexNum()];
        edgeTo = new int[graph.vertexNum()];
        dfs(graph, s);
    }

    private void dfs(UndiGraph<?> graph, int v) {
        // 将刚访问到的顶点设置标志
        marked[v] = true;
//        System.out.println(v);
        // 从v的所有邻接点中选择一个没有被访问过的顶点
        for (int w : graph.adj(v)) {
            if (!marked[w]) {
                edgeTo[w] = v;
                dfs(graph, w);
            }
        }
    }

    // 连通图的任意一个顶点都有某条路径能到达任意一个顶点,如果v在这个连通图中,必然存在起点到v的路径
    // 现在marked数组中的值都是true,所以数组中若有这个v(在这个连通图中), 返回true就表示路径存在
    public boolean hasPathTo(int v) {
        return marked[v];
    }

    public Iterable<Integer> pathTo(int v) {
        if (hasPathTo(v)) {
            LinkedList<Integer> path = new LinkedList<>();
            for (int i = v; i != s; i = edgeTo[i]) {
                path.push(i);
            }
            // 最后将根结点压入
            path.push(s);
            return path;
        }
        // 到v不存在路径,就返回null
        return null;
    }

    public void printPathTo(int v) {
        System.out.print(s+" to "+ v+": ");

        if (hasPathTo(v)) {
            for (int i : pathTo(v)) {
                if (i == s) {
                    System.out.print(i);
                } else {
                    System.out.print("-" + i);
                }
            }
            System.out.println();
        } else {
            System.out.println("不存在路径!");
        }
    }

    public static void main(String[] args) {
        List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4", "v5");
        int[][] edges = {{3, 5},{0, 2}, {0, 1}, {0, 5},
                {1, 2}, {3, 4}, {2, 3}, {2, 4}};

        UndiGraph<String> graph = new UndiGraph<>(vertexInfo, edges);

        DepthFirstSearch search = new DepthFirstSearch(graph, 0);
        search.printPathTo(4);
    }
}


/* Output

0 to 5: 0-2-3-5

*/

只是在深度优先搜索的实现中新加了一些东西,最重要的是在dfs递归方法中插入了一行edgeTo[w] = v;,我们知道w是v的一个邻接点,那么这行的字面意思就是到w的顶点是v,即路径v-w。上面提到过这是起点s到w的最后一条边。

扩展的方法hasPathTo用来判断是否有到某顶点的路径,由于这是连通图,任意一个顶点(包括起点s)都有某条路径能到达任意一个顶点,在初始化该类时,已经调用过深度优先搜索,所以marked数组里都是true,这意味着只要某个顶点能在marked数组中找到对应的下标,那么返回true,表示肯定存在到它的路径。

我们的pathTo用来确定一条从起点到指定顶点的路径,注意这条路径不一定是最短的,也可能并非是唯一路径。必须先判断是否存在到该指定顶点的路径,如果不存在则返回null;若存在,则从查找的顶点开始入栈,i = edgeTo[i]表示树向上一层,更新当前值为结点i的父结点,直到根结点停止,由条件i != s可知,根结点并没有入栈,所以在循环之后要将根结点入栈。

printPathTo就是将pathTo返回的内容格式化输出,就像这样。表示顶点0到顶点5的路径是0-2-3-5。

0 to 5: 0-2-3-5

可见这路径并不是最短路径,0-5直接可达才是最短的。

现在我们来看下深度优先搜索的详细轨迹,注意对照着上图的邻接表:先是从起点0开始

  • 因为2是0的邻接表第一个元素且没有被标记访问,则递归调用它并标记。edgeTo[2] = 0表示0-2这条路径。
  • 现在顶点0是顶点2的邻接表第一个元素,但是0已经被标记了,所以跳过它,看下一个。1没有被标记,所以递归调用它,并标记。edgeTo[1] = 2,表示0-2-1这条路径。
  • 顶点1的邻接表元素都被标记过了,所以不再递归,方法从dfs(1)中返回到上一个顶点2,现在检查2的下一个邻接顶点,3没被标记所以递归它并标记,edgeTo[3] = 2表示0-2-3这条路径。
  • 顶点5是3的邻接表第一个元素没被标记,递归调用它并标记,edgeTo[5] = 3表示0-2-3-5这条路径。
  • 顶点5的邻接表元素都被标记过了,方法从dfs (5)中返回到带上一个顶点3,检查3的邻接表下一个元素,4没有被标记,所以递归它并标记,edgeTo[4] = 3表示0-2-3-4。至此,所有顶点都被标记过。搜索算是完成了。

DFS的非递归版本

DFS也可以自己设一个栈模拟系统栈,下面是非递归版本。


/**
* 非递归实现DFS
*
* @param graph 图
* @param s    起点
*/
public void dfs(UndiGraph<?> graph, int s) {
      boolean[] marked = new boolean[graph.vertexNum()];
      // 模拟系统栈
      LinkedList<Integer> stack = new LinkedList<>();
      // 起点先入栈
      stack.push(s);
      // 标记访问
      marked[s] = true;
      System.out.print(s);

      while (!stack.isEmpty()) {
          // 取出刚访问的顶点
        int v = stack.peek();
        for (int w : graph.adj(v)) {
              if (!marked[w]) {
                marked[w] = true;
                System.out.print(w);
                stack.push(w);
                  // 模拟DFS只存入一个就好,一定要break
                break;
              }
        }
        // 所有邻接点都被访问过了,模拟递归的返回
        stack.pop();
      }
}

广度优先搜索

深度优先搜索得到的路径上面已经看到,并不是最短路径,很自然地我们对下面的问题感到兴趣:单点最短路径,即给定一个图和一个起点s,是否存在到给定顶点v的一条路径,如果有找出最短的那条。

解决这个问题方法是广度优先搜索(Breadth First Search),简称BFS。

这个算法的思想大体是:

  • 从起点开始,标记之并加入队列。
  • 起点出列,其所有未被标记的邻接点在被标记后,入列。
  • 队列头的元素出列,将该元素的所有未被标记的邻接点标记后,入列。

如此反复,当队列为空时,所有顶点也都被标记过了。不像DFS的递归那样隐式地使用栈(系统管理的,以支持递归),BFS使用了队列。

package Chap7;

import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;

public class BreadthFirstSearch {
    // 用来标记已经访问过的顶点,保证每个顶点值访问一次
    private boolean[] marked;
    // 起点
    private final int s;
    // 到该顶点的路径上的最后一条边
    private int[] edgeTo;


    public BreadthFirstSearch(UndiGraph<?> graph, int s) {
        this.s = s;
        marked = new boolean[graph.vertexNum()];
        edgeTo = new int[graph.vertexNum()];
        bfs(graph, s);
    }

    public void bfs(UndiGraph<?> graph, int s) {
        marked[s] = true;
        // offer入列, poll出列
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(s);
        while (!queue.isEmpty()) {
            int v = queue.poll();
//            System.out.print(v+" ");
            for (int w: graph.adj(v)) {
                if (!marked[w]) {
                    edgeTo[w] = v;
                    marked[w] = true;
                    queue.offer(w);
                }
            }
        }
    }

    public boolean hasPathTo(int v) {
        return marked[v];
    }

    public Iterable<Integer> pathTo(int v) {
        if (hasPathTo(v)) {
            LinkedList<Integer> path = new LinkedList<>();
            for (int i = v; i != s; i = edgeTo[i]) {
                path.push(i);
            }
            // 最后将根结点压入
            path.push(s);
            return path;
        }
        // 到v不存在路径,就返回null
        return null;
    }

    public void printPathTo(int v) {
        System.out.print(s+" to "+ v+": ");

        if (hasPathTo(v)) {
            for (int i : pathTo(v)) {
                if (i == s) {
                    System.out.print(i);
                } else {
                    System.out.print("-" + i);
                }
            }
            System.out.println();
        } else {
            System.out.println("不存在路径!");
        }
    }

    public static void main(String[] args) {
        List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4", "v5");
        int[][] edges = {{3, 5},{0, 2}, {0, 1}, {0, 5},
                {1, 2}, {3, 4}, {2, 3}, {2, 4}};

        UndiGraph<String> graph = new UndiGraph<>(vertexInfo, edges);
        BreadthFirstSearch search = new BreadthFirstSearch(graph, 0);
        search.printPathTo(5);
    }
}

/* Output

0 to 5: 0-5

*/

我把打印操作注释了,实际上它会输出如下内容

0 2 1 5 3 4

先是打印了起点,然后依次打印了0的所有邻接点2、1、5,之后按照队列的出列顺序,打印2的所有未被标记的邻接点,实际上这已经打印完了所有顶点。而且从代码里也能看出,不像DFS那样每次只标记一个顶点,BFS每次都标记了若干顶点。

上面的代码中,除了bfs的实现代码,其余有关path的方法可以直接使用DFS中的实现。还是来看下详细的搜索轨迹。

  • 首先顶点0入列
  • 顶点0出列,将它所有邻接点2、1、5(参考DFS中的邻接表图片),标记他们。且edgeTo[2]、edgeTo[1]、edgeTo[5]的值都设为0
  • 顶点2出列,检查它的邻接点,0、1已经被标记,于是将3、4入列,并标记它们。edgeTo[3]、edgeTo[4]的值都设为2
  • 顶点1出列,其邻接点均已被标记
  • 顶点5出列,其邻接点均已被标记
  • 顶点3出列,其邻接点均已被标记
  • 顶点4出列,其邻接点均已被标记

可以发现,实际上标记工作和edgeTo数组在第三步之后就已经完成,之后的工作只是检查出列的顶点的邻接点是否被标记过而已。

我们不妨打印下起点到其余各个顶点的路径

0 to 5: 0-5
0 to 4: 0-2-4
0 to 3: 0-2-3
0 to 2: 0-2
0 to 1: 0-1

不难发现,这些路径都是最短路径。实际上有这么一个命题:对于从s可达的任意顶点v,广度优先搜索都能找到一条从s到v的最短路径。

广度优先搜索是先覆盖起点附近的顶点,只在邻近的所有顶点都被访问后才向前进,其搜索路径短而直接;而深度优先搜索是寻找离起点更远的顶点,只有在碰到周围的邻接点都被访问过了才往回退,选一个近处的顶点,继续深入到更远的地方,其路径长而曲折。

以上深度优先和广度优先的实现对于有向图也是适用的,把接收的参数的换成有向图即可。


by @sunhaiyu

2017.9.19

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容

  • 1)这本书为什么值得看: Python语言描述,如果学的Python用这本书学数据结构更合适 2016年出版,内容...
    孙怀阔阅读 12,458评论 0 15
  • 大部分内容来自于《大话数据结构》,代码全部使用Swift实现。至于为什么抽风写这个?😊你懂的。 1.线性表 线性表...
    一剑孤城阅读 81,802评论 12 111
  • 图是一种比线性表和树更复杂的数据结构,在图中,结点之间的关系是任意的,任意两个数据元素之间都可能相关。图是一种多对...
    Alent阅读 2,298评论 1 22
  • 课程介绍 先修课:概率统计,程序设计实习,集合论与图论 后续课:算法分析与设计,编译原理,操作系统,数据库概论,人...
    ShellyWhen阅读 2,268评论 0 3
  • 电影看完了。可能有人会问我,讲了一个什么故事,是悲剧还是喜剧,我可能会讲不出来。电影看的是一种感觉,一种期待,当你...
    4e9f0f5e6b31阅读 157评论 0 0