《算法与数据结构》第七章[算法2]:广度优先搜索(BFS)
1、广度优先遍历
聪明的你在上一次废弃地下迷宫通过DFS策略成功找到了宝藏,可是不难发现,这个策略是因为只有一人去,所以我们需要找一条路都走,那假设我们拥有神奇的分身术呢?比如在电影复仇者联盟中奇异博士就做到将自己分成多个分身去与灭霸缠斗,这样一来,我们就不用在遇到一个暗室时只挑一条通道进入了,我们不做选择,而是全都要,这样我们就有了如下策略:
- 每进入一个新的暗室,你(或者分身)就用粉笔在门口做一个记号,表示你已经来过这里了,防止以后再走到这里时重复探索。
- 每进入一个暗室,先用手电筒照亮这个暗室,看看这里有没有宝藏。
- 如果这个暗室没有宝藏,你就会查看这个暗室的所有通道,有多少通道就创建多少分身,然后让分身去逐个探索,每个分身进入一个通道,进入下一个暗室,继续进行上述操作。
- 如果进入的暗室没有通道了,或者所有通道都已经被探索过了(即通道通向的暗室已经被你做过记号了),那你就会结束这个分身的探索,分身消失。
- 如果宝藏被找到,或者发现所有暗室都被探索过了(即所有暗室的门口都有你的记号了),那你就会结束探索,离开迷宫。
这种"层层推进,不漏一处"的搜索策略,就是广度优先遍历中"广度"的体现。在这个方法中,我们优先检查离起点近的所有位置,然后再逐渐向外扩展,就像水波一样从出发结点向四周扩散,确保不会错过任何一个可能的位置。
广度优先遍历(Breadth-First Search, BFS):是一种优先广泛探索图中所有相邻节点的遍历策略。
有些同学会发现不同于DFS,我们不再有返回上一顶点,而是让分身走完后就消失了,这样一来我们就不会再用到递归了,那如何实现呢?我们先来看看过程,试着找一下规律,在此之前,我们要体现层层推进,就需要将每一个顶点的邻接点都作为其下一层,我们发现它就会变成一个类似树的结构,我们来看看:
这是我将DFS中我们用于探索遍历过程的图转化为层次分明的类树形结构,我们发现因为图中边关系非常合适,我们这个图实际上是一棵树,同时也不难发现,该棵树的前序遍历结果即为我们当时找出的DFS遍历结果,也正说明我们提出DFS是类似与树的前序遍历的。那BFS呢?我们来看看BFS的遍历过程:
我们发现这不就是这棵树的层序遍历吗?确实,若果我们前面说DFS是类似于树的前序遍历,那么BFS就是类似于树的层序遍历。可是我们并没有具体探索层序遍历的实现,现在该补上了。
既然BFS不会用到递归,那么它跟栈也就不会有太大关系了,我们先来看看它的具体顺序,如图59中的过程产生的结果应该是:
v3→v4→v5→v1→v2v_3 \rightarrow v_4 \rightarrow v_5 \rightarrow v_1 \rightarrow v_2v3→v4→v5→v1→v2
我们可以看到,它的策略是:先访问当前顶点,然后逐个访问与当前顶点同一层的其余顶点,当本层所有顶点都被访问后,再从本层最开始的顶点开始,逐个访问其邻接点,再逐个访问本层其余顶点的邻接点,重复此过程,直到所有顶点都被访问为止。
我们发现,不同于DFS在选择一个未被访问的邻接顶点进行探索,并不关心当前顶点后续的邻接顶点,即只关心最新到达的顶点;BFS则是关心当前顶点的所有邻接顶点,即先来的(同一层的顶点)我就先处理,后来的等我所有需要先处理的处理结束(同一层顶点都处理完了)后再处理,那这样一来,不就符合“先进先出”的特点了吗。那我们就发现BFS跟栈确确实实是没有关系的,而是跟我们在栈之后所学的队列有关。
现在大家就理解了“队列”一节中我所说队列的应用实例我们当时“还不到学习的时候”的含义了,队列的其中一个应用实例就是BFS。
同样,我们先给出循环队列及涉及到的操作定义:
#define MAXSIZE 100 // 队列的最大长度
typedef int ElemType; // 队列元素类型typedef struct {ElemType data[MAXSIZE]; // 存储队列元素的数组int front; // 队头指针int rear; // 队尾指针
} SqQueue;// 初始化队列
void InitQueue(SqQueue *Q)
{Q->front = 0;Q->rear = 0;
}// 判断队列是否为空
int QueueEmpty(SqQueue *Q)
{return Q->front == Q->rear;
}// 入队操作
int EnQueue(SqQueue *Q, ElemType e)
{if ((Q->rear + 1) % MAXSIZE == Q->front){return 0; // 队满情况}Q->data[Q->rear] = e;Q->rear = (Q->rear + 1) % MAXSIZE;return 1; // 入队成功
}// 出队操作
int DeQueue(SqQueue *Q, ElemType *e)
{if (QueueEmpty(Q)){return 0; // 队空情况}*e = Q->data[Q->front];Q->front = (Q->front + 1) % MAXSIZE;return 1; // 出队成功
}
我们再给出邻接矩阵存储结构的图的BFS的队列实现代码:
#define MAXVEX 100 // 最大顶点数
int visited[MAXVEX]; // 访问标志数组void BFS(GraphAdjMatrix G, int i) // 从顶点i出发进行广度优先遍历
{SqQueue Q;InitQueue(&Q); // 初始化队列EnQueue(&Q, i); // 将起始顶点入队visited[i] = 1; // 标记当前顶点已访问while (!QueueEmpty(&Q)) // 队列不为空{DeQueue(&Q, &i); // 出队printf("%c ", G.vexs[i]); // 访问顶点for (int j = 0; j < G.numVertexes; j++) // 查找当前顶点的所有邻接点{if (G.arc[i][j] == 1 && !visited[j]) // 若邻接点未被访问{visited[j] = 1; // 标记为已访问EnQueue(&Q, j); // 入队}}}
}void BFSTraverse(GraphAdjMatrix G) // 广度优先遍历图
{for (int i = 0; i < G.numVertexes; i++){visited[i] = 0; // 初始化访问标记数组}for (int i = 0; i < G.numVertexes; i++) // 对每个顶点进行检查{if (!visited[i]) // 若顶点未被访问{BFS(G, i); // 从该顶点出发进行BFS}}
}
可以看到,大体上跟DFS栈实现的代码结构是类似的,不同之处在于使用了队列,还有一点是我们对顶点做标记的时机不同,DFS是在入栈时做标记,而BFS是在入队时做标记,这样就避免了重复入队。不懂的话没有关系,我们接下来通过一个例子来具体分析一下BFS的过程。
再给出邻接表的BFS代码:
#define MAXVEX 100 // 最大顶点数
int visited[MAXVEX]; // 访问标志数组void BFS(GraphAdjList G, int i) // 从顶点i出发进行广度优先遍历
{SqQueue Q;InitQueue(&Q); // 初始化队列EnQueue(&Q, i); // 将起始顶点入队visited[i] = 1; // 标记当前顶点已访问while (!QueueEmpty(&Q)) // 队列不为空{DeQueue(&Q, &i); // 出队printf("%c ", G.vexs[i].data); // 访问顶点EdgeNode *p = G.vexs[i].firstedge; // 获取当前顶点的邻接点链表头指针while (p) // 遍历邻接点链表{int j = p->adjvex; // 邻接点的索引if (!visited[j]) // 若邻接点未被访问{visited[j] = 1; // 标记为已访问EnQueue(&Q, j); // 入队}p = p->next; // 移动到下一个邻接点}}
}void BFSTraverse(GraphAdjList G) // 广度优先遍历图
{for (int i = 0; i < G.numVertexes; i++){visited[i] = 0; // 初始化访问标记数组}for (int i = 0; i < G.numVertexes; i++) // 对每个顶点进行检查{if (!visited[i]) // 若顶点未被访问{BFS(G, i); // 从该顶点出发进行BFS}}
}
2、广度优先遍历过程
我们还是通过一个例子来具体分析一下BFS的过程,假设我们有如下图所示的图:
我们从调用BFSTraverse(G)
开始进行广度优先遍历,推出遍历的过程和结果。先初始化所有所需元素,如visited
数组,队列等。
同样我们前面的代码不再看,直接从BFS(G, 0)
开始,即从顶点v0v_0v0开始。
首先我们将v0v_0v0入队并标记为已访问。
接下来进入循环,判断队列不为空,将队中第一个元素即v0v_0v0出队,出队后先进行访问(输出),然后我们就又看到了一个循环:
for (int j = 0; j < G.numVertexes; j++) // 查找当前顶点的所有邻接点
{if (G.arc[i][j] == 1 && !visited[j]) // 若邻接点未被访问{visited[j] = 1; // 标记为已访问EnQueue(&Q, j); // 入队}
}
可以看到与DFS相同的是,循环变量j
均是从0开始,也就是说,我们在同一层的顶点搜索策略也是从下标最小开始的,然后我们同样看G.arc[0]=[0,1,0,0,0,0]G.arc[0]=[0,1,0,0,0,0]G.arc[0]=[0,1,0,0,0,0],也就是说,完整的循环后只有j=1
时符合条件,换言之,就是我们将v1v_1v1标记并入队,如下:
for
循环结束后,我们又进入下一轮while
循环,判断队列不为空,再将队中v1v_1v1出队,访问。
在v1v_1v1顶点同样,以下标从小到大的顺序逐个检查其邻接顶点,找到v3v_3v3和v5v_5v5(v0v_0v0已被访问则不再处理),将它们标记并入队。然后再进入下一轮while
循环。
将队中首位v3v_3v3出队进行访问,然后检查它的邻接顶点,发现v2v_2v2和v4v_4v4符合条件,标记并入队。然后进入下一轮while
循环。
将队首v5v_5v5出队进行访问,检查其邻接顶点,其邻接顶点有v1v_1v1和v4v_4v4,我们知道v1v_1v1已经被访问了,但是v4v_4v4却并没有被真正访问(输出),而是在队中,这样大家就能理解为什么要在入队时进行标记了,因为v4v_4v4已经在队中了,相当于我们心里已经有底了,它被访问只是迟早的事,没有必要再次入队了。此时v5v_5v5访问完之后就相当于我们(我们的分身)不会再继续探索了,他已经将他能所做的都做完了,则此轮循环没有顶点入队。进入下一轮while
循环。
后序过程则只剩将队中元素v2v_2v2和v4v_4v4依次出队做访问,for
循环中则因为没有符合条件的顶点了,所以没有入队,最终队列为空,while
循环结束。同样回到BFSTraverse(G)
中,继续检查下一个顶点v1v_1v1,发现它已经被访问了,则继续检查v2v_2v2,同理也被访问了,继续检查v3v_3v3和v4v_4v4和v5v_5v5均被访问了,则最终BFS遍历结束,遍历结果为:
v0→v1→v3→v5→v2→v4v_0 \rightarrow v_1 \rightarrow v_3 \rightarrow v_5 \rightarrow v_2 \rightarrow v_4v0→v1→v3→v5→v2→v4
我们将图3中的图转化为类树形结构,如下:
可以看到,移去多余的边(v4,v5)(v_4,v_5)(v4,v5)后(多余即指在BFS中未被经过的边),它变成一棵树,而这棵树的层序遍历结果即为我们上面所求的BFS遍历结果。
至此,我们完成了BFS的学习,有同学可能会想,DFS我们用了两种方式(递归、栈)实现,而BFS呢?事实上,BFS只能用队列实现,因为它的策略决定了它只能是“先进先出”的,只能用队列实现。
3、DFS与BFS总结
我们通过上面的学习,已经掌握了DFS和BFS两种图的遍历策略,下面我们来总结一下它们的异同:
特点 | 深度优先遍历(DFS) | 广度优先遍历(BFS) |
---|---|---|
遍历策略 | 优先探索当前顶点的一个未被访问的邻接顶点 | 优先探索当前顶点的所有未被访问的邻接顶点 |
数据结构 | 栈(递归或显式栈) | 队列 |
访问顺序 | 先深入后回溯 | 先广后深 |
适用场景 | 适合用于路径搜索、拓扑排序等 | 适合用于最短路径搜索、层次遍历等 |
通过上表,我们可以清晰地看到DFS和BFS的不同之处,同时也能理解它们各自的适用场景。选择合适的遍历策略,可以有效地解决不同类型的问题。