【考纲内容】
(1). 图的基本概念
(2). 图的存储及基本操作
邻接矩阵;邻接表;邻接多重表;十字链表
(3). 图的遍历
深度优先搜索;广度优先搜索
(4). 图的基本应用
最小(代价)生成树;最短路径;拓扑排序;关键路径
【知识结构】
【复习提示】
图算法的难度较大,因此主要掌握深度优先搜索和广度优先搜索,其他内容以算法题形式出现的概率不高;
必须掌握图的基本概念和基本性质 、图的存储结构(邻接矩阵 、邻接表 、邻接多重表 、十字链表)及其特性 、存储结构之间的转换 、基于存储结构上的遍历操作 、图的各种应用(拓扑排序 、最小生成树 、最短路径 、关键路径等)
图的相关算法较多 、易混淆,但通常只要求掌握其基本思想和实现步骤(能动手模拟),算法的具体实现则不是重点
1. 图的基本概念
1.1 图的定义 | |
图的定义 | 图 由顶点集 和边集 组成,记为 |
表示图 中顶点的有限非空集 | |
表示图 中顶点之间的关系(边)集合 | |
若 = { } ,则用 || 表示图 中顶点的个数,也称图 的 "阶" | |
= { ( ) | ∈ , ∈ } ,用 || 表示图 中的边的条数 | |
注意 | 线性表可以是空表,树可以是空树,但图不可以是空图; 也就是说,图中必须存在至少一个顶点; 图的顶点集 一定非空,但边集 可以为空,此时图中只有顶点而没有边 |
1.2 一些基本概念及术语 | |
1. 有向图 | |
有向图 | 若 是有向边(也称 " 弧 ")的有限集合时,则图 为 "有向图" ; |
弧、弧头、弧尾 | 弧 ,是顶点的有序对,记为 < ,> ,其中 , 是顶点 ; 称为 " 弧尾 " , 称为 " 弧头 " ; < ,> 称为从顶点 到顶点 的弧 , 也称 邻接到 ,或 邻接自 |
示例 | |
上面的有向图 可表示为 = { 1,2,3 } = {<1,2>,<2,1>,<2,3>} | |
2. 无向图 | |
无向图 | 若 是无向边(简称 "边")的有限集合时,则图 为 "无向图" ; |
边 、顶点 | 边,是顶点的无序对,记为 (,) 或 (,) ,因为 (,) = (,) 其中,, 是顶点 可以说顶点 和顶点 互为邻接点 边 (,) 依附于顶点 和 ,或者说边 (,) 和顶点 , 相关联 |
示例 | |
上面的无向图 可表示为: = {1,2,3,4} = { (1,2),(1,3),(1,4),(2,3),(2,4),(3,4) } | |
3. 简单图 | |
简单图定义 | 一个图 若满足: |
1. 不存在重复边 | |
2. 不存在顶点到自身的边 | |
则称图 为 "简单图" | |
示例 | 图 、 均为简单图 |
4. 多重图 | |
多重图定义 | 一个图 若满足: |
1. 两个结点之间的边,不止一条 | |
2. 允许顶点通过同一条边和自己关联 | |
则称图 为 "多重图" | |
说明 | 多重图的定义和简单图是相对的 |
5. 完全图(也称 简单完全图) | |
无向完全图 | 对于无向图,|| 的取值范围是 0 到 ,有 条边的无向图,称为 "完全图" 在无向完全图中,任意两个顶点之间都存在边(直接相连,不经过其他顶点) |
有向完全图 | 对于有向图,|| 的取值范围是 0 到 ,有 条弧的有向图,称为 "有向完全图" 在有向完全图中,任意两个顶点之间都存在方向相反的两条弧 |
示例 | |
上面两张图, 是无向完全图 , 是有向完全图 | |
6. 子图 | |
子图 | 设有两个图 和 ,若 是 的子集,且 是 的子集,则称 是 的子图 |
生成子图 | 若有满足 的子图 ,则称 为 的生成子图 |
注意 | 并非 和 的任何子集都能构成 的子图,因为这样的子集可能不是图,即 的子集中的某些边关联的顶点可能不在这个 的子集中 |
7. 连通 、连通图和连通分量 | |
连通 | 在无向图中,若从顶点 到顶点 有路径存在,则称 和 是 "连通" 的 |
连通图、非连通图 | 若图 中任意两个顶点是连通的,则称图 为 "连通图",否则称为 "非连通图" |
连通分量 | 无向图中的 "极大连通子图" ,称为 "连通分量" |
极大连通子图 | 极大连通子图是无向图的连通分量 ,极大就是要求该连通子图包含其所有的边 |
极小连通子图 | 极小连通子图则是既要保持图连通,又要使得边的条数最少 |
8. 强连通图、强连通分量 | |
强连通 | 在有向图中,若从顶点 到顶点 ,以及从顶点 到顶点 之间都有路径,则称这两个顶点是 "强连通" 的 |
强连通图 | 若图中任意一对顶点都是强连通的,则称此图为 "强连通图" |
强连通分量 | 有向图中的极大强连通子图,称为有向图的 "强连通分量" |
注意 | 强连通图、强连通分量只是针对有向图而言的; 一般在无向图中讨论连通性,在有向图中考虑强连通性 |
强连通分量示例 | |
9. 生成树、生成森林 | |
生成树 | 连通图的生成树,是包含图中全部顶点的一个极小连通子图 |
若图中顶点个数为 ,则它的生成树含有 条边 | |
对生成树而言,若砍去它的一条边,则会变成非连通图;若加上一条边,则会形成一个回路 | |
生成森林 | 在非连通图中,连通分量的生成树,构成了非连通图的生成森林 |
注意 | 包含无向图中全部顶点的极小连通子图,只有生成树满足条件,因为砍去生成树的任一条边,图将不再连通 |
生成树示例 | |
10. 顶点的度、入度和出度 | |
顶点的度 | 图中每个顶点的度,定义为,以该顶点为一个端点的边(弧)的数目 |
无向图中顶点的度 | 对于无向图,顶点 的度是指,依附于该顶点的边的条数,记为 |
在具有 个顶点 、 条边的无向图中, ; 即无向图的全部顶点的度的和,等于边数的 2 倍;因为每条边和两个顶点关联 | |
有向图中顶点的度 入度 、出度 | 对于有向图,顶点 的度,分为 :入度 、出度 入度,是以顶点 为终点的有向边(弧)的数目,记为 出度,是以顶点 为起点的有向边(弧)的数目,记为 顶点 的度,等于其入度和出度之和,即 |
在具有 个顶点 、 条边的有向图中, ,即有向图的全部顶点的入度之和与全部顶点的出度之和,两者相等;并且等于边数 这是因为每条有向边都有一个起点和一个终点 | |
11. 边的权和网 | |
权值 | 在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该条边的 "权值" |
带权图(网) | 这种边带有权值的图,称为 " 带权图(网)" |
12. 稠密图、稀疏图 | |
稠密图、稀疏图 | 边数很少的图,称为 "稀疏图" ;反之称为 "稠密图" |
稀疏和稠密是模糊的概念,稀疏图和稠密图尝尝是相对而言的 | |
一般当图 满足 时,可以将 视为稀疏图 | |
13. 路径、路径长度和回路 | |
路径 | 顶点 到顶点 之间的一条路径是指顶点序列 ; 当然,关联的边也是路径的构成要素 |
路径长度 | 路径上,边的数目,称为 "路径长度" |
回路(环) | 第一个顶点和最后一个顶点是同一个顶点,那么这条路径称为 "回路(环)" |
若一个图有 个顶点,并且存在大于 条边,则此图一定有环 | |
14. 简单路径、简单回路 | |
简单路径 | 在路径序列中,顶点不重复出现的路径,称为 "简单路径" |
简单回路 | 除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路,称为 "简单回路" |
15. 距离 | |
距离 | 从顶点 出发到顶点 的最短路径若存在,则此路径的长度称为从 到 的 "距离" |
若从 到 根本不存在路径,则记该距离为无穷(∞) | |
16. 有向树 | |
有向树 | 一个顶点的入度为 0 ,其余顶点的入度均为 1 的有向图,称为 "有向树" |
2. 图的存储及基本操作
图的存储必须要完整 、准确地反映顶点集和边集的信息;根据不同图的结构和算法,采用不同的存储方式将对程序的效率产生相当大的影响,因此所选的存储结构应适合于求解的问题
2.1 邻接矩阵(无 、有)
邻接矩阵 | ||
邻接矩阵既可表示无向图,也可表示有向图 | ||
邻接矩阵概念 | 所谓邻接矩阵存储,是指 : 1). 用一个一维数组存储图中顶点的信息 2). 用一个二维数组存储图中边的信息(即各顶点之间的邻接关系) 存储顶点之间邻接关系(边的信息)的二维数组,称为 "邻接矩阵" | |
邻接矩阵描述 无权值 | 结点数为 的图 的邻接矩阵 是 的方阵 | |
将图 的顶点编号为 ; 若 ∈ ,则 ,否则 | ||
= | 1 ,若 或 是 中的边 | |
0 或 ∞ ,若 或 不是 中的边 | ||
1 表示边存在,0 或 ∞ 表示边不存在 | ||
邻接矩阵描述 有权值 | 对于带权图而言 : 若顶点 和 之间有边相连,则邻接矩阵中的对应项,存放该边对应的权值 ; 若顶点 和 不相连,则用 ∞ 来代表这两个顶点之间不存在边 | |
(权值),若 或 是 中的边 | ||
0 或 ∞ ,若 或 不是 中的边 | ||
邻接矩阵示例 | 有向图 、无向图 、网,分别对应的邻接矩阵 | |
存储结构描述 | 图的邻接矩阵存储结构定义 | |
#define MaxVertexNum 100 // 顶点数目的最大值 typedef char VertexType; // 顶点的数据类型 typedef int EdgeType; // 带权图中边的权值的数据类型 typedef struct { VertexType Vex[MaxVextexNum]; // 顶点表 EdgeType Edge[MaxVextexNum][MaxVextexNum]; // 邻接矩阵 ,边表 int vexnum ,arcnum; // 图的当前顶点数 、弧数 }MGraph; | ||
注意 | 1. 在简单应用中,可直接用二维数组作为图的邻接矩阵(顶点信息等均可省略) | |
2. 当邻接矩阵中的元素仅表示相应的边是否存在时,EdgeType 可定义为值为 0 和 1 的枚举类型 | ||
3. 无向图的邻接矩阵是对称矩阵,对于规模特大的邻接矩阵,可采用压缩矩阵 | ||
4. 邻接矩阵表示法的空间复杂度为 ,其中 为图的顶点数 | ||
邻接矩阵 存储特点 | 1. 无向图的邻接矩阵一定是一个对称矩阵(并且唯一);因此,在实际存储邻接矩阵时,只需存储上(或下)三角矩阵的元素即可 | |
2. 对于无向图,邻接矩阵的第 行(或第 列)非零元素(或非 ∞ 元素)的个数正好是第 个顶点的度 | ||
3. 对于有向图,邻接矩阵的第 行(或第 列)非零元素(或非 ∞ 元素)的个数正好是第 个顶点的出度 【 或入度 】 | ||
4. 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连;但是,要确定图中有多少条边,则必须按行 、按列对每个元素进行检测,所花费的时间代价很大 | ||
5. 稠密图适合使用邻接矩阵的存储表示 | ||
6. 设图 的邻接矩阵为 , 的元素 等于由顶点 到顶点 的长度为 的路径的数目 |
2.2 邻接表(无 、有)
邻接表 | |
邻接矩阵缺点 | 当一个图为稀疏图时,使用邻接矩阵会浪费大量的存储空间 |
解决方法 | 图的邻接表法,结合了顺序存储和链式存储方法,及大地减少了这种不必要的浪费 |
邻接表概念 | 第一步,所有的顶点信息都存放在一个一维数组当中,那如何表示顶点之间的关系呢?看下面: 所谓邻接表,是指对图 中的每个顶点 建立一个单链表,第 个单链表中的结点表示依附于顶点 的边 实际上,链表中的结点信息还是图中的顶点信息; 对于无向图,链表中的结点,是与当前顶点相连的边的另一个顶点; 对于有向图,链表中的结点,是与当前顶点相连的弧的弧头顶点(即,数组顶点是弧尾) |
这个单链表就称为顶点 的 " 边表 ",对于有向图则称为 " 出边表 " | |
结点结构 | 边表的头指针和顶点的数据信息,采用顺序存储(称为顶点表),即顶点信息与边表的头指针都存在一维的顶点数组中; 所以在邻接表中存在两种结点 :顶点表结点 、边表结点 |
顶点表结点 | 顶点表结点由顶点域(data)和指向第一条邻接边(其实是人为规定的第一个邻接顶点,无向图中就是第一个相连的结点,有向图中就是第一个相连的弧头顶点)的指针(firstarc)构成 |
边表结点 | 边表(邻接表)结点由邻接点域(adjvex)和指向下一条邻接边的指针域(nextarc)构成 |
无向图邻接表 | |
其实,边表中存放的还是顶点信息,都是与当前顶点表中的顶点直接相连的顶点 | |
有向图邻接表 | |
在有向图中,顶点表中的顶点作为弧尾;比如编号为 2 的顶点,虽然顶点 1 、 4 、 5 都与顶点 2 相连,但只有一条弧是从 2 射出去的,其他两条弧都是射向 2 的,所以顶点 2 的边表中,只存放了顶点 5 的信息 | |
图的邻接表存储结构定义 | |
#define MaxVertexNum 100 typedef struct ArcNode { int adjvex; struct ArcNode *next; // InfoType info; } ArcNode; typedef struct VerNode { // 顶点表结点 VertexType data; // 顶点信息 ArcNode *first; // 指向第一条依附于该顶点的弧的指针 } VerNode ,AdjList[MaxVertexNum]; // 顶点类型 typedef struct { AdjList vertices; // 邻接表(实际是顶点表) int vexnum ,arcnum; // 顶点数和弧数 } ALGraph; // 图类型 | |
图的邻接表存储方法具有以下特点 | |
1 | 若 为无向图,则所需的存储空间为 ;倍数 2 是因为在邻接表中,每条边出现两次 若 为有向图,则所需的存储空间为 ; |
2 | 对于稀疏图,采用邻接表表示,能极大地节省存储空间 |
3 | 在邻接表中,给定一个顶点,能很容易地找出该顶点的所有邻边(其实是邻接顶点),因为只需要读取该顶点的邻接表; 在邻接矩阵中,找到某个顶点的所有邻接顶点,则需要扫描一行,花费的时间是 如果要确定给定的两个顶点之间是否存在边,则在邻接矩阵中可以立刻查到,而在邻接表中则需要在相应结点对应的边表中查找另一个结点,效率极低 |
4 | 在有向图的邻接表表示中,求一个给定顶点的出度,只需要计算其邻接表中的结点个数;但求一个给定顶点的入度,则需要遍历整个邻接表; 因此,也有人采用逆邻接表(顶点表中的顶点作为弧头,边表中的结点都是弧尾)的存储方式来加速求解给定顶点的入度; 逆邻接表与邻接表的存储方式类似 |
5 | 图的邻接表表示并不唯一,因为在每个顶点对应的单链表中,各边结点的链接次序可以是任意的,取决于建立邻接表的算法及边的输入次序 |
2.3 十字链表(有)
十字链表 | |
十字链表概念 | 十字链表,是有向图的一种链式存储结构 |
在十字链表中,有向图的每条弧对应一个结点,有向图的每个顶点对应一个结点 | |
十字链表的结点结构 | |
顶点结点结构 | 顶点结点中有 3 个域 : |
data 域 :存放顶点相关的数据信息,如顶点名称 | |
firstin 域 :指向以该顶点为弧头的第一个弧结点 | |
firstout 域 :指向以该顶点为弧尾的第一个弧结点 | |
弧结点结构 | 弧结点中有 5 个域 : |
info 域 :指向该弧的相关信息 | |
headvex 域 :指示弧头顶点在图中的位置 | |
tailvex 域 :指示弧尾顶点在图中的位置 | |
hlink 域 :指向弧头相同的下一条弧 | |
tlink 域 :指向弧尾相同的下一条弧 | |
如此,弧头相同的弧在同一个链表上,弧尾相同的弧在同一个链表上 | |
图示说明 | 邻接表中: 无向图邻接表: 顶点表,边表存放所有在图中与当前顶点结点直接相连的邻接结点 有向图邻接表: 当前顶点为弧尾,边表中的结点都是弧头 有向图逆邻接表: 当前顶点为弧头,边表中的结点都是弧尾 现在,十字链表专门表示有向图; 对比有向图邻接表和有向图逆邻接表,在十字链表中,顶点结点同时作为弧头和弧尾 顶点结点: firstin 是把当前顶点当做弧头,指向其他弧尾顶点中的第一个顶点(这里的序号可以任意) firstout 是把当前顶点当做弧尾,指向其他弧头顶点中的第一个顶点(这里的序号可以任意) 弧结点: headvex 弧头在顶点表中的下标 tailvex 弧尾在顶点表中的下标 hlink 指向弧头相同的下一个弧尾结点 tlink 指向弧尾相同的下一个弧头结点 |
在十字链表中,既容易找到 为尾的弧,又容易找到 为头的弧,因而容易求得顶点的出度和入度; 图的十字链表表示不是唯一的,但一个十字链表表示能唯一确定一个图 |
2.4 邻接多重表(无)
邻接多重表 | ||
邻接多重表,是 "无向图" 的另一种链式存储结构 | ||
邻接表优点 | 在邻接表中,容易求得顶点和边的各种信息 | |
邻接表缺点 | 但在邻接表中求两个顶点之间是否存在边,从而对边执行删除等操作时,需要分别在两个顶点的边表中遍历,效率较低 | |
边表结点 | 与十字链表类似,在邻接多重表中,每条边用一个结点表示,其结构如下: | |
info | 指向和边相关的各种信息的指针域 | |
mark | 标志域,可用于标记该条边是否被搜索过 | |
ivex | 为该边依附的两个顶点在图中的位置(顶点表中的下标) | |
jvex | ||
ilink | 指向下一条依附于顶点 ivex 的边 | |
jlink | 指向下一条依附于顶点 jvex 的边 | |
顶点表结点 | 每个顶点也用一个结点表示,顶点结点结构如下: | |
data | 存储该顶点的相关信息 | |
firstedge | 指示第一条依附于该顶点的边(依附于该顶点的边可能有多条) | |
注意 | 在邻接多重表中,所有依附于同一个顶点的边,串联在同一个链表中;由于每条边依附于两个顶点,因此每个边结点同时链接在两个链表中 | |
对无向图而言,其邻接多重表和邻接表的差别仅在于: 同一条边在邻接表中用两个结点表示,而在邻接多重表中只需要用一个结点表示 | ||
邻接多重表 图示 | 下图为无向图的邻接多重表表示法;邻接多重表的各种基本操作的实现与邻接表类似 | |
2.5 边集数组
边集数组 | ||
边集数组 概念 | 边集数组是由两个一维数组构成的 | |
顶点数组 | 存储顶点的信息 | |
边数组 | 存储边的信息 | |
每个数据元素由一条边的起点下标(begin),终点下标(end)和权(weight)组成 | ||
边数组结点 结构 | ||
特点 | 显然,边集数组关注的是边的集合,在边集数组中药查找一个顶点的度,需要扫描整个边数组,效率并不高 | |
因此,边集数组更适合对边依次进行处理的操作,而不适合对顶点进行相关操作 | ||
图示 |
2.6 图的基本操作
图的基本操作 | |
图的基本操作是独立于图的存储结构的; 对于不同的存储结构,操作算法的具体实现会有不同的性能; 在设计具体算法的实现时,应考虑采用何种存储方式的算法效率会更高 | |
基本操作接口说明 | |
图的基本操作主要包括(仅抽象地考虑,故忽略掉各变量的类型): | |
Adjacent(G,x,y) | 判断图 是否存在边 或 |
Neighbors(G,x) | 列出图 中与结点 邻接的边 |
InsertVertex(G,x) | 在图 中插入顶点 |
DeleteVertex(G,x) | 在图 中删除顶点 |
AddEdge(G,x,y) | 若无向边 或有向边 不存在,则向图 中添加该边 |
RemoveEdge(G,x,y) | 若无向边 或有向边 存在,则从图 中删除该边 |
FirstNeighbor(G,x) | 求图 中顶点 的第一个邻接点,若有则返回顶点号;若 没有邻接点或图中不存在 ,则返回 -1 |
NextNeighbor(G,x,y) | 假设图 中顶点 是顶点 的一个邻接点,返回除 之外顶点 的下一个邻接点的顶点号,若 是 的最后一个邻接点,则返回 -1 |
Get_edge_value(G,x,y) | 获取图 中边 或 对应的权值为 |
Set_edge_value(G,x,y,v) | 设置图 中边 或 对应的权值为 |
此外,还有图的遍历算法:按照某种方式访问图中的每个顶点且仅访问一次; 图的遍历算法包括 :深度优先遍历 、广度优先遍历 |
3. 图的遍历
3.1 广度优先搜索
广度优先搜索 | |
广度优先搜索 基本思想 | 广度优先搜索(Breadth-First-Search ,BFS)类似于二叉树的层序遍历算法 二叉树的层序遍历,需要借助一个队列 |
首先访问起始顶点 | |
接着由 出发,依次访问 的各个未访问过的邻接顶点 | |
然后依次访问 的所有未被访问过的邻接结点 | |
再从这些访问过的顶点出发,访问它们的所有未被访问过的邻接顶点, 直至图中所有顶点都被访问过为止 | |
若此时图中尚有顶点未被访问过,则另选图中一个未被访问过的顶点重新开始,重复上述过程,直至图中所有顶点都被访问到为止 | |
广度优先搜索 应用 | Dijkstra 单源最短路径算法和 Prim 最小生成树算法也应用了类似的思想 |
广度优先搜索 理解 | 换句话说,广度优先搜索遍历图的过程:是以顶点 为起始点,由近至远依次访问和顶点 有路径相同且路径长度为 1,2,... 的顶点 |
广度优先搜索是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度搜索那样有回退的情况;因此,广度优先搜索不是一个递归的算法 | |
为了实现逐层的访问,广度优先搜索必须借助一个辅助队列,来记忆正在访问的顶点的下一层顶点 | |
广度优先搜索算法伪代码如下: | |
bool visited[MAX_VERTEX_NUM]; void BFSTraverse(Graph G) { for ( i = 0;i < G.vexnum;++i ) // 初始化标记数组,所有顶点均未被访问过 visited[ i ] = FALSE; InitQueue(Q); // 初始化队列 for ( j = 0;j < G.vexnum;++j) { if ( visited[ j ] == FALSE ) // 假如当前顶点未被访问过,则对该顶点执行广度优先搜索 { BFS(G,i); } } } void BFS(Graph G,int v) // 从顶点 v 出发,广度优先遍历图 G { visit(v); // 访问初始顶点 v ,可能只是打印该顶点的值 visited[v] = TRUE; // 在标记数组中,将该顶点 v 标记为已访问过 EnQueue(Q,v); // 将顶点 v 入队 while ( !IsEmpty(Q) ) { DeQueue(Q,v); // 这里的逻辑,相当于二叉树的层序遍历中,获取下一层的所有结点 for ( w = FirstNeighbor(G,v);w >= 0;w = NextNeighbor(G,v,w) ) { if ( visited[ w ] == FALSE ) { visit(w); visited[ w ] = TRUE; EnQueue(Q,w); } } } } | |
辅助数组 的作用 | 辅助数组 visited[ ] 标记顶点是否被访问过,其初始状态为 FALSE |
在图的遍历过程中,一旦某个顶点 被访问,即立即置 visited[i] 为 TRUE ,防止该顶点被多次访问 | |
下面通过实例演示广度优先搜索的过程,给定图 如下 | |
注意 | 结点总是先访问,后入队 |
1 | 假设从 结点开始访问, 先入队 |
2 | 此时队列非空(存在元素 ),取出队头元素 |
3 | 由于结点 、 与 邻接且未被访问过,于是依次访问 、 ,将 、 依次入队 |
4 | 此时队列非空(存在元素 、),取出队头元素 |
5 | 依次访问与 邻接,且未被访问过的顶点 、 ,将 、 入队 此处注意, 与 邻接,但 已经被访问过,故不再重复访问 |
6 | 此时队列非空(存在元素 、 、),取出队头元素 |
7 | 依次访问与 邻接,且未被访问过的顶点 、 ,将 、 入队 |
8 | 此时队列非空(存在元素 、 、 、),取出队头元素 |
9 | 与 邻接且未被访问过的顶点为空,故不做任何操作;继续取出队头元素 |
10 | 依次访问与 邻接,且未被访问过的顶点 ,将 入队 |
11 | 最终取出队头元素 后,队列为空,跳出循环 |
12 | 最终的遍历结果为 |
总结 | 从上面的例子可以看出,图的广度优先搜索过程,与二叉树的层序遍历是完全一致的;这也说明,图的广度优先搜索遍历算法是二叉树的层序遍历算法的扩展 |
1. BFS 算法的性能分析 | |
空间复杂度 | 无论是邻接矩阵还是邻接表,BFS 算法都需要借助一个辅助队列 |
个顶点都需要入队一次 | |
在最坏情况下,空间复杂度为 | |
时间复杂度 | 采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次),故时间复杂度为 ; 在搜索任一顶点的邻接点时,每条边至少访问一次,故时间复杂度为 ; 算法的总的时间复杂度为 |
采用邻接矩阵存储方式时,查找每个顶点的邻接点所需的时间为 ; 算法的总的时间复杂度为 | |
2. BFS 算法求解单源最短路径问题 | |
若图 为非带权图,定义从顶点 到顶点 的 " 最短路径 " 为从 到 的任何路径中最少的边数;若从 到 没有通路,则 = ∞ | |
使用 BFS ,我们可以求解一个满足上述定义的非带权图的单源最短路径问题,这是由广度优先搜索总是按照距离由近到远来遍历图中每个顶点的性质决定的 | |
BFS 算法求解单源最短路径问题的算法如下: | |
void BFS_MIN_Distance(Graph G,int u) { // d[ i ] 表示从 u 到 i 结点的最短路径 for (i = 0;i < G.vexnum;++i) d[i] = ∞; visited[u] = TRUE ; d[u] = 0; while ( !IsEmpty(Q) ) { DeQueue(Q,v); // 这里的逻辑,相当于二叉树的层序遍历中,获取下一层的所有结点 for ( w = FirstNeighbor(G,v);w >= 0;w = NextNeighbor(G,v,w) ) { if ( visited[ w ] == FALSE ) { visit(w); visited[ w ] = TRUE; EnQueue(Q,w); } } } } | |
3. 广度优先生成树 | |
在广度遍历的过程中,我们可以得到一棵遍历树,称为 "广度优先生成树" | |
对于一个给定的图的邻接矩阵表示是唯一的,故其广度优先生成树也是唯一的; 但由于邻接表表示不是唯一的,故其广度优先生成树也是不唯一的 | |
图示 |
3.2 深度优先搜索
深度优先搜索 | |
深度优先搜索 概念 | 与广度优先搜索不同,深度优先搜索(Depth-First-Search,DFS)类似于树的先序遍历;深度优先搜索算法的搜索策略是尽可能 " 深 " 地搜索一个图(存在回退过程) |
基本思想 | 首先访问图中某一起始顶点 |
然后由 出发,访问与 邻接且未被访问的任一顶点 | |
再访问与 邻接且未被访问的任一顶点 | |
重复上述过程 | |
当不能再继续向下访问时,依次退回到最近被访问的顶点,若这个最近的顶点存在邻接顶点未被访问过,则从该顶点开始继续上述搜索过程,直至图中所有顶点均被访问过为止 | |
一般情况下,其递归形式的算法过程如下: | |
// 元素下标对应顶点编号,元素值标记顶点是否被访问过 bool visited[MAX_VERTEX_NUM]; void DFSTraverse(Graph G) { // 标记数组初始化,所有的顶点都未被访问过 for (v = 0;v < G.vexnum;++v) visited[v] = FALSE; // 遍历顶点数组,若顶点未被访问过,对该顶点执行深度优先搜索 for (v = 0;v < G.vexnum;++v) { if (visited[v] == FALSE) { DFS(G,v); } } } // 深度优先搜索 void DFS(Graph G,int v) { // 访问当前顶点,在标记数组中标记该编号的顶点状态为 "已被访问" visit(v); visited[v] = TRUE; // 从当前顶点 v 的第一个邻接顶点开始,一直获取下一个邻接顶点 for ( w = FirstNeighbor(G,v);w >= 0;w = NextNeighbor(G,v,w) ) { // 若当前获取到的顶点未被访问过,则递归执行深度优先搜索 if ( visited[ w ] == FALSE ) { DFS(G,w); } } } | |
深度遍历过程解析 | |
1 | 以上面的无向图为例,深度优先搜索的过程如下: |
2 | 首先访问 ,并置 访问标记 |
3 | 然后访问与 邻接且未被访问的顶点 ,置 访问标记 |
4 | 然后访问与 邻接且未被访问的顶点 ,置 访问标记 |
5 | 此时 已没有未被访问过的邻接点,故返回上一个访问过的顶点 ,访问与其邻接且未被访问过的顶点 ,置 访问标记 ... |
6 | 以此类推,直至图中所有的顶点都被访问过一次 |
遍历结果 | 遍历结果为 |
注意 | 图的邻接矩阵表示是唯一的;但对于邻接表来说,若边的输入次序不同,生成的邻接表也不同 |
对于同一个图,基于邻接矩阵的遍历所得到的 DFS 序列和 BFS 序列是唯一的; 基于邻接表的遍历所得到的 DFS 序列和 BFS 序列是不唯一的 | |
1. DFS 算法的性能分析 | |
空间 、时间 复杂度分析 | DFS 算法是一个递归算法,需借助一个递归工作栈,故其空间复杂度为 |
遍历图的过程,实质上是对每个顶点查找其邻接点的过程,其耗费的时间取决于所用的存储结构; | |
以邻接矩阵表示时,查找每个顶点的邻接点所需的时间为 ,故总的时间复杂度为 | |
以邻接表表示时,查找所有顶点的邻接点所需的时间为 ,访问顶点所需的时间为 ,此时总的时间复杂度为 | |
2. 深度优先的生成树和生成森林 | |
与广度优先搜索一样,深度优先搜索也会产生一棵深度优先生成树; | |
对连通图调用 DFS 才能产生深度优先生成树,否则产生的将是深度优先生成森林 | |
与 BFS 类似,基于邻接表存储的深度优先生成树是不唯一的 | |
3.3 图的遍历与图的连通性
图的遍历算法可以用来判断图的连通性 | |
无向图 | 若无向图是连通的,则从任一结点出发,仅需一次遍历就能够访问图中的所有顶点 |
若无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问 | |
有向图 | 若从初始顶点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点 |
故在 BFSTraverse() 或 DFSTraverse() 中添加了第二个 for 循环,再选取初始点,继续进行遍历,以防止一次无法遍历图的所有顶点 | |
理解 | |
无向图 | 上述两个函数调用 BFS(G,i) 或 DFS(G,i) 的次数等于该图的连通分量数 理解: |
有向图 | 因为一个连通的有向图分为强连通的和非强连通的,其连通子图也分为强连通分量和非强连通分量;非强连通分量的一次调用 BFS(G,i) 或 DFS(G,i) 无法访问到该连通分量的所有顶点 |
4. 图的应用
本节是历年考查的重点;
图的应用主要包括:最小生成(代价)树 、最短路径 、拓扑排序和关键路径;
一般而言,这部分内容直接以算法设计题形式考查的可能性很小,而更多的是结合图的实例来考查算法的具体操作过程,读者必须学会手工模式给定图的各个算法的执行过程;此外,还需掌握对给定模型建立相应的图,去解决问题的方法
4.1 最小生成树
最小生成树 | |
生成树概念 | 一个连通图的生成树,包含图的所有顶点,并且只含尽可能少的边 |
生成树特点 | 若砍去生成树的一条边,则会使生成树变成非连通图 |
若增加生成树的一条边。则会在生成树中形成一条回路 | |
前提 | 对于一个带权连通无向图 ,生成树不同,每棵树的权(树的权等于树中所有边的权值的和)也可能不同 |
最小生成树概念 | 设 S 为图 G 的所有生成树的集合,若 T 为 S 中边的权值之和最小的那棵生成树,则 T 称为 G 的 "最小生成树(Minimum-Spanning-Tree,MST)" |
最小生成树的性质 | |
1 | 最小生成树不是唯一的,即最小生成树的形状不唯一, 中可能存在多个最小生成树 |
2 | 当图 中的各边权值互不相等时, 的最小生成树是唯一的(权值和最小) |
3 | 若无向连通图 中,边数比顶点数少 1,即 本身是一棵树时,则 的最小生成树就是本身 |
4 | 最小生成树的边的权值之和总是唯一的,虽然最小生成树不唯一,但其对应的边的权值之和总是唯一的,而且是最小的 |
5 | 最小生成树的边数等于顶点数减 1 |
构造最小生成树有多种算法,但大多数算法都利用了最小生成树的下列性质: | |
最小生成树 存在定理 | 假设 是一个带权连通无向图, 是顶点集 的一个非空子集; 若 是一条具有最小权值的边,其中 , ,则必定存在一棵包含边 的最小生成树 |
构造生成树的两大算法 | |
基于上述性质的最小生成树算法主要有 Prim 算法和 Kruskal 算法,它们都基于贪心算法的策略;对这两种算法,需要掌握算法的本质含义和基本思想,并能够手工模拟算法的实现步骤 | |
通用最小生成树算法: | |
Generate_MST(G) { T = NULL; while T 未形成一棵生成树; do 找到一条最小代价边 (u , v) 并且加入 T 后不会产生回路 T = T ∪ (u,v); } | |
通用算法每次加入一条边以逐渐形成一棵生成树 |
1. Prim 算法
1. Prim 算法 | |
Prim 算法过程 | Prim(普利姆)算法的执行非常类似于寻找图的最短路径的 Dijkstra 算法 |
Prim 算法构造最小生成树的过程如下图: | |
1 | 初始时,从图中任取一顶点(如顶点 1)加入树 ,此时树中只含有一个顶点 |
2 | 选择一个与当前 中顶点集合距离最近(权值最小)的顶点,并将该顶点和相应的边加入 |
3 | 每次操作(步骤 2)后 中的顶点数和边数都增加 1 |
4 | 以此类推,直至图中所有的顶点都加入 ,得到的 就是最小生成树 此时 中必然有 条边 |
图示 | |
Prim 算法的步骤 | |
前提 | 假设 { , } 是连通图,其最小生成树 { , } , 是最小生成树中边的集合 |
初始化 | 向空树 { , } 中添加图 { , } 的任一顶点 ,使 {} ,
|
循环操作 重复下列操作直至 () | 从图 中选择满足 { (,) | , -- } 且具有最小权值的边 ( ,),加入树 ,置 { } , {(,)} |
Prim 算法 简单实现 | void Prim(G,T) { T = ∅; U = { w }; while ((V - U) != ∅) { } } |