【数据结构】图论探秘:广度优先遍历(BFS)与生成树的构建艺术
广度优先遍历
- 导读
- 一、图的遍历
- 二、广度优先搜索
- 2.1 基本思想
- 2.2 算法逻辑
- 2.2.1 连通图的遍历
- 2.2.2 非连通图的遍历
- 2.3 算法分析
- 2.4 广度优先生成树
- 🌟 结语 | 探索不止,下期更精彩
导读
大家好,很高兴又和大家见面啦!!!
在前面的内容中,我们已经认识了图,学习了图的一些基本概念与核心术语以及4种图的存储结构:
- 邻接矩阵:适合存储稠密图
- 邻接表:适合存储稀疏图
- 十字链表:适合存储有向图
- 邻接多重表:适合存储无向图
并且我们还了解了图的一些基本操作:
Adjacent(G, x, y)
: 判断图G是否存在边<x, y>
或(x, y)
;Neighbors(G, x)
: 列出图G中与结点x邻接的边InsertVertex(G, x)
: 在图G中插入顶点xDeleteVertex(G, x)
: 在图G中删除顶点xAddEdge(G, x, y)
: 若无向边(x, y)
或有向边<x, y>
不存在,则向图G中添加改边RemoveEdge(G, x, y)
: 若无向边(x, y)
或有向边<x, y>
存在,则从图G中删除该边FirstNeighbors(G, x)
: 求图G中顶点x的第一个邻接点,若有则返回顶点号。若x没有邻接点或图中不存在x,则返回-1NextNeighbors(G, x, y)
: 假设图G中顶点 y 是顶点 x 的一个邻接点,返回除 y 外顶点 x 的下一个邻接点的顶点号,若 y 是 x 的最后一个邻接点,则返回-1Get_edge_value(G, x, y)
: 获取图G中边(x, y)
或<x, y>
对应的权值Set_edge_value(G, x, y, v)
: 设置图G中边(x, y)
或<x, y>
对应的权值
从今天开始,我们将会进入图的遍历操作的学习。下面我们就进入今天的内容;
一、图的遍历
图的遍历是指从图中的某一顶点出发,按照某种搜索方法沿着图中的边对图中的所有顶点访问一次,且仅访问一次。
在前面的内容中我们有说过,之前学习的数据结构:
- 线性表:顺序表、链表、栈、队列……
- 树
- 集合
都属于一种特殊图。因此这些数据结构的遍历都可以视为一种图的遍历。图的遍历算法是求解图的连通性问题、拓扑排序和求关键路径等算法的基础。
在这些数据结构中,树的遍历要比线性表或者集合的遍历要复杂,而图的遍历要比树的遍历更加复杂,这是因为两种数据结构的元素之间的关系之间存在差异:
- 树形结构:元素之间满足一对多的关系
- 图状结构:元素之间满足多对多的关系
由于这种多对多的关系,这使得图中的任意一个顶点都可能与其余顶点相邻接,所以在访问某个顶点后,可能沿着某条路径搜索又回到了该顶点。
为了避免同一顶点被多次访问,在遍历图的过程中,必须记下每个已访问过的顶点,为此可以设一个辅助数组visited[]
来标记顶点是否被访问过。
在图中,遍历算法主要有两种:
- 广度优先搜索
- 深度优先搜索
今天我们将会介绍第一种遍历——广度优先搜索。
二、广度优先搜索
广度优先搜索(Breadth-First-Search, BFS)类似于树的层序遍历。
PS:对树的层序遍历感兴趣的朋友可以回顾【数据结构】C语言实现二叉树的基本操作——二叉树的层次遍历、求深度、求结点数……
在这篇博客中,有介绍二叉树中的层序遍历以及C语言的实现,这里我就不再展开赘述。
2.1 基本思想
BFS 的基本思想是:
- 首先访问起始顶点v;
- 接着从v出发,依次访问v的各个未被访问过的邻接顶点 w 1 , w 2 , … … , w i w_1, w_2, ……, w_i w1,w2,……,wi 的所有未被访问过的邻接顶点;
- 再从这些访问过的顶点出发,访问它们所有未被访问过的邻接顶点;
- 直至图中的所有顶点都被访问过为止;
- 若此时图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作为起始点,重复上述过程,直至图中所有顶点都被访问到为止。
换句话说,BFS 遍历图的过程就是以v为起点,由近至远依次访问和v有路径相通且路径长度为1, 2……的顶点。
从二叉树的角度来看,BFS 就是一种分层查找的过程,与起始点v路径相同的顶点位于同一层,每一次访问都会完成同一层全部顶点的访问。
在上图中,各个顶点按路径长度的分层如下:
- 顶点b, c 与起始点a的路径长度为1,因此b, c位于同一层
- 顶点d, e与起始点a的路径长度为2,因此d, e位于同一层
- 顶点f, g与起始点a的路径长度为3,因此f, g位于同一层
因此按照图的广度优先搜索进行遍历时,获得的遍历序列为: abcdefg
不过在图中,其遍历并不是唯一的,具体的遍历序列需要根据图的存储结构确定:
- 邻接矩阵:图的遍历序列唯一
- 邻接表、十字链表、邻接多重表:图的遍历序列不唯一
在这四种存储结构中,十字链表与邻接多重表的实现比较复杂,下面我们还是以简单的邻接矩阵和邻接表这两种存储结构来介绍BFS;
2.2 算法逻辑
2.2.1 连通图的遍历
因为广度优先遍历是一种层序遍历,参考二叉树的层序遍历,我们同样可以通过辅助队列来完成整个遍历的过程:
- 队列中存放当前遍历层的顶点信息以及下一层的顶点信息;
- 队头元素为当前需要进行访问的顶点
- 当队头元素出队时,与该顶点相邻的且未被访问过的顶点入队
- 当队列为空时,完成所有顶点的遍历
但是在图中,由于顶点之间可能存在相互邻接,那么为了正确判断当前顶点是否被访问,我们则需要通过一个额外的数组visited[]
来判断当前邻接顶点是否被访问过。
整个逻辑可以用C语言代码表示为:
bool visited[MAX_VERTEX_NUM]; // 访问标记数组
Queue* q = NULL; // 队列指针
// 广度优先搜索
void BFS(Graph* G, int i) {q = Create_Queue(); // 创建队列visit(i); // 访问起始点visited[i] = true; // 添加访问标记EnQueue(q, i); // 遍历起点入队while (!Is_Empety) { // 队列为空时,停止遍历ElemType x = DeQueue(q); // 队首元素出队for (ElemType y = FirstNeighbors(G, x); y != -1; y = NextNeighbors(G, x, y)) {if (!visited[y]) { // 当前顶点未被访问visit(y); // 访问当前顶点visited[y] = true; // 添加访问标记EnQueue(q, y); // 当前顶点入队}}}
}
在整个遍历的过程中,我们需要注意的是邻接顶点的获取。不同的存储结构,其获取邻接顶点的方法也有所不同。但是,不管采用何种存储结构,其广度优先遍历的逻辑是不会发生改变:
- 创建好一个空队列
- 遍历起始点并进行标记
- 起始点入队
- 队首元素出队
- 获取与队首顶点相邻的下一个顶点信息
- 当该顶点被访问过,继续获取除该顶点以外的下一个顶点信息
- 当该顶点未被访问过,访问该顶点并进行标记,将该顶点入队
- 当队列为空时,完成连通图中所有顶点的访问
2.2.2 非连通图的遍历
当给定的图为非连通图时,如果我们只是进行简单的 BFS
是无法完成对非连通图中的所有连通分量的访问的,因此我们还需要通过 visited[]
数组来判断是否存在未被访问的顶点:
// 图的BFS
void BFSTraverse(Graph* G) {// 初始化标记数组for (int i = 0; i < MAX_VERTEX_NUM; i++) {visited[i] = false;}// 检查是否存在未被访问的顶点for (int i = 0; i < MAX_VERTEX_NUM; i++) {if (!visited[i]) { // 当前顶点未被访问BFS(G, i); // 通过BFS完成该连通分量的访问}}
}
在上述代码中,BFS
被调用的次数与连通分量的个数相同:
- 当图G为连通图时,只会调用一次
BFS
且起始点从下标为0的顶点开始 - 当图G为非连通图时,每次调用
BFS
都会完成一个连通分量的遍历
在后续的内容中,我们将会完成图中各种基本操作的实现,这里就不再展开,大家记得关注哦!!!
2.3 算法分析
在整个算法中,其主要的时间消耗为:
- 标记数组的初始化 —— O ( ∣ V ∣ ) O(|V|) O(∣V∣)
- 标记数组的检查 —— O ( ∣ V ∣ ) O(|V|) O(∣V∣)
BFS
的调用- 邻接表的实现中:
- 顶点的入队 —— O ( ∣ V ∣ ) O(|V|) O(∣V∣)
- 访问顶点
x
的所有邻边 —— O ( ∣ E ∣ ) O(|E|) O(∣E∣) - 总的时间复杂度为: O ( ∣ V ∣ + ∣ E ∣ ) O(|V| + |E|) O(∣V∣+∣E∣)
- 邻接矩阵的实现中:
- 顶点的入队 —— O ( ∣ V ∣ ) O(|V|) O(∣V∣)
- 访问顶点
x
的所有邻边 —— O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2) - 总的时间复杂度为: O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)
- 邻接表的实现中:
在前面的内容中我们有过详细的介绍,这里就不再重复赘述。
2.4 广度优先生成树
广度优先生成树 指的是通过 BFS
遍历图时,所得到的一个遍历树,该遍历树中保留了所有的顶点以及被正常访问的边。
这里需要理解的是何为正常访问的边,我们以下图为例:
在这个图中,各顶点与其邻接顶点的关系为:
- 顶点a与顶点b、顶点c相邻
- 顶点b与顶点a、顶点c、顶点d相邻
- 顶点c与顶点a、顶点b、顶点d相邻
当我们从顶点a开始进行 BFS
时,我们在完成顶点a的访问后,此时的队列中只有顶点a:
接下来会进一步访问其邻接顶点,与顶点a相邻的有两个顶点:
- 顶点b:未访问
- 顶点c:未访问
这里我们以访问顶点b为例,对应的边为: ( a , b ) (a, b) (a,b)。当访问完顶点b后,此时队列中只有顶点b:
接下来我们会继续访问顶点a除顶点b以外的下一个顶点c,对应的边为: ( a , c ) (a, c) (a,c)。完成顶点c访问后,此时队列中存在顶点b与顶点c:
第一轮对顶点a的邻接顶点访问结束,下面开始对顶点b的邻接顶点进行访问,此时与顶点b相邻的有3个顶点:
- 顶点a:已访问
- 顶点c:已访问
- 顶点d:未访问
由此我们需要继续访问的是顶点d,对应的边为: ( b , d ) (b, d) (b,d)。当完成了对顶点d的访问后,此时的队列中存在的顶点为:顶点d和顶点c
接下来不管是对顶点c还是顶点d的邻接点进行访问,都不存在未被访问的顶点,因此我们实际完成的访问边为: ( a , b ) − > ( a , c ) − > ( b , d ) (a, b)->(a, c)->(b, d) (a,b)−>(a,c)−>(b,d),其边与对应顶点所得到的生成树为:
在原图中,边 ( b , c ) 、 ( c , d ) (b, c)、(c, d) (b,c)、(c,d) 在整个遍历的过程中未完成访问,因此在生成树中被去掉。
PS:
判断一条边是否被访问,是通过判断在遍历的过程中是否同时访问了该边的两个顶点。如:
- 队首元素为顶点a,则当a完成出队操作后正常访问的顶点,其依附于该顶点与顶点a的边即为完成访问的边。
对于不同的存储结构而言,同一个图的广度优先生成树也存在区别:
- 邻接矩阵:有且仅有唯一的广度优先生成树
- 邻接表:有不唯一的广度优先生成树
- 十字链表:有不唯一的广度优先生成树
- 邻接多重表:有不唯一的广度优先生成树
导致这一差异的区别在于对边信息的存储方式的差异:
- 邻接矩阵:边的信息通过矩阵存储,对应的矩阵唯一
- 邻接表、十字链表、邻接多重表:边的信息通过链表进行存储,对应的边链表不唯一
因此,在探讨一张图所对应的广度优先生成树时,一定要弄清其具体的存储结构;
🌟 结语 | 探索不止,下期更精彩
广度优先搜索的内容我们就先介绍到这里,通过本文的学习,我们深入剖析了广度优先遍历(BFS)的核心逻辑:从分层遍历的思想到队列与visited数组的巧妙配合,从连通图的逐层扩散到非连通图的主动扫描,最终揭开广度优先生成树的奥秘。无论是邻接矩阵的唯一性,还是邻接表的灵活性,BFS都为我们展现了图遍历的分层之美。
📢 你的支持,是我持续创作的动力!
点赞❤️:如果这篇博客让你对BFS有了更清晰的理解,不妨点个赞支持一下吧~
收藏⭐️:将本文加入收藏夹,随时回顾算法精髓!
转发🚀:分享给更多朋友,一起踏上图论学习的征程!
关注🔔:蒙奇D索大,第一时间获取深度优先搜索(DFS)的硬核解析!
🔜 下期预告 | 深度优先搜索(DFS):一场“不撞南墙不回头”的探索之旅
-
当BFS的“广撒网”遇到DFS的“一条路走到黑”,会碰撞出怎样的火花?
-
递归与栈的博弈:DFS如何用回溯思想挖掘图的深层结构?
-
生成树 vs 生成森林:DFS的遍历结果又有哪些独特性质?
-
应用场景:拓扑排序、强连通分量、迷宫回溯……DFS的实战价值等你解锁!
🚀 敬请期待! 我们下期见~
💬 互动讨论:你对BFS还有哪些疑问?欢迎评论区留言,我们一起探讨!