算法导论-第22章-BFS和DFS
本章将介绍图的表示和图的搜索。图的搜索指的是跟随图中的边来访问图中的每个结点。图搜索是整个图算法领域的核心。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})\) 予以表示:
邻接矩阵的存储空间为 \(\Theta(V^2)\)。
无向图的邻接矩阵是一个对称矩阵。即 \(A=A^T\)。在某些应用中,可能只需要存放对角线及其以上部分,从而将图存储空间减少近半。
邻接矩阵也可以用来表示带权图。
- 若 \((u, v) \in E\) ,则 \(a_{uv} = w(u, v)\);
- 若 \((u, v) \notin E\) ,则 \(a_{uv}=NIL\) ,通常用 0 或 ∞ 表示。
下图是分别是无向图和有向图的邻接链表和邻接矩阵表示法。
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的执行过程如下:
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
深度优先算法的运行时间为 \(\Theta(V+E)\)。