算法导论-第22章-BFS和DFS

发布时间 2023-06-29 21:55:41作者: gengduc

本章将介绍图的表示和图的搜索。图的搜索指的是跟随图中的边来访问图中的每个结点。图搜索是整个图算法领域的核心。22.1介绍图的两种表示方法:邻接链表和邻接矩阵。22.2介绍广度优先搜索(BFS)。22.3介绍深度优搜索(DFS)。

22.1 图的表示

对于图 \(G=(V, E)\),有用两种标准表示方法:邻接链表邻接矩阵。两种方法都既可以表示无向图,也可以表示有向图。

  • 稀疏图:\(|E| << |V|^2\),通常使用邻接链表表示。
  • 稠密图:\(|E| 接近 |V|^2\),通常使用邻接矩阵表示。如果需要快速判断两个结点之间是否有边相连,可能也需要使用邻接矩阵表示法。

邻接链表

对于图 \(G=(V, E)\) 来说,其邻接链表表示法由一个包含 \(|V|\) 条链表的数组 \(Adj\) 构成。每个结点有一条对应的链表。对于结点 \(u \in V\),邻接链表 \(Adj[u]\) 表示包含所有与结点 \(u\) 之间有边相连的结点 \(v\)

如果 \(G\) 是一个有向图,所有邻接链表的长度之和等于 \(|E|\)。如果 \(G\) 是一个无向图,所有邻接链表的长度之和等于 \(2|E|\)

无论是有向图还是无向图,邻接链表表示法的存储空间均为 \(\Theta(V+E)\)

对邻接链表稍加修改,可以用来表示权重图。权重图是每条边都有一个相关权重的图。该权重通常由一个权重函数给出。例如,设 \(G=(V, E)\)为一个权重图,其权重函数为 \(w\),可以直接将边 \((u, v) \in E\) 的权重值 \(w(u, v)\) 存放在结点 \(u\) 的邻接链表里。

邻接链表的一个缺点是无法快速判断 \((u, v)\) 是否是图中的一条边,唯一的办法是在邻接链表 \(Adj[u]\) 里面搜索结点 \(v\),邻接矩阵克服了这个缺陷。

邻接矩阵

对于邻接矩阵表示来说,通常会将图 \(G\) 中的结点编号为 \(1, 2, \cdots , |V|\)。编号后,邻接矩阵可以使用一个 \(|V| \times |V|\) 的矩阵 \(A=(a_{ij})\) 予以表示:

\[a_{ij}=\begin{cases} 1& \text{若}(i, j)\in E\\0& \text{其他} \end{cases} \]

邻接矩阵的存储空间为 \(\Theta(V^2)\)

无向图的邻接矩阵是一个对称矩阵。即 \(A=A^T\)。在某些应用中,可能只需要存放对角线及其以上部分,从而将图存储空间减少近半。

邻接矩阵也可以用来表示带权图。

  • \((u, v) \in E\) ,则 \(a_{uv} = w(u, v)\);
  • \((u, v) \notin E\) ,则 \(a_{uv}=NIL\) ,通常用 0 或 ∞ 表示。

下图是分别是无向图和有向图的邻接链表和邻接矩阵表示法。

Figure 20.1

Figure 20.2

22.2 广度优先搜索

给定图 \(G=(V, E)\) 和一个可以识别的源结点 \(s\),广度优先搜索对图 \(G\) 中的边进行系统性的探索来发现可以从源结点 \(s\) 到达的所有结点。该算法能够计算从源结点 \(s\) 到每个可到达的结点的距离,同时生成一棵“广度优先搜索树”。该树以源结点 \(s\) 为根结点,包含所有可以从 \(s\) 到达的结点。对于从 \(s\) 到达的结点 \(v\),在广度优先搜索树里从结点 \(s\) 到结点 \(v\) 的简单路径所对应的就是图 \(G\) 中从 \(s\)\(v\) 的“最短路径”,即包含边数最少的路径。该算法即可用于有向图,也可用于无向图。

为了跟踪算法的进展,广度优先搜索将每个结点涂上白色、灰色或黑色。白色顶点表示该顶点未被发现,灰色顶点表示其邻接顶点可能还有未发现顶点,黑色顶点表示其邻接顶点全部被发现。初始所有顶点为白色。第一次发现某顶点,该顶点变为灰色,即该顶点被发现,所有灰色顶点构成搜索边界。当与某个顶点相连的所有边都被发现后,该顶点变为黑色。

下面给出广度优先搜索过程 \(BFS\),需要给每个顶点 \(v\) 添加额外属性:

  • \(v.color\) 表示 \(v\) 的颜色: WHITE 、 GRAY 或 BLACK 。
  • \(v.d\) 表示从源点 \(s\)\(v\) 的距离。
  • \(v.\pi\) 表示 \(v\) 的在广度优先树中的前驱,若 \(v\) 为源点或者未被发现,故 \(v\) 没有前驱,则 \(v.\pi=NIL\)。用于构造广度优先树。

伪代码如下:

BFS(G, s)
    for each vertex u ∈ G.V - {s}
        u.color = WHITE
        u.d = ∞
        u.π = NIL
    s.color = GRAY
    s.d = 0
    s.π = NIL
    Q = ∅
    ENQUEUE(Q, s)
    while Q ≠ ∅
        u = DEQUEUE(Q)
        for each vertex v in G.Adj[u]    // search the neighbors of u
            if v.color == WHITE    // is v being discovered now?
                v.color = GRAY
                v.d = u.d + 1
                v.π = u
                ENQUEUE(Q, v)    // v is now on the frontier
        u.color = BLACK

BFS的执行过程如下:

Figure 20.3

Java代码实现如下:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;

public class BFS {

    /**
     * 读取data.txt文件,生成邻接矩阵
     *
     * @param path  文件路径
     * @param nodes 结点集
     * @return 邻接矩阵
     * @throws IOException 异常
     */
    public static int[][] readDataFileToMatrix(String path, List<Character> nodes) throws IOException {
        List<String> readAllLines = Files.readAllLines(Paths.get(path));
        String[] split = readAllLines.get(0).split(",");
        for (String s : split) {
            nodes.add(s.charAt(0));
        }

        int[][] adjMatrix = new int[nodes.size()][nodes.size()];
        for (int i = 1; i < readAllLines.size(); i++) {
            String line = readAllLines.get(i);
            char start = line.charAt(0);
            char end = line.charAt(2);

            int row = nodes.indexOf(start);
            int col = nodes.indexOf(end);
            adjMatrix[row][col] = 1;
        }
        return adjMatrix;
    }

    /**
     * BFS(广度优先遍历)
     *
     * @param adjMatrix 邻接矩阵
     * @param nodes     结点集
     * @param node      开始结点
     * @return 遍历的结点数目
     */
    public static int BFSWithMatrix(int[][] adjMatrix, List<Character> nodes, Character node) {
        int count = 0;

        boolean[] visited = new boolean[nodes.size()]; // 该结点是否访问过

        int startVertex = nodes.indexOf(node); // 开始结点在矩阵中对应的索引

        Queue<Integer> queue = new LinkedList<>(); // 辅助队列
        visited[startVertex] = true; // 开始结点是否访问位置为true
        queue.offer(startVertex); // 开始结点加入队列

        while (!queue.isEmpty()) {
            int currentVertex = queue.poll(); // 从队列中弹出
            System.out.print(nodes.get(currentVertex) + " "); // 输出访问的结点
            count++;

            for (int i = 0; i < nodes.size(); i++) { // 遍历结点的出度边
                if (adjMatrix[currentVertex][i] == 1 && !visited[i]) {
                    visited[i] = true;
                    queue.offer(i);
                }
            }
        }
        System.out.println();
        return count;
    }


    /**
     * 读取文件,生成邻接链表
     *
     * @param path 文件路径
     * @return 邻接链表
     * @throws IOException 异常
     */
    public static HashMap<Integer, LinkedList<Integer>> readDataFileToLinkedList(String path) throws IOException {
        List<String> readAllLines = Files.readAllLines(Paths.get(path));

        HashMap<Integer, LinkedList<Integer>> adjLinkedList = new HashMap<>();

        for (String readAllLine : readAllLines) {
            String[] nodePair = readAllLine.split(" ");
            Integer start = Integer.parseInt(nodePair[0]);
            Integer end = Integer.parseInt(nodePair[1]);

            LinkedList<Integer> linkedList;
            if (adjLinkedList.containsKey(start)) {
                linkedList = adjLinkedList.get(start);
            } else {
                linkedList = new LinkedList<>();
            }
            linkedList.add(end);
            adjLinkedList.put(start, linkedList);
        }
        return adjLinkedList;
    }

    /**
     * 邻接链表BFS
     *
     * @param adjLinkedList 邻接链表
     * @param node          起始结点
     * @return 遍历的结点数目
     */
    public static int BFSWithLinkedList(HashMap<Integer, LinkedList<Integer>> adjLinkedList, Integer node) {
        int count = 0; // 遍历的结点数目

        HashSet<Integer> visited = new HashSet<>();// 该结点是否访问过,访问过加入visited集合

        Queue<Integer> queue = new LinkedList<>(); // 辅助队列

        for (Integer integer : adjLinkedList.keySet()) {
            if (!visited.contains(integer)) { // 考虑是否包含其他连通分量
                visited.add(node); // 开始结点是否访问位置为true
                queue.offer(integer); //加入队列
                
                while (!queue.isEmpty()) {
                    Integer tmp = queue.poll(); // 从队列中弹出
                    count++;
                    if (adjLinkedList.get(tmp) != null) { // 有出度
                        for (int i = 0; i < adjLinkedList.get(tmp).size(); i++) { // 遍历结点的出度边
                            if (!visited.contains(adjLinkedList.get(tmp).get(i))) {
                                visited.add(adjLinkedList.get(tmp).get(i));
                                queue.offer(adjLinkedList.get(tmp).get(i));
                            }
                        }
                    }
                }
            }
        }
        return count;
    }


    public static void main(String[] args) throws IOException {

        // 数据集1
        String path1 = "C:\\Projects\\IDEAProjects\\algorithms\\src\\main\\java\\ch22\\data.txt";

        List<Character> nodes = new ArrayList<>();
        int[][] adjMatrix = readDataFileToMatrix(path1, nodes);

        int count1 = BFSWithMatrix(adjMatrix, nodes, 'A');
        System.out.println("数据集1 遍历的结点数目: " + count1);


        System.out.println("===================================");

        // 数据集2
        String path2 = "C:\\Projects\\IDEAProjects\\algorithms\\src\\main\\java\\ch22\\twitter_small.txt";

        HashMap<Integer, LinkedList<Integer>> adjLinkedList = readDataFileToLinkedList(path2);

        long start = System.currentTimeMillis();
        int count2 = BFSWithLinkedList(adjLinkedList, 214328887);
        long end = System.currentTimeMillis();

        System.out.println("数据集2 遍历的结点数: " + count2);
        System.out.println("BFS耗时: " + (end - start) + "ms");
    }
}

分析

BFS是一种借用辅助队列来存储的过程,分层查找,优先考虑距离出发点近的点。无论是在邻接表还是邻接矩阵中存储,都需要借助一个辅助队列,\(v\) 个顶点均需入队,最坏的情况下,空间复杂度为 \(\Omicron(V)\)

邻接表形式存储时,每个顶点均需搜索一次,每次结点被入队一次,出队一次,所以时间复杂度是 \(\Omicron(V)\),但是遍历某个顶点所有邻接点的复杂度是 \(\Omicron(e_i)\), ei 为第 \(i\) 条链表的弧的条数,也就是邻接点个数,所以查找所有邻接点的复杂度为 \(\Omicron(e_1 + e_2 + \cdots + e_n) = \Omicron(E)\), 加上对每个结点进行入队出队的复杂度 \(\Omicron(V)\), 所以总的时间复杂度为\(\Omicron(V + E)\)

邻接矩阵存储方式时,查找每个顶点的邻接点所需时间为 \(\Omicron(V)\),即该节点所在的该行的所有列。又因为有 \(n\) 个顶点,查找所有邻接点的时间复杂度为 \(\Omicron(V^2)\), 加上对每个结点进行入队出队的复杂度 \(\Omicron(V)\), 所以总的时间复杂度为 \(\Omicron(V^2+V)\)。省略低阶复杂度,最终复杂度为 \(\Omicron(V^2)\)

广度优先树

过程BFS在对图的搜索过程中将创建一棵广度优先树,该树对应的是 \(\pi\) 属性。形式化定义:对于图 \(G=(V, E)\) 和源结点 \(s\),我们定义图 \(G\)前驱子图\(G_\pi=(V_\pi, E_\pi)\),其中 \(V_\pi=\{v \in V: v.\pi \ne NIL\}\cup \{s\}\)\(E_\pi=\{(v.\pi, v):v \in V_\pi-\{s\}\}\)

如果 \(V_\pi\) 由从源结点 \(s\) 可以到达的结点组成,并且对于所有的 \(v \in V_\pi\),子图 \(G_\pi\) 包含一条从源结点 \(s\) 到结点 \(v\) 的唯一简单路径,且该路径也是图 \(G\) 里面从源结点 \(s\) 到结点 \(v\) 的一条最短路径,则前驱子图 \(G_\pi\) 是一棵广度优先树。广度优先树实际上就是一棵树,因为它是连通的,并且 \(|E_\pi|=|V_\pi|-1\)。我们称 \(E_\pi\) 为树边。

引理:当运行在一个有向或无向图 \(G=(V, E)\) 上时,BFS过程所构造出来的 \(\pi\) 属性使得前驱子图 \(G_\pi=(V_\pi, E_\pi)\) 成为一棵广度优先树。

假定过程BFS已经构造出一棵广度优先树,过程PRINT-PATH打印从 \(s\)\(v\) 的一条最短路径上的所有顶点,伪代码如下:

PRINT-PATH(G, s, v)
    if v == s
        print s
    else if v.π == NIL
        print "no path from" s "to" v "exists"
    else PRINT-PATH(G, s, v.π)
        print v

过程PRINT-PATH的运行时间与路径上的顶点个数呈线性关系。

22.3 深度优先搜索

深度优先搜索(depth-first search):只要可能,就在图中尽量“深入”。深度优先搜索优先沿着从最近发现的顶点 \(v\) 发出的一条边进行探索,尽管该顶点可能还有其他边未被探索。一旦从 \(v\) 发出的所有边都被探索后,则需要回溯(backtrack)到到达 \(v\) 的前一个顶点进行继续探索。不断执行上述过程,直到从源点可达的所有顶点全部被发现。若还有顶点未被发现,选择未被发现的顶点中的任一顶点作为新的源点继续上述过程。深度优先搜索重复以上全部过程直到所有顶点全部被发现。

DFS(G)
    for each vertex u ∈ G.V
        u.color = WHITE
        u.π = NIL
    time = 0
    for each vertex u ∈ G.V
        if u.color == WHITE
            DFS-VISIT(G, u)

DFS-VISIT(G, u)
    time = time + 1    // white vertex u has just discovered
    u.d = time
    u.color = GRAY
    for each vertex v ∈ G.Adj[u]    // explore each edge (u, v)
        if v.color == WHITE
            v.π = u
            DFS-VISIT(G, v)
    time = time + 1
    u.f = time
    u.color = BLACK    // blacken u; it is finished

Figure 20.4

深度优先算法的运行时间为 \(\Theta(V+E)\)