6. 数据结构-图

206 阅读57分钟

考纲要求 💕

知识点(考纲要求)

1、图结构的基本概念

2、图的存储结构。

3、图的遍历和求图的连通分量。

4、生成树和最小生成树;

5、最短路径

考核要求

1、掌握图的定义、特性和相关概念。

2、深刻理解图的最小生成树和最短路径的算法,并能运用这些算法解决综合问题。


▶️ 1. 图的基本概念和术语


图片.png


1.1 ✨ 图的定义

图(Graph):是由顶点集 V边集 E组成,记为 G = ( V , E ) 。其中 V(G)表示图G中顶点的有限非空集; E(G)表示图G中顶点之间的关系(边)的集合。若 V = V 1 , V 2 , . . . , V n,则用 ∣V∣表示图 G中顶点的个数,也称之为图 G的,用 ∣E∣表示图G中边的条数

  • 线性表示可以是空表,树可以是空树,但图不可以是空图
  • 边可以是空的,此时表明两个顶点没有关系

图片.png


1.2 ✨ 图类型

❗ 1.2.1 无向图

无向图( Undirected graphs):如果图中任意两个顶点之间的边都是无向边,则称该图为无向图。由于是没有方向的,因此连接 A和 B之间的边,可以写成(A, B)也可以写成(B, A)

  • 无向边:是指顶点vi到vj之间的边没有方向,采用无序偶对( vi, vj)表示

如下是一个无向图 :

  • G2 = (V2, E2)
  • V2 = {A, B, C, D, E}
  • E2 = {(A, B), (B, D), (B, E), (C, D), (C, E), (D, E)}

图片.png


❗ 1.2.2 有向图

有向图(Directed graphs):如果图中任意两个顶点之间的边都是有向边,则称该图为有向图。由于是有方向的,因此连接 A到 B的边,必须写成<A, B>,而不能写作<B, A>

  • 有向边:是指顶点 vi​到 vj​之间的边有方向,采用有序偶对<vi​,vj​>表示,其中vi​是弧尾(Tail)vj​是弧头(Head)

如下是一个有向图:

  • G1 = (V1, E1)
  • V1 = {A, B, C, D, E}
  • E1 = {<A, B>, <A, C>, <A, D>, <A, E>, <B, A>, <B, C>, <B, E>, <C, D>}

图片.png


❗ 1.2.3 简单图、多重图

简单图、多重图:若不存在顶点到其自身的边,且同一条边不重复出现,则称这样的图为简单图,反之则为多重图

  • 简单图:

图片.png

  • 多重图:

图片.png


❗ 1.2.4 无向完全图

无向完全图:如果任意两个顶点之间都存在边,则称该图为无向完全图。含有n个顶点的无向完全图的边数为S=(n*(n-1))/2

  • 无向完全图

图片.png

  • 顶点4个,边数S=(4x(4-1))/2=6条

❗ 1.2.5 有向完全图

有向完全图:如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。含有n个顶点的有向完全图的边数为:S=n(n−1)

  • 有向完全图

图片.png

  • 顶点4个,边数S=4x(4-1)=12条

❗ 1.2.6 稀疏图、稠密图

  • 稀疏图:边或弧很少
  • 稠密图:边或弧很多

没有绝对的界限,一般来说|E| < |V|log|V|时,可以将G视为稀疏图

  • 稀疏图

图片.png

  • 稠密图

图片.png


❗❗ 1.2.7 子图和生成子图

设有两个图G = (V, E)和G1 = (V1, E1),若V1是V的子集,且E1是 E的子集,则称G1是G的子图

若有满足V(G1) = V(G)的子图G1,则称其为G的生成子图。(也就是顶点相同都包含

  • 原图

图片.png

  • 子图(顶点和边都属于子集)

图片.png

  • 生成子图(顶点相同,边子集)

图片.png


❗❗ 1.2.8 带权图

  • 边的权——在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值

  • 带权图/网——边上带有权值的图称为带权图,也称

  • 带权路径长度——当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度

如下图:

图片.png


1.2.9 特殊的图—树,有向树

  • ——不存在回路,且连通的无向图

图片.png


  • 有向树1个顶点的入度为0、其余顶点的入度均为1有向图,称为有向树。

图片.png


常见考点:

  • n个顶点的树,必有n-1条边。

  • n个顶点的图,若 |边E|>n-1,则一定有回路


1.3 ✨ 顶点度和边相关术语

❗❗ 1.3.1 顶点的度、入度和出度

  • 无向图顶点v的度:是和顶点v相关联的边的数目,记为TD(v)。仔细推敲可知,无向图中边数其实就是各顶点度数和的一半

图片.png

  • 边数:度数和/2=(1+3+3+3+2)/2=6

有向图顶点v的度:TD(v)=ID(v)+OD(v)

  • 入度(箭头指向我的边),记为 ID(v)

  • 出度(我发出的边),记为 OD(v)

  • 弧的条数=入度和=出度和

图片.png

弧的条数= 8= ( 1 + 1 + 2 + 2 + 2 ) = ( 4 + 3 + 1 + 0 + 0 )


❗❗ 1.3.2 顶点与顶点间的关系描述

无向图路径

无向图路径(Path):无向图从顶点vi到vj的路径是一个顶点序列

  • 简单路径:序列中顶点不重复出现的路径

如下是从顶点B到顶点D的四种不同路径

图片.png


有向图路径

有向图路径(Path):对于有向图,其路径也是有向的

  • 简单路径:序列中顶点不重复出现的路径

如下B到D有两种路径,但 A到 B不存在路径

图片.png


回路或环

回路或环(Cycle):第一个顶点到最后一个顶点相同的路径称为回路或环

  • 简单回路(或简单环):除了第一个和最后一个顶点外,其余顶点不重复出现的回路

图片.png


点到点的距离

点到点的距离——从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离。 若从u到v根本不存在路径,则记该距离为无穷(∞)


❗❗ 1.3.3 连通图

❗ 连通图及连通分量定义(无向图)

  • 连通图(Connected Graph):在无向图G中,如果顶点u到顶点v之间有路径,则称 u和v是连通的。如果对于图中任意两个顶点都是连通的,则称该图为连通图

图片.png

  • 连通分量如果无向图本身不是连通图,但是他的某个极大连通子图是连通图,则称该极大连通子图为连通分量

什么叫极大连通子图?

  • 极大连通子图:需要注意这不是在比大小,所谓极大指的是“不能再大的图”。因此下面例子中的三幅子图都是极大连通子图,因为它们不能再大了,如果再填入其他节点,那就不是连通子图了。

右面三个都是连通分量:

图片.png

  • 另外注意,连通分量的提出是以"整个无向图不是连通图"为前提的,因为如果整个无向图就是是连通图,则其无法分解出多个最大连通子图(图中所有的顶点之间都是连通的),也即连通图的连通分量是自己

常考考点

  • 若 G为连通图,则最少有n−1条边
  • 若 G为非连通图,则最多有Cn12 C_{n-1}^2条边

比如:

图片.png

  • G是连通图:至少 n-1=4-1=3条边

  • G是非连通图:最多有C52C_5^2=(5*4)/2 *1 =10


❗ 强连通图及强连通分量(有向图)

  • 强连通图在有向图G中,如果对于每一对vi​和 vj​,从 vi​到 vj​和从vj​到vi​都存在路径,则称该图为强连通图。如果对于图中任意两个顶点都是连通的则称该图为强连通图

图片.png

  • 强连通分量如果有向图本身不是强连通图,但是他的某个最大连通子图是强连通图,则称该最大连通子图为强连通分量

如下图含有两个强连通分量:

图片.png


常考考点

  • 若 G为强连通图,则最少有 n条边(形成回路)

图片.png

  • 如图为强连通图,最少有5条边,也就是形成个回环

图片.png


1.4 ✨ 生成树和生成森林

❗ 1.4.1 生成树

  • 生成树:一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但是却只有足以组成一棵树的n−1条边

比如下面上方的图是一个普通的图,并不是生成树,当它去掉两条构成环的边后(左面的图去掉了AB和FG,右面的去掉了AD和EH),就满足了 n个顶点n−1条边且连通的定义了,所以是生成树

图片.png


❗ 1.4.2 生成森林

  • 生成森林:在非连通图中。连通分量的生成树构成了非连通图的生成森林

如下是非连通图G的三个连通分量

图片.png

然后让这些连通分量生成与之对应的生成树就得到了 G的生成森林

图片.png

  • 如上图,右边的就是G的生成森林

▶️ 2. 图的存储结构(邻接矩阵、邻接表、十字链表和邻接多重表)


图片.png


2.1 ✨ 邻接矩阵—适合存储稠密图

❗ 2.1.1 邻接矩阵定义(无向\有向图\网)

图的邻接矩阵(Adjacency Matrix):采用两个数组表示图。具体来说,用一个一维数组存储图中顶点信息;用一个二维数组存储(邻接矩阵)图中边(无向图)或弧(有向图)的信息

【对于无向图】

  • 若某个元素值为1,代表两个顶点存在边
  • 邻接矩阵是对称的,其中"1"的个数为图中总边数的2倍
  • 矩阵中第i行或第i列元素之和为顶点i的度

图片.png

【对于有向图】

  • 若某个元素为1,代表从vi​到vj​有弧
  • 矩阵中"1"的个数为图的边数
  • 矩阵中第i行的元素之和为顶点i的出度;第j列的元素之和为顶点j的入度
  • 矩阵中第i行元素之和+第i列元素之和为顶点i的度

图片.png

【对于网】

  • 元素如果是数字表示顶点vi​到vj​的权值
  • 元素如果是 ∞ 表示是一个不可能取到的极限值

图片.png


邻接矩阵代码实现

#define MaxVertexNum 100 //顶点数目的最大值
typedef struct{
           char Vex[MaxVertexNum];   //顶点表
           int Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵、边表
           int vexnum,arcnum;   //图的当前顶点数和边数/弧数
    }MGraph; 

修改后有无穷,或者自定义类型:

#define MaxVertexNum 100 //顶点数目的最大值
#define INFINITY  //最大的int值
typedef char VertexType;  //顶点的数据类型
typedef int EdgeType;     //带权图权值的数据类型
typedef struct{
           char Vex[MaxVertexNum];   //顶点表
           EdgeType Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵、边权值
           int vexnum,arcnum;   //图的当前顶点数和边数/弧数
    }MGraph; 

邻接矩阵性能分析(时间复杂度)

  • 邻接矩阵法求顶点的度/出度/入度的时间复杂度为 O(|V|)(因为需要遍历行和列)

  • 空间复杂度:O(V2|V|^2) -- 只和顶点数相关,和实际的边数无关

  • 适合用于存储稠密图,无向图的邻接矩阵是对称矩阵,可以压缩存储(只存储上三角区/下三角区)。


图片.png


❗ 2.1.2 邻接矩阵定义性质

比如现在有个邻接矩阵:

图片.png

  • 设图G的邻接矩阵为A(矩阵元素为0/1),则AnA^n 的元素AnA^n[i][j]等于由顶点i到顶点j的长度为n的路径的数目

类似于相乘:

  • 举例:

  • A2A^2[1][4] = a1,1 a1,4 + a1,2 a2,4 + a1,3 a3,4 + a1,4 a4,4 = 1

图片.png

  • 它的意义是:

看a1,2 a2,4 :

  • a1,2是第1行第2列,指的是从A->B 。
  • a2,4是第2行第4列,指的是从B->D 。

图片.png

  • 也就是说有一条从A->B->D的路径。

  • 然后看公式:- A2A^2[1][4] = a1,1 a1,4 + a1,2 a2,4 + a1,3 a3,4 + a1,4 a4,4 = 1

说明从A(1)到D(4),路径长度为2的,只能找到一条,也就是从A->B->D这条路径


通过一个一个找得到A2A^2的矩阵

图片.png

  • 在刚才的基础上再求A3A^3的矩阵,即A2A^2* A1A^1

图片.png

  • 举例: A3A^3[1][4] = a1,1 a1,4 + a1,2 a2,4 + a1,3 a3,4 + a1,4 a4,4 = 1

  • 表明从顶点A到顶点D的长度为3的路径只有一条,即A->C长度为2的路径一条+ C->D长度为1的路径一条=A->C->D=长度为3


✨ 2.1.3 邻接矩阵要点回顾

邻接矩阵法要点回顾:

• 如何计算指定顶点的度、入度、出度(分无向图、有向图来考虑)?时间复杂度如何?

• 如何找到与顶点相邻的边(入边、出边)?时间复杂度如何?

• 如何存储带权图?

• 空间复杂度——O(|V|2 ),适合存储稠密图

• 无向图的邻接矩阵为对称矩阵,如何压缩存储?

• 设图G的邻接矩阵为A(矩阵元素为0/1),则AnA^n的元素AnA^n[i][j]等于由顶点i到顶点j的长度为n 的路径的数目


2.2 ✨ 邻接表

❗ 2.2.1 邻接表定义

邻接表结构和树的孩子表示法基本类似:孩子表示法


邻接表(Adjacency List):采用数组和链表存储图结构

  • 顶点表:顶点用一个一维数组(因为数组可以很方便的读取结点信息)来存储,数组中的每个元素除了存放顶点外,还要存储一个指针(指向第一个邻接点),用于查找该顶点的边信息
  • 边表:是图中每一个顶点vi​所有邻接点构成的一个线性表,由于邻接点个数不定,因此采用单链表存储,称为顶点vi​的边表,边表中的结点数据域部分存储该邻接点在顶点表中的下标,指针域指向下一个邻接点。

❗ 2.2.2 无向图

【对于无向图】

如下图所示,是一个无向图邻接表结构:

图片.png

  • 顶点表各个结点由data*first两部分组成,作用是存储顶点信息和指向边表的第一个结点

  • 边表结点由adjvexnext两部分组成。adjvex是数据域,存储该顶点的该邻接点在顶点表中的下标;next指向边表下一个结点,也即该顶点的下一个邻接点

  • 比如 A顶点与 B和 C和D互为邻接点,所以在A的边表中,其adjvex分别为1和2和3


❗ 2.2.3 有向图

【对于有向图】

如下图所示,是一个有向图邻接表结构

需要注意的是有向图是存在方向的,所以我们还需要建立一个有向图的逆链接表,便于区分“入”和“出”

  • 有向图邻接表便于区分出边
  • 有向图逆邻接表便于区分入边

图片.png


❗ 2.2.4 网\带权图

【对于网】

如下图所示,是一个网的邻接表结构

对于网来说,由于带有权值,所以在边表中需要增加额外的域来标识

图片.png

  • 跟有向图相比,就增加了一个数据域来表示权值

❗ 2.2.5 代码实现

//"边/弧"
typedef struct ArcNode//边表
{
	int adjvex;//存储该点在顶点表中的下标
	struct ArcNode* nextarc; //指向下一条弧的指针
   
	int info;//对于网,要加上权值信息
}ArcNode;

typedef struct VNode//顶点表结点
{
	char data;//顶点
	ArcNode* firstarc;//指向边表第一个结点
}VNode;

typedef struct Graph //用邻接表存储的图
{
	VNode adjlist[MaxSize];//顶点表数组
	int n,e;//顶点数和边数
}Graph;

❗ 2.2.6 邻接表的性能分析

  • 无向图边结点的数量是2|E|, 整体空间复杂度为 O(|V| + 2|E|)

图片.png

  • 无向图 -- 求度就是找到与它相连的所有邻接表。比如A有123,三条边

  • 有向图边结点的数量是|E|, 整体空间复杂度为 O(|V| + |E|)

图片.png

  • 有向图 -- 求出度就是找到与它相连的所有邻接表。比如A有1,1条边
    -- 出度就是有向图逆邻接表的


  • 如何找到与一个顶点相连的边/弧?

  • 只能遍历的方法找到顶点相连的所有边:比如上面A相连的,通过遍历,C,D指向A,A自身指向B

  • 这样时间复杂度高


2.2.7 邻接表形式不唯一

最后邻接表的形式不一定唯一。

图片.png

但是上一个节中介绍的邻接矩阵是唯一的

图片.png


2.3 ✨ 邻接表与邻接矩阵对比

图片.png

  • 如下

图片.png


2.4 ✨ 十字链表(存储有向图)

❗ 2.4.1 十字链表的定义

对于有向图

  • 邻接表可以解决出度问题,但想要了解入度就必须进行遍历
  • 逆邻接表可以解决入度问题,但想要了解出度就必须进行遍历

十字链表(Orthogonal List):是一种集邻接表和逆邻接表于一体的结构

  • 特别提醒:<A,B>中 A是弧尾,B是弧头

  • 重新定义顶点表结构:其中firstin表示以该顶点作为弧头(指向我)的第一条弧;firstout表示以该顶点作为弧尾(我发出)的第一条弧

图片.png

  • 重新定义边表结点:其中tailvex是弧尾顶点编号;headvex是弧头顶点编号;hlink是弧头相同的下一条弧;tlink是弧尾相同的下一条弧;如果是网,还需要增加权值信息info

图片.png


十字链表关系稍微有点复杂,我们可以用下面的例子理解一遍:A、B、C、D的编号分别为0 1 2 3

图片.png


  • 比如A出发,从A指向弧头编号1,即A->B,后面AA->C

图片.png

  • 其他两条指向A的是入度,D->A,C->A,你看C->A,弧的顶点是2(C),弧的结束点是0(A),继续往下指D->A也是入度,它跟C->A弧头相同,从弧头向下指,最后空了^

图片.png

  • 这样可以特别容易的找到入度和出度的路径,不需要遍历

所以对于十字链表:

  • 只需按照tlink遍历可以找到指定顶点的所有出边
  • 只需按照hlink遍历可以找到指定顶点的所有入边

❗ 2.4.2 十字链表的性能分析

  • 空间复杂度:O(|V|+|E|)

  • 如何找到指定顶点的所有出边?——顺着绿色线路找

  • 如何找到指定顶点的所有入边?——顺着橙色线路找

  • 注意:十字链表只用于存储有向图

图片.png


2.5 ✨ 邻接多重表(存储无向图)

❗ 2.5.1 邻接多重表的定义

  • 重新定义顶点结点:其中firstedge指向与该顶点相连的第一条边

图片.png

  • 重新定义边结点:
    其中ij表示边的两个顶点编号;
    iLink表示依附于顶点i的下一条边;
    jLink表示依附于顶点j的下一条边;
    如果是网,还需要增加权值信息info

图片.png

  • 如下图:

图片.png


  • 这些顶点就是依靠数组结构定义的,0开始,然后顶点结构是数据域+firstedge指向与该顶点相连的第一条边

图片.png

  • 比如这里A-B 就是0,1 ,1指的是下一条边编号

  • 然后,看一下A-D,顶点开始是0(A)开始下一边是3(D),它跟A-B通过依附于顶点i0的下一条边连接。然后这个iLink为空,表示没有下一个跟i0相连的。

图片.png


  • 查询B结点的,B指向上面01,表示B和A相连,然后绿色的jLink表示j1 即B的下一条边,对应是21,即C和B相连。还有绿色,指向41,表示E和B相连,最后为空表示没有了。

图片.png


❗ 2.5.2 邻接多重表的性能分析

  • 其他结构查询删除时候,邻接表,数据重复二份,邻接矩阵需要遍历。而这种结构可以快速的查询,且不重复。

举例:

  • 删除A-B

图片.png

  • 其实只用删除A-B 01结点

图片.png

  • 修改二个指针

图片.png

图片.png


分析得出:

  • 空间复杂度:O(|V|+|E|)

  • 删除边、删除节点等操作很方便

  • 注意:邻接多重表只适 用于存储无向图


2.6 ✨ 四种图结构对比

图片.png

使用十字链表和邻接多重表


▶️ 3. 图的基本操作


图片.png


3.1 Adjacent(G,x,y):判断边存在

  • Adjacent(G,x,y):判断图G是否存在边或(x, y)

  • 无向图

图片.png

  • 邻接矩阵:直接查找对应是否为1,比如查找B-D存在不,B-D对应的邻接矩阵是0,说明没有

  • 邻接表:查找BD,直接看B那行的,D是3,没有对应3,所以没有

时间复杂度(效率):

  • 邻接矩阵:直接查询,所以O(1)
  • 邻接表:需要遍历B那一行,可能最多有n-1条,所以最坏时间复杂度为O(n)即O(|V|)

图片.png


  • 有向图

一样,如下图:

图片.png


3.2 Neighbors(G,x):结点x邻接边

  • Neighbors(G,x):列出图G中与结点x邻接的边

  • 无向图

图片.png

  • 邻接矩阵通过查询那行或者那列,邻接表示直接查询那行

  • 邻接矩阵时间复杂度为O(|V|),邻接表的时间复杂度为O(1)~O(|V|);


  • 有向图

图片.png

  • 邻接矩阵查找入边可以遍历行,查找出边可以遍历列。邻接表查找出边直接查找那行,查找入边要遍历所有顶点。

  • 注意:这里注意一下,不一定邻接矩阵一定比邻接表效率高,因为万一顶点少是个稀疏图,这样的话邻接表查询的更快。

3.3 InsertVertex(G,x):插入顶点x

  • InsertVertex(G,x):在图G中插入顶点x

image.png

  • 插入x:邻接矩阵就就是多加一行一列都是0。邻接表就是加一行,没有后续结点

  • 关于时间复杂度,只需要在顶点表插入顶点信息。

  • 有向图类似


3.4 DeleteVertex(G,x):删除顶点x

  • DeleteVertex(G,x):从图G中删除顶点x。

image.png

  • 比如删除顶点C

邻接矩阵如果是删除C,删除那行那列,还要移动元素来补空,不好。可以直接用0表示空,顶点可以用一个bool类型标志表示为空。

邻接表如果删除C,也是把那行赋空,然后顶点用一个bool类型表示为空。

image.png

  • 时间复杂度,邻接矩阵需要遍历一行一列,O(|V|)。邻接表需要删除后面的边表时候需要遍历它们顶点,所以可能范围是O(1)~O(|E|);

  • 有向图

image.png

  • 对于邻接矩阵是一样的

  • 对于邻接表,删除出边只需要直接删除后面的连接边就可以,但是出边需要遍历其他顶点


3.5 AddEdge(G,x,y):增加边

  • AddEdge(G,x,y):若无向边(x, y)或有向边不存在,则向图G中添加该边

  • 无向图

image.png

  • 邻接矩阵只需要找到对应的边,使值变1。

  • 邻接表需要需要在两个顶点后面增加结点,可以使用链表头插法和尾插法,头插法O(1),但是尾插法需要遍历前面的,O(|V|)

image.png


  • 有向图类似

3.6 FirstNeighbor(G,x):第一个邻接点

  • FirstNeighbor(G,x):求图G中顶点x的第一个邻接点,若有则返回顶点号。若x没有邻接点 或图中不存在x,则返回-1。

image.png

  • 邻接矩阵:需要查找那行的1,遇到的第一个1就是邻接边。上面那个就是邻接点。
  • 邻接表:直接找那行后面的第一个结点。

  • 有向图

image.png

  • 邻接矩阵:找出边直接行查找,找入边直接列查找。
  • 邻接表:找出边直接后面第一个结点,找出边需要遍历顶点表

3.7 NextNeighbor(G,x,y):下一个邻接点

  • NextNeighbor(G,x,y):假设图G中顶点y是顶点x的一个邻接点,返回除y之外顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1。

image.png

  • 邻接矩阵:需要查找那行的1,遇到的第二个1就是邻接边。上面那个就是邻接点。
  • 邻接表:直接找那行后面的第二个结点。

  • 有向图跟first类似
  • 邻接矩阵:找出边直接行查找,找入边直接列查找。
  • 邻接表:找出边直接后面第二个结点,找出边需要遍历顶点表

3.8 Get获取权值/Set设置权值

  • Get_edge_value(G,x,y):获取图G中边(x, y)或对应的权值。
  • Set_edge_value(G,x,y,v):设置图G中边(x, y)或对应的权值为v。

首先还是要先找到边

Adjacent(G,x,y):判断图G是否存在边或(x, y)。雷同,核心在于找到边

找到后权值变就行

image.png


下面看看图的遍历


▶️ 4. 图的遍历


image.png


4.1 ✨ 图的广度优先遍历(BFS)

❗ 4.1.1 图的广度优先遍历的定义

首先我们回忆下树的广度优先遍历:

image.png

树的⼴度优先遍历(层序遍历):

  • ①若树⾮空,则根节点⼊队
  • ②若队列⾮空,队头元素出队并访问,同时将该元素的孩⼦依次⼊队
  • ③重复②直到队列为空

图的广度优先遍历与树的广度优先遍历挺类似的


比如:如下

image.png

  • 从结点2出发,可以找到下一层的1和6这二个结点

image.png

  • 再找到它们1和6邻近的其他结点,也就是5,3,7这几个结点

image.png

  • 再从这几个结点往下找,也就是4,8结点

image.png


但是这里有个缺点,运用查询操作时候,无向图有可能搜到以及访问过的顶点。比如下面找1,要从6搜2到1,但是之前搜索过2了。

image.png


下面进行优化:添加一个标记,访问过的跳过。


❗ 4.1.2 广度代码实现

定义

⼴度优先遍历(Breadth-First-Search, BFS)要点:

    1. 找到与⼀个顶点相邻的所有顶点
    1. 标记哪些顶点被访问过
    1. 需要⼀个辅助队列

使用下面二个操作实现第一个问题查询:

  • •FirstNeighbor(G,x):求图G中顶点x的第⼀个邻接点,若有则返回顶点号。 若x没有邻接点或图中不存在x,则返回-1。
  • •NextNeighbor(G,x,y):假设图G中顶点y是顶点x的⼀个邻接点,返回除y之外 顶点x的下⼀个邻接点的顶点号,若y是x的最后⼀个邻接点,则返回-1。

使用bool visited[MAX_VERTEX_NUM];//访问标记数组解决第二个问题


开始图像如下:

image.png

image.png


代码

代码如下:

bool visited[MAX_VERTEX_NUM]; //访问标记数组

//广度优先遍历
void BFS(Graph G,int v)   //从顶点V出发,广度优先遍历图G
{
	visit(v);//访问初始顶点V
	visited[v]=TRUE;//对V做已访问标记
	EnQueue(Q,v); //顶点V入队列Q
        
        while(!isEmpty(Q)){ //不空还有未遍历到的节点 
        DeQueue(Q,v); //出队v 
        for(w = FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) //找到所有符合条件的邻接节点 
        if(!visited[w]){ //w为V的尚未访问的邻接顶点;
        visit(w);//访问顶点w
        visited[w] = true;//对顶点w做已访问标记
        EnQueue(Q,w); //顶点w入队列
		}
	}
}

流程(以2为例)

  • 举例:假设v是2,从2出发

先通过visit(v)访问顶点2,然后对应的visited[v]数组设为已访问标记。再让2号结点入队。

image.png

image.png

  • 往后是一个while循环,如果队列不空的话,就让队头的元素出对。然后通过first函数找到与2相邻的所有的顶点w,也就是1和6。

image.png

  • 然后检测它们是未被访问过的顶点,所以访问顶点w,然后对w做已访问标记,入队。

image.png

image.png

  • 然后继续while循环中,1号结点出队,找w即1号结点所有邻接点,即2和5。这时候进行if检测,2号结点已经访问过了,不执行。5号结点执行,访问,做标记,入队。

image.png

image.png

  • 继续往下,6号顶点,出队,相邻顶点即2,3,7。就3,7没访问过,执行,访问,做标记,入队。

image.png

  • 继续往下,5号顶点,出队,相邻顶点就1,访问过了不执行

image.png

  • 继续往下,3号顶点,出队,相邻顶点就6,4,7,就4没访问过,执行,访问,做标记,入队。

image.png

  • 继续往下,7号顶点,出队,相邻顶点就6,4,8,就8没访问过,执行,访问,做标记,入队。

image.png

  • 继续往下,4号顶点,出队,相邻顶点就3,7,8,访问过了不执行.

  • 继续往下,8号顶点,出队,相邻顶点就4,7,访问过了不执行.结束

image.png

image.png

  • 最后得到的遍历序列:21653748

书面求广度遍历序列

image.png


以1为例:

从1开始,开始1,相邻25,125。然后2开始遍历,只能访问到6,5没有。即1256。6相邻3,7。即125637。然后3相邻4,7。7已经有了,加个4就行,即1256374。然后7遍历,相邻4,8。即12563748。

image.png


❗ 4.1.3 遍历序列的可变性

  • 同⼀个图的邻接矩阵表示⽅式唯⼀,因此⼴度优先遍历序列唯⼀
  • 同⼀个图邻接表表示⽅式不唯⼀,因此⼴度优先遍历序列不唯⼀

  • 举例:如图2开始遍历,2的邻接点

image.png

  • 邻接矩阵:2的邻接点就是216,顺序不会变
  • 邻接表:2的邻接点可以是216,或者换下顺序是261.不唯一

❗ 4.1.4 存在的问题

- 如果是⾮连通图,则⽆法遍历完所有结点

image.png


❗❗ 4.1.5 最终优化算法

那么可以添加一个判断它visited数组是否false,是的话调用遍历算法,这样就不会少了

bool visited[MAX_VERTEX_NUM]; //访问标记数组

//处理非连通图的情况 
void BFSTraverse(Graph G){ //对图G进行广度优先遍历
	for(int i=0;i<G.vexnum;++i)
		visited[i] = false; //初始化都为false
                InitQueue(Q);       //初始化辅助队列Q
	for(int i=0;i<G.vexnum;++i){   //从0号顶点开始遍历
		if(!visited[i])        //对每个连通分量调用一次BFS 不是true
			BFS(G,i);      //vi未被访问过,从i开始BFS
	}
        
        //广度优先遍历
void BFS(Graph G,int v)   //从顶点V出发,广度优先遍历图G
{
	visit(v);//访问初始顶点V
	visited[v]=TRUE;//对V做已访问标记
	EnQueue(Q,v); //顶点V入队列Q
        
        while(!isEmpty(Q)){ //不空还有未遍历到的节点 
        DeQueue(Q,v); //出队v 
        for(w = FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) //找到所有符合条件的邻接节点 
        if(!visited[w]){ //w为V的尚未访问的邻接顶点;
        visit(w);//访问顶点w
        visited[w] = true;//对顶点w做已访问标记
        EnQueue(Q,w); //顶点w入队列
		}
	}
}

结论:对于⽆向图,调⽤BFS函数的次数=连通分量

image.png

  • 第一次调用:从1开始,BFS(G,1),从1遍历到8。然后都为true。
  • 第二次调用:从9开始,BFS(G,9), 从9遍历到11。
  • 极大连通子量=连通分量=最小连通图,也就是有二个。

❗❗ 4.1.6 效率分析

  • 空间复杂度最坏情况,辅助队列⼤⼩为 O(|V|)

image.png

  • 这种情况下,都需要申请放入。

  • 时间复杂度

image.png


❗❗ 4.1.7 广度优先生成树

  • 开始

image.png

  • 从2开始

image.png

  • 找相邻,把2作为根节点

image.png

  • 相邻结点1,6,看邻接表和邻接矩阵,1是先入队的,依次入队,写入树

image.png

  • 通过1,6分别找邻接点

image.png

  • 通过5,3,7分别找邻接点

image.png

  • 最后,4,8查询邻接点,都是遍历过的。

这里需要注意下:⼴度优先⽣成树由⼴度优先遍历过程确定。

由于邻接表的表示⽅式不唯⼀,因此基于邻接表的⼴度优先⽣成树也不唯⼀。

image.png


如果是非连通图,那么就有多个树了


❗❗ 4.1.8 广度优先生成森林

对⾮连通图的⼴度优先遍历,可得到⼴度优先⽣成森林

image.png


4.2 ✨ 图的深度优先遍历(DFS)


❗ 4.2.1 图的深度优先遍历的定义

咱们先来看下树的深度优先遍历:

也就是一条分支走到头,根左右,也就是先序遍历。

image.png


但是图的顶点有可能是访问过的,所以跟广度优先遍历一样,也是定义个访问标记数组。


❗ 4.2.2 深度代码实现

代码

bool visited[MAX_VERTEX_NUM]; //访问标记数组

//深度优先遍历
void DFS(Graph G,int v)   //从顶点V出发,深度优先遍历图G
{
	visit(v);//访问初始顶点V
	visited[v]=TRUE;//对V做已访问标记
        for(w = FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) //找到所有符合条件的邻接节点 
        if(!visited[w]){ //w为V的尚未访问的邻接顶点;
         DFS(G,w); }
}

流程

  • 以顶点2为例子,访问顶点v,设置访问标记为true

image.png

  • 查找相邻顶点w,下一个是1,如果是未被访问过的,调用DFS,访问w,设置访问标记true

image.png

  • 然后1的相邻点是2,5。2已经访问过了,所以用5调用DFS

image.png

  • 执行完后,5没有相邻结点了。返回到1号顶点,1号顶点此时是1,w=5,也执行完了。返回到2号顶点,之前已经执行过2,w=1。但是2号顶点的另一个邻接点6没有访问。重新从2,w=6开始执行。

image.png

  • 然后6的相邻点是2,3,7。2已经访问过了,所以用3调用DFS

image.png

  • 然后3的相邻点是4,7,6。6已经访问过了,所以用4调用DFS

image.png

  • 然后4的相邻点是3,7,8。3已经访问过了,所以用7调用DFS

image.png

  • 然后7的相邻点是3,4,8。3,4已经访问过了,所以用8调用DFS

image.png

  • 此时往下没有了,所有的顶点都调用完了(visited数组都是true),开始返回

  • 与8号顶点相邻的都是ture,直接返回,7顶点也是,其他的类似

image.png

  • 这样得到的从2出发的深度优先遍历序列:2,1,5,6,3,4,7,8

如果是非连通图,无法遍历完全部结点。跟广度一样,所以修改代码

image.png


❗❗ 4.2.3 最终优化算法

bool visited[MAX_VERTEX_NUM]; //访问标记数组

//处理非连通图的情况 
void DFSTraverse(Graph G){ //对图G进行深度优先遍历
	for(int i=0;i<G.vexnum;++i)
		visited[i] = false; //初始化都为false
	for(int i=0;i<G.vexnum;++i){   //从0号顶点开始遍历
		if(!visited[i])        //对每个连通分量调用一次DFS 不是true
			DFS(G,i);      //vi未被访问过,从i开始BFS
	}
        

//深度优先遍历
void DFS(Graph G,int v)   //从顶点V出发,深度优先遍历图G
{
	visit(v);//访问初始顶点V
	visited[v]=TRUE;//对V做已访问标记
        for(w = FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) //找到所有符合条件的邻接节点 
        if(!visited[w]){ //w为V的尚未访问的邻接顶点;
         DFS(G,w); }
}

image.png


❗❗ 4.2.4 效率分析

  • 空间复杂度

image.png

  • 最坏空间复杂度:上面上图,从1开始遍历整个顶点边,递归深度为O(|V|);
  • 最好空间复杂度:上面下图,这种情况下,比如1-2,然后2后面没有相邻点,就回去1,所以只需要递归深度栈是二层,也就是O(1)

  • 时间复杂度

image.png


❗❗ 4.2.5 书面求深度优先序列

image.png

  • 比如从3出发,3相邻4,34。然后4相邻7,347。然后7相邻6,3476。然后6相邻2,34762。然后2相邻1,347621。然后1相邻5,3476215。然后5相邻的1已经访问过了,所以要返回上一层,也就是查找1相邻的顶点,1也没有。继续上层267,到7的时候,才发现一个8没有被访问过,最后序列为34762158。到此为止顶点都有了,遍历完了。

这里也需要注意下 邻接表的形式不唯一,所以深度优先遍历序列也不唯一

image.png


❗❗ 4.2.6 深度优先生成树

  • ⼀个图的邻接矩阵表示⽅式唯⼀,因此深度优先遍历序列唯⼀,深度优先⽣成树也唯⼀
  • 同⼀个图邻接表表示⽅式不唯⼀,因此深度优先遍历序列不唯⼀,深度优先⽣成树也不唯⼀

image.png

从2开始的遍历:2->1->5 这是一次到底

image.png

然后返回到2,遍历2->6->3->4->7->8

image.png


❗❗ 4.2.7 深度优先生成森林

image.png

image.png


4.3 ✨ 图的遍历与图的连通性

  • ⽆向图进⾏BFS/DFS遍历 调⽤BFS/DFS函数的次数=连通分量数
  • 对于连通图,只需调⽤1次 BFS/DFS

image.png

  • 极大连通子量=连通分量=最小连通图,如图也就是有三个。

  • 它们进行遍历时候,调用函数的次数=连通分量数目


  • 有向图进⾏BFS/DFS遍历 调⽤BFS/DFS函数的次数要具体问题具体分析
  • 起始顶点到其他各顶点都有路径,则只需调⽤1次 BFS/DFS 函数

如图:

image.png

从7号顶点调用,都有邻接点,一次结束

而从2号顶点开始

image.png

只能找到一条路径,可能需要调用多次。


但是对强连通图来说,从任一结点出发只需要调用一次BFS/DFS

image.png


参考:(王道408考研数据结构)第六章图-第三节:图的遍历(DFS和BFS)


▶️ 5. 最小生成树(带权连通无向图)


image.png


5.1✨ 最小生成树定义

首先我们回忆下生成树是什么?


5.1.1 生成树

连通图生成树是包含图中全部顶点的一个极小连通子图。 若图中顶点数为n,则它的生成树含有 n-1 条边.

对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路

image.png

除此之外还有广度优先生成树和深度优先生成树.


今天我们来学最小生成树


❗5.1.2 最小生成树定义

最小生成树(Minimum Cost Spanning Tree):一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但是却只有足以组成一棵树的n−1条边。对于网来说,各个边具有权值,因此我们把这个极小连通子图形成的最小代价树称之为最小生成树

最小生成树在道路规划等场景具有很大的意义

  • 如下图:

image.png

  • 可以有如下方案,看最小代价的就是最小生成树

image.png


因此最小生成树的定义可以如下:

对于⼀个带权连通⽆向图G = (V, E),⽣成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有⽣成树的集合,若T为R中边的权值之和最⼩的⽣成树,则T称为G的最⼩⽣成树(Minimum-Spanning-Tree, MST)

image.png

  • 最⼩⽣成树可能有多个,但边的权值之和总是唯⼀且最⼩的
  • 最⼩⽣成树的边数 = 顶点数- 1。砍掉⼀条则不连通,增加⼀条边则会出现回路
  • 如果⼀个连通图本身就是⼀棵树,则其最⼩⽣成树就是它本身
  • 只有连通图才有⽣成树,⾮连通图只有⽣成森林

下面看看求最小生成树的二种算法:


5.2✨ 普利姆(Prim)算法

❗5.2.1 普利姆(Prim)算法定义

普利姆算法:从某一个顶点开始构建生成树,每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止

以下图为例

image.png

在开始的时候,“P城”这个顶点可以视为一个生成树。

现在还有“农场”、“学校”、“矿场”、“渔村”和“电站”这5个顶点,而只有选择“学校”这个顶点才能使得生成树代价最小,所以并入“学校”顶点

image.png

生成树此时变大了,在此基础上如果纳入“矿场”或“渔村”当中的任何一个结点都可以使生成树代价最小,这里选择并入“矿场”(也可以选择“渔村”)

image.png

  • 继续,顶点相连的最小的是2,矿场和渔村

image.png

  • 继续,顶点相连的最小的是5,这里不能连4,因为会变成连通图.

image.png

  • 继续,这时候最小的是3,农村和电站连线

image.png

  • 不能连线了,非连通图,结束

image.png


❗❗5.2.2 普利姆(Prim)注意点

  • 1:最小生成树并不唯一,比如上面的例子中,如果我们选择“渔村”而不是矿场,那么新的生成树就是下面这样,但是代价仍然是15

image.png


  • 2:生成树起点选择不同也会导致最终生成树的形态不同,比如这次选择“农场”作为起点

image.png


❗5.2.3 Prim算法实现思路

image.png

  • 比如,如果假如V1,代价为6. 而V2连接最低代价为5. 这里没有办法把v4,v5连接,所以是∞

这是初始的状态.

  • 第1轮:循环遍历所有个结点,找 到lowCost最低的,且还没加⼊树的顶点

显然是v3,代价为1.

image.png

  • 这时候代价可能会改变,找小的代价把lowcost数组替换

image.png

  • 替换后,继续循环遍历找到lowCost最低的,且还没加⼊树 的顶点

image.png

  • 后面的类似

image.png

image.png

image.png

image.png

image.png

  • 最后全部加入,结束

  • 总结

  • 从V0开始,总共需要 n-1 轮处理 (总时间复杂度 O(n2n^2),即O(V2|V|^2))

  • 每⼀轮处理:循环遍历所有个结点,找到lowCost最低的,且还没加⼊树的顶点。

  • 再次循环遍历,更新还没加⼊的各个顶点的lowCost值(每⼀轮时间复 杂度O(2n))

image.png


❗5.2.4 Prim算法代码

采用邻接矩阵结构

typedef struct
{
	int no;//顶点编号
	char info;//顶点其他信息,非必要可不写
}VertexType

typedef struct
{
	int edges[maxSize][maxSize];
	int n,e;//n为顶点,e为边
	VertexType vex[maxSize];
}MGraph;

Prim算法要使用以下两个数组

  • lowCost[]数组:存放当前生成树到剩余各顶点最短边的权值
  • vSet[]数组:如果某个元素被设置为1,表示该顶点已经被纳入生成树中了

首先初始化两个数组,然后进入大的for循环进行遍历,大的for循环中还有两个小for循环,第一个小for循环通过查询lowcost数组从未被并入的顶点找出一个代价最小的顶点然后并入生成树;找到最小代价后进入第二个for循环,用于更新lowcost数组,因为新的顶点的并入增大了生成树,那么这个新的生成树到此时未被并入的顶点的权值也一定发生了变化

也就是上面算法实现思路:

代码:

void Prim(Mgraph* G,int v0,int* sum)
{
	int lowCost[];
	int vSet[];//初始化数组
	int v,k,min;//三个循环变量

	for(int i=0;i<G.n;i++)//对lowcost数组进行赋值
	//开始时起始节点本身作为生成树
	{
		lowCost[i]=g.edges[v0][i];//权值保存在邻接矩阵中
		vSet[i]=0;//初始情况下,所有顶点还没有被纳入树中	
	}
	
	v=v0;//v用于跟踪此次找出的最小权值顶点编号
	vSet[v]=1;//起点顶点肯定要设置为1
	sum=0;//用于记录总代价
	for(int i=0,i<G.n-1;i++)//循环从所有未被纳入生成树的顶点找出一个最小代价
	{
		min=INF;//INF表示一个非常小的数,每次min被重置,最终保存最小代价
		for(int j=0;j<G.n;j++)//找最小代价的循环
		{
			if(vest[j]==0 && lowCost[j]<min)//如果此顶点没有被访问并且
			//在lowCost数组查询找到了一个更小代价,
			{
				min=lowCost[j];//保证min保存的是此次的最小代价
				k=j;//j由于要不断循环,因此使用k确保指向那个最小代价对应的顶点
			}
			vSet[k]=1;//该顶点被纳入
			v=k;//使用保存k,因为下一步还要更新lowcost数组
			*sum+=min;//总代价更新
		}
		//一个结点的纳入导致生成树的长大,lowCost数组也就需要
		//更新,因为它必须始终存储当前生成树到未被并入结点的权值
		for(int m=0;m<G.n;m++)
		{
			//如果当前已经并入的顶点到其余未被并入顶点的权值小于原来的值,那么更新lowcost
			if(vest[m]==0 && G.edges[v][m]<lowCost[m])
			{
				lowCost[j]=G.edges[v][m];
			}
		}
	}
}

代码视频演示:

最小生成树之Prim算法代码流程演示


普利姆(Prim)算法动画演示

75a44030802a48e0a64ef900fbbde8dd.gif

参考

(135条消息) (王道408考研数据结构)第六章图-第四节1:最小生成树之普利姆算法(思想、代码、演示、答题规范)


5.3 ✨ 克鲁斯卡尔(Kruskal)算法

❗5.3.1 克鲁斯卡尔(Kruskal)算法定义

克鲁斯卡尔算法:每次选择一条权值最小的边,使这条边的两头连通,原本已经连通的就不选,直到所有结点都连通

  • 以下图为例:

image.png

首先,由于  “学校”和“P城”两顶点之间的权值最小并且还没有连通,因此选择他们连通

image.png

接着是“矿场”和“渔村”最小

image.png

接着是“农场”和“电站”

image.png

下来是权值更小的边4,有两条,都没有连通,这里我们挑选“P城”和“矿场”

image.png

不选4的原因是会连通----接下来对于上面的边5,它连接的是“学校”和“矿场”,但是“学校”和“矿场”早已连通,因此不选。所以选择下面的边5,连通“农场”和“P城”

image.png

至此所有结点连通,最小代价为15

image.png


❗5.3.2 Kruskal算法实现思路

image.png

  • 第1轮:检查第1条边的两个顶点是否连通(是否属于同⼀个集合) 不连通连起来.

image.png

  • 第2轮:检查第2条边的两个顶点是否连通(是否属于同⼀个集合)

image.png

  • 第3轮:检查第3条边的两个顶点是否 连通(是否属于同⼀个集合)

image.png

  • 第4轮:检查第4条边的两个顶点是否 连通(是否属于同⼀个集合)

image.png

  • 第5轮:检查第5条边的两个顶点是否 连通(是否属于同⼀个集合)

会生成连通图,跳过.

image.png

  • 继续往下直到遍历完

image.png


  • 总结

  • 共执⾏ e 轮,每轮判断两个顶点是否属于同⼀集合,需要 O(log2elog_2e)

  • 总时间复杂度 O(elog2eelog_2e)

image.png


❗5.3.3 Kruskal算法代码

使用并查集结构

  • Road结构体保存的是一条边,此结构体将该边的两个顶点和它们之间的长度信息封装在一起;

  • Kruskal算法核心思想就是不断取出较小权值的边然后连通,所以在初始情况下,对于所有边需要按照权值大小进行排序;

  • v[maxSize]是一个并查集数组,通过赋值以及配合getroot函数可以找到某个节点的祖先结点,当祖先结点不一致时这两个结点没有连通也即没有路径。其中v[a]=b表示a的父节点是b;

  • 在算法中,遍历所有边,每次遍历时取出这个边对应的两个顶点然后通过getroot函数查询他们的祖先结点是否相同,如果不相同那么就可以进行连通,连接操作代码就是v[a]=b

代码:

#include <stdio.h>
#define maxSize 100

typedef struct Road//这里定义的结构体保存的是边的信息
{
	int a, b;//这条边连接的两个顶点
	int w;//这条边的权值信息
}Road;//Kruskal算法经常用来求城市最短铺设距离这样的问题,所以这个名字这样写

Road road[maxSize];//把所有的边全部放入road数组中
void sort(Road arr[],int n)//克鲁斯卡尔算法逐步选取从小到大的权值的边,所以在算法开始前对所有边根据权值排序,这里使用直接插入排序
{
	Road temp;
	int i, j;
	for (i = 1; i < n; i++)
	{ 
		temp = arr[i];
		j = i - 1;
		while (j >= 0 && temp.w < arr[j].w)
		{
			arr[j + 1] = arr[j];
			--j;
		}
		arr[j + 1] = temp;
	}
}

int v[maxSize];//定义并查集数组
int getRoot(int p)//用于获取某个结点的根节点是谁
{
	while (p != v[p])//只有根节点才会有V[0]=0,V[1]=1这样,如果不是这样,那么就继续向上走,直到走到根节点
		p = v[p];
	return p;
}


//Kruskal算法
void Kruskal(Road road[], int n, int e, int* sum)//road数组存储的是各个相邻顶点边的信息,n和e分别是顶点数和边数
{
	int a, b;//a和b保存一条边所在的两个顶点
	sum = 0;

	for (int i = 0; i < n; ++i)//对并查集数组赋值,开始任何一个节点都可以看做是一个树,也就是自己就是根节点,所以v[0]=0,v[1]=1,v[2]=2.......
		v[i] = i;
	
	sort(road, e);//调用排序函数,所有边按照权值大小从小到大排序

	for (int i = 0; i < e; ++i)
	{
		a = getRoot(road[i].a);//用a和b取这条边的两个顶点,传给并查集,查找他们的根节点
		b = getRoot(road[i].b);
		if (a != b);//如果a!=b那就表示他们的根节点不一样,这样就能归并到树内,不然如果根节点一样,并入后就成了环了
		{
			v[a] = b;//如果不相等,那么把a挂在b下,也就是在并查集中,a的父节点是b
			sum = sum + road[i].w;//既然并入了,权值就相应增加即可
		}
	}
}

代码视频演示:

Kruskal算法代码流程演示


Kruskal算法动画演示

386b4fcce4d44b3fae8f42ad579f88fe.gif


参考

(136条消息) (王道408考研数据结构)第六章图-第四节2:最小生成树之克鲁斯卡尔算法(思想、代码、演示、答题规范)


5.4 ✨Prim 算法 v.s. Kruskal 算法

image.png


▶️ 6. 最短路径问题


image.png


  • 最短路径问题:

image.png

需要找到最短路径

image.png


6.1 ✨ BFS算法(无权图)

❗6.1.1 BFS算法基本思想

  • 注意:无权图可以视为权值均为1的带权图

BFS算法:从某一个顶点开始找到它的邻接点,对应最短路径为1,接着再通过邻接点找到邻接点的邻接点,于是最短路径增加1,以此得到单源最短路径

c621984d4eea4e57932b31482bd0b4ce.gif


❗6.1.2 BFS算法代码

算法的基本框架如下:

对于单源最短路径问题,BFS算法中会涉及如下三个数组

  • d[]数组:其中d[i]表示从顶点u到顶点i的最短路径数值
  • path[]数组:前驱结点。假如起点为a到e的最短路径为a->c->f->e,那么就有path[e]=f,path[f]=c,path[c]=a,path[a]=-1
  • visited[]数组:visited[i]=true表示顶点i已被访问

代码如下:

void BFS(Graph G,int u)
{

	for(int i=0;i<G.vexnum;++i)//初始化数组
	{
		d[i]=MAX;//一个很大的数,开始肯定没有路径
		path[i]=-1;//记录的路径
	}

	d[u]=0;
	visited[u]=True;
	EnQueue(Q,u);
	while(!isEmpty(Q))//BFS算法主体
	{
		DeQueue(Q,u);
		for(w=firstNeighbor(G,u);w>=0;w=nextNeighbor(G,u,w))
		{
			if(!visited[w])//w为u的尚未被访问的邻接点
			{
				d[w]=d[u]+1;//路径长度+1
				path[w]=u;//从u到w
				visited[w]=True; //设已访问标记
				EnQueue(Q,w); //顶点w入队
			}
		}
	}
}

模拟执行过程

  • 初始:d[]开始都为∞,path都是-1,visited数组都是false

image.png

  • 从顶点2开始,d[2]设置为0,表示起始顶点。标记访问过,放入队列。while查找相邻点,找到后,比如现在可以找到1和6号,需要路径长度+1,1和6的d[]设置为1,path设置为2.

image.png

  • 然后while循环,弹出对头元素2,然后对1号顶点进行查询相邻点,只有5.

image.png

  • 继续下一个6号顶点,未访问过的相邻点是3,7.这里注意下d[]是2-6-3,即长度为2,也就是当前顶点到起始顶点的长度。

image.png

  • 下一个是5,这时候没有相邻点,直接出队。下一个3。

image.png

  • 继续7

image.png

  • 后面都没有未访问过的相邻点,直接出队列

image.png


  • 最后生成树

image.png

这样构造出来的生成树高度应该最小的


参考


6.2 ✨ Dijkstra算法(带/无权图)

首先我们看下BFS算法的局限性:

带权路径⻓度——当图是带权图时,⼀条路径上 所有边的权值之和,称为该路径的带权路径⻓度

  • BFS算法求单源最短路径只适⽤于⽆权图,或所有边的权值都相同的图

image.png


❗6.2.1 Dijkstra算法基本思想

迪杰斯特拉算法:该算法和普利姆算法有些地方比较相似。具体来说,该算法会在剩余顶点中选出一个顶点,此顶点有这样一个特点:通往这个顶点的路径在通往其他所有顶点的路径中长度是最短的

迪杰斯特拉(dijkstra)算法不便于用语言完整描述,可以跟随下面的例子体会一下这个过程。其中会涉及三个数组

  • dist[]数组:存储了当前起点到其余顶点最短路径的长度
  • path[]数组:路径前驱
  • final[]数组:是否已经找到最短路径

如图:

image.png


❗6.2.2 Dijkstra算法流程

  • 初始:从V0开始

image.png

  • 第1轮:循环遍历所有结点,找到还没确定最短 路径,且dist 最⼩的顶点Vi,令final[i]=ture。

即这里是v0到v4最短。

image.png

  • 会影响其他最短路径变化,检查所有邻接⾃Vi的顶点,若其final值为false,则更新 dist 和 path

image.png

  • 第2轮:循环遍历所有结点,找到还没确定最短 路径,且dist 最⼩的顶点Vi,令final[i]=ture

image.png

  • 更新最短路径信息

image.png

  • 类似上面继续

image.png

image.png

image.png

  • 最后全访问过,不更新

image.png

  • 得到结果

V0到V2 的最短(带权)路径⻓度为:dist[2] = 9

通过 path[ ] 可知,V0到V2 的最短(带权)路径:V0->V4->V1->V2


❗6.2.3 Dijkstra算法效率分析

每轮都需要循环遍历一次,并检查所有邻接⾃Vi 的顶点,也就是O(2N)

总共需要n-1轮处理,所以需要n-1*2n

  • 时间复杂度:O(n2n^2)或者O(v2|v|^2)

image.png


❗6.2.4 Dijkstra不适用负权值

image.png


❗6.2.5 Dijkstra代码实现

总的来说,迪杰斯特拉算法和普利姆算法其实还是挺相似的。普利姆算法第一个小for循环是在找权值最小的边然后纳入生成树,迪杰斯特拉算法第一个小for循环
也是在剩余顶点中选出一个顶点,通往这个顶点的路径在通往所有顶点的路径中长度是最短的。普利姆算法第二个小for循环是在更新lowcost数组,是指如果剩余的顶点距离树的距离小于之前的就更新,而迪杰斯特拉算法第二个小for循环用于判断刚并入路径中的顶点是否会导致出现通往其余顶点更短的路径(他在判断时,是以新加入的那个顶点为起点,然后再逐个比较所有未被并入的顶点)

代码:

//带权图
typedef struct
{
	int no;
	char info;
}VertexType;
typedef struct
{
	float edges[maxSize][maxSize];
	int n, e;
	VertexType vex[maxSize];
}MGraph;

//Dijkstra算法
void Dijkstra(MGraph g, int v, int dist[], int path[])
/*
	dist[]数组存储了当前起点到其余顶点最短路径的长度
	path[]数组存储了起点到其余顶点的最短路径(通过查询该数组,可获得路径信息)
	final[]数组中标记为1表示被选入最短路径
*/
{
        //初始化
	int final[maxSize];//初始化final数组
	int min, i, h, u;
	for (i = 0; i < g.n; ++i)
	{
		dist[i] = g.edges[v][i];//初始化dist数组,根据edges数组的信息,录入根结点到其余结点距离信息
		final[i] = 0;//开始时所有结点均为被并入,故设为0
		if (g.edges[v][i] < INF)
		/*
		  举例 如果path[0][3]不是无穷大(置于无穷大会大于这个很大的数)
		   那么path[3]=0,表示3这个节点之前是0,0-3是一个最短路径
		*/
			path[i] = v;
		else
			path[i] = -1;//如果path[3]=-1,表示之前没有元素
	}
	final[v] = 1;//根节点被并入
	path[v] = -1;//根节点前没有结点
///迪杰斯特拉算法核心//
	for (i = 0; i < g.n-1; ++i)
	{
		min = INF;
		for (int j = 0; j < g.n; ++j)
		/*
			此for循环每次从剩余结点中选出一个一个结点,通过往这个
			顶点的路径在通往所有剩余顶点的路径中是最短的
		*/
		{
			if (final[j] == 0 && dist[j] < min)
			{
				u = j;
				min = dist[j];
			}
		}
		final[u] = 1;
		for (int j = 0; j < g.n; ++j)
		/*
			此for循环以刚并入的结点作为中间点,对所有通往剩余顶点的路径进行监测
		*/
		{
			if (final[j] == 0 && dist[u] + g.edges[u][j] < dist[j])
			{
				/*
				如果顶点u的加入会出现通往顶点j的更短的路径,那么就更新信息
				*/
				dist[j] = dist[u] + g.edges[u][j];
				path[j] = u;
			}
		}
	}
}

❗6.2.6 dijkstra代码视频演示


❗6.2.7 dijkstra算法动画演示

132652e6520943e4bcef319421bc2268.gif


6.2.8 参考


6.3 ✨ 弗洛伊德(Floyd)算法(带/无权图)

❗6.3.1 Floyd算法基本思想

Floyd算法:求出每⼀对顶点之间的最短路径

对于n个顶点的图G,求任意⼀对顶点 Vi —> Vj 之间的最短路径可分为如下⼏个阶段:

  • 初始:不允许在其他顶点中转,最短路径是?
  • 0:若允许在 V0 中转,最短路径是?
  • 1:若允许在 V0、V1 中转,最短路径是?
  • 2:若允许在 V0、V1、V2 中转,最短路径是?
  • n-1:若允许在 V0、V1、V2 …… Vn-1 中转,最短路径是?

弗洛伊德(Floyd)算法不便于用语言完整描述,可以跟随下面的例子体会一下这个过程。其中会涉及两个数组

  • A[][]:用来记录当前已经求得的任意两个顶点最短路径的长度
  • path[]:来记录当前两顶点间最短路径上要经过的中间顶点

这两个数组需要进行状态转移(上述文章有介绍),使用上标进行区分


❗6.3.2 Floyd算法流程

  • 初始时为A^-1和path^-1,表示不允许以其它顶点作为中转顶点时的情况

比如v0->v2最短路径长度为13.因为现在不能有其他顶点作为中转顶点。

初始时候中转点path值都是-1

image.png

  • 0:若允许在V0中转,最短路径是?--求 A0A^0path0path^0

image.png

比如,原来的v2到v1的路径,无穷到不了。然后现在考虑能够以v0作为中转,那么最短路径v2->v0->v1 ,长度为11. 比无穷小。可以用。

所以A0A^0[2][1]=11 ,$path^0[2][1]=0; v2到v1最短路径长度为11,中间点是v0

image.png

  • 1:若允许在 V0、V1中转,最短路径是?---求 A1A^1path1path^1

image.png

image.png

  • 2:若允许在 V0、V1、V2 中转,最短路径是?--求 A1A^1path1path^1

image.png

image.png

  • 类似这样,往下递推直到顶点用完

从A^(-1) 和 path^(-1) 开始,经过 n 轮递推,得到 A^(n-1) 和 path^(n-1)

image.png


❗6.3.3 Floyd算法核心代码

//弗洛伊德核心
	for (k = 0; k < n; k++)//开始考虑以k为中间点
{
	for (i = 0; i < n; i++)//遍历整个矩阵,i为行数,j为列数
{
	for (j = 0; j < n; ++j)
{
	if (A[i][j] > A[i][k] + A[k][j])//以k为中间点的路径更短
     {
	A[i][j] = A[i][k] + A[k][j]; //更新最短路径
	path[i][j] = k;//更新中转点
     }
}}}}
  • 时间复杂度,O(V3|V|^3)
  • 空间复杂度,O(V2|V|^2)

image.png


还记得上面di算法解决不了负权值图嘛。这个可以用


6.3.4 Floyd算法可以用于负权图

6.3.5 Floyd不能解决负权值回路

  • Floyd 算法不能解决带有“负权回路”的图(有负权值的边组成回路),这种图有可能没有最短路径

image.png


❗6.3.6 Floyd算法整体代码

#include <stdio.h>
#define maxSize 100

//带权图的结构类型
typedef struct//先定义顶点类型
{
	int no;//顶点编号
	char info;//顶点其他信息(可以不写)
}VertexType;
typedef struct//再定义图
{
	float edges[maxSize][maxSize];//定义一个邻接矩阵,有权图必须是float类型
	int n, e;//顶点数和边数
	VertexType vex[maxSize];//存放结点信息
}MGraph;

void Floyd(MGraph* g, int path[][maxSize], int A[][maxSize])
/*
	A数组:用来记录当前已经求得的任意两个顶点最短路径的长度
	Path数组:用来记录当前两顶点间最短路径上要经过的中间顶点
*/
{
	int i, j, k;
	for (i = 0; i < g->n; ++i)
	{
		for (j = 0; j < g->n; ++j)
		{
			A[i][j] = g->edges[i][j];
			path[i][j] = -1;
		}
	}

///弗洛伊德核心

	for (k = 0; k < g->n; ++k)//开始以k为中间点
	{
		for (i = 0; i < g->n; ++i)//以k为中间点,监测i到j距离是否大于i到k和k到j的距离
		{
			for (j = 0; j < g->n; ++j)
			{
				if (A[i][j] > A[i][k] + A[k][j])
				{
					A[i][j] = A[i][k] + A[k][j];//若大于说明这是最短路径
					path[i][j] = k;//同时由i找j时,应该先去找k
				}
			}
		}
	}
}

//依据弗洛伊德算法生成的数组,找任意两个顶点之间的最短路径
void printPath(int u, int v, int path[][maxSize])
{
	if (path[u][v] == -1)
		printf("输出即可");
	else
	{
		int mid = path[u][v];
		printPath(u, mid, path);
		printPath(mid, v, path);
	}
}

❗6.3.7 Floyd算法代码演示视频

Floyd算法代码流程演示


✨ 三种算法对比

image.png

  • 注:也可⽤ Dijkstra 算法求所有顶点间的最短路径,重复 |V| 次即可,总的时间复杂度也是O(V3|V|^3)

▶️ 7. 有向无环图

7.1 ✨ 有向无环图的定义

  • 有向⽆环图:若⼀个有向图不存在环,则称为有向⽆环图,简称DAG图(Directed Acyclic Graph)

image.png


7.2 ✨ DAG描述表达式简化

image.png

注意看,如下

image.png

  • 因为它们结构一样,简化如下:

image.png

  • 这样可以节省很多节点空间

  • 继续

image.png

image.png

  • 继续

image.png

image.png

简化完成,找完结构相同的


7.2.1 例题

image.png

简化过程如下:

image.png

image.png

image.png

image.png


  • 这里有个规律,简化后顶点中不可能出现重复的操作数。

image.png

比如上面二图,最后简化后顶点不重复,比如第一个是x,y。第二个是abcde。


7.2.2 解题规范

  • Step 1:把各个操作数不重复地排成⼀排

image.png

  • Step 2:标出各个运算符的⽣效顺序(先 后顺序有点出⼊⽆所谓)

image.png

  • Step 3:按顺序加⼊运算符,注意“分层

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png


  • Step 4:从底向上逐层检查同层的运算符 是否可以合体

从底往上,+cd 三个结构相同,简化

image.png

简化:

image.png

然后从底往上,*e结构相同

image.png

简化:

image.png


▶️ 8. 拓扑排序(AOV网)


image.png


8.1 ✨ AOV网(DAG图-顶点活动)

AOV网(Activity On Vertex network):⽤DAG图(有向⽆环图表示⼀个⼯程。顶点表示活动,有向边<vi,vj>表示活动Vi必须先于活动Vj进行。

  • 举个例子:

image.png

比如你洗番茄之前必须先买菜。


8.2 ✨ 拓扑排序

❗8.2.1 拓扑序列定义

拓扑序列:设G=(V,E)是一个具有n个顶点的有向图,V中的顶点序列v1​、v2​、…、vn​满足从顶点vi​到vj​有一条路径,且在顶点序列中顶点vi​必须在顶点vj​之前,那么我们称这样的顶点序列为一个拓扑序列

  • 拓扑排序并不唯一,每个AOV⽹都有⼀个或多个拓扑排序序列..

❗8.2.2 拓扑排序定义

拓扑排序:所谓拓扑排序,其实就是对一个有向图构造拓扑序列的过程。构造时有如下两种情况

  • 如果此网的顶点全部被输出,说明他是一个不存在环的AOV网
  • 如果输出顶点数目不全,哪怕少了一个,就说明该网存在环,并不是AOV网

下面继续拓扑排序:

  • 初始

image.png

  • 找到起点开始

image.png

image.png

  • 找到下一个顶点

image.png

  • 继续

顺序不唯一,这里打鸡蛋和洗番茄选一个

image.png

  • 继续往下

image.png

image.png

  • 最后:

image.png


上面是一个流程,总结出来排序的规律如下:


❗8.2.3 拓扑排序的实现

拓扑排序实现 :简单来说就是每次删除入度为0的顶点然后输出。具体来说:

  • 从有向无环图中找到一个没有前驱的顶点输出
  • 删除以这个顶点为起点的边(也即它发出的边要删除,这是为了找到下一个没有前驱的顶点)
  • 重复以上步骤,直至最后一个顶点被输出,如果还有顶点未输出,则说明有环

image.png


8.2.3.1 有回路的图进行拓扑排序

image.png

image.png

到这步的时候,有回路,不能进行下去,不能进行拓扑排序


❗8.2.4 拓扑排序代码实现

image.png

邻接表定义如下

#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode{  //边表结点
    int adjvex; //该弧所指向的顶点的位置
    struct ArcNode * nextarc;//指向下一个邻接点的指针
    //InfoType info;         //网的边权值
}ArcNode;

typedef struct VNode{  //顶点表结点
    VertexType data;//顶点的数据域
    ArcNode * firstarc;//指向第一条依附该顶点的弧的指针
}VNode,AdjList[MaxVertexNum];//存储各链表头结点的数组

typedef struct {     //邻接表
    AdjList vertices;//图中顶点及各邻接点数组
    int vexnum,arcnum;//记录图中顶点数和边或弧数
}ALGraph;

这算结构定义完成了,下面完成操作。


image.png

bool TopologicalSort(ALGraph G){
    int indegree[G.vexnum];//创建记录各顶点入度的数组
    int  print[G.vexnum];//记录拓扑序列的数组
    memset(print,-1,sizeof(print));//初始化都为-1
    
    //建立栈结构,程序中使用的是链表
    stack *S;
    initStack(&S);  //存储入度为0的顶点
    //查找度为0的顶点,作为起始点
    for (int i=0; i<G.vexnum; i++) {
        if (!indegree[i]) {  //如果顶点入度数组为空
            push(S, i);      //将所有入度为0的顶点入栈
        }
    }
    int count=0;     //计数,记录当前已经输出的顶点数
    //当栈为空,说明排序完成
    while (!StackEmpty(*S)) { //栈不空,则存在入度为0的顶点
        int index;
        //弹栈,并记录栈中保存的顶点所在邻接表数组中的位置
        pop(S,&index);  //栈顶元素出栈
        print[count++]=i; //输出顶点i
        //将所有i指向的顶点的入度减1,并且将入度为0的顶点压入栈s
        for (ArcNode *p=G.vertices[index].firstarc; p; p=p->nextarc) {
            VertexType k=p->adjvex;
            if (!(--indegree[k])) {
                //顶点入度为0,入栈
                push(S, k);
            }
        }
    }
    //如果count值小于顶点数量,表明该有向图有环
    if (count<G.vexnum) {
        printf("该图有回路");
        return false;
    }
    else
        return true;//拓扑排序成功
}

image.png


参考

参考的整体代码:

#include <stdio.h>
#include <stdlib.h>
#define  MAX_VERTEX_NUM 20//最大顶点个数
#define  VertexType int//顶点数据的类型
typedef enum{false,true} bool;
typedef struct ArcNode{
    int adjvex;//邻接点在数组中的位置下标
    struct ArcNode * nextarc;//指向下一个邻接点的指针
}ArcNode;

typedef struct VNode{
    VertexType data;//顶点的数据域
    ArcNode * firstarc;//指向邻接点的指针
}VNode,AdjList[MAX_VERTEX_NUM];//存储各链表头结点的数组

typedef struct {
    AdjList vertices;//图中顶点及各邻接点数组
    int vexnum,arcnum;//记录图中顶点数和边或弧数
}ALGraph;
//找到顶点对应在邻接表数组中的位置下标
int LocateVex(ALGraph G,VertexType u){
    for (int i=0; i<G.vexnum; i++) {
        if (G.vertices[i].data==u) {
            return i;
        }
    }
    return -1;
}
//创建AOV网,构建邻接表
void CreateAOV(ALGraph **G){
    *G=(ALGraph*)malloc(sizeof(ALGraph));
   
    scanf("%d,%d",&((*G)->vexnum),&((*G)->arcnum));
    for (int i=0; i<(*G)->vexnum; i++) {
        scanf("%d",&((*G)->vertices[i].data));
        (*G)->vertices[i].firstarc=NULL;
    }
    VertexType initial,end;
    for (int i=0; i<(*G)->arcnum; i++) {
        scanf("%d,%d",&initial,&end);
       
        ArcNode *p=(ArcNode*)malloc(sizeof(ArcNode));
        p->adjvex=LocateVex(*(*G), end);
        p->nextarc=NULL;
       
        int locate=LocateVex(*(*G), initial);
        p->nextarc=(*G)->vertices[locate].firstarc;
        (*G)->vertices[locate].firstarc=p;
    }
}
//结构体定义栈结构
typedef struct stack{
    VertexType data;
    struct stack * next;
}stack;
//初始化栈结构
void initStack(stack* *S){
    (*S)=(stack*)malloc(sizeof(stack));
    (*S)->next=NULL;
}
//判断链表是否为空
bool StackEmpty(stack S){
    if (S.next==NULL) {
        return true;
    }
    return false;
}
//进栈,以头插法将新结点插入到链表中
void push(stack *S,VertexType u){
    stack *p=(stack*)malloc(sizeof(stack));
    p->data=u;
    p->next=NULL;
    p->next=S->next;
    S->next=p;
}
//弹栈函数,删除链表首元结点的同时,释放该空间,并将该结点中的数据域通过地址传值给变量i;
void pop(stack *S,VertexType *i){
    stack *p=S->next;
    *i=p->data;
    S->next=S->next->next;
    free(p);
}
//统计各顶点的入度
void FindInDegree(ALGraph G,int indegree[]){
    //初始化数组,默认初始值全部为0
    for (int i=0; i<G.vexnum; i++) {
        indegree[i]=0;
    }
    //遍历邻接表,根据各链表中结点的数据域存储的各顶点位置下标,在indegree数组相应位置+1
    for (int i=0; i<G.vexnum; i++) {
        ArcNode *p=G.vertices[i].firstarc;
        while (p) {
            indegree[p->adjvex]++;
            p=p->nextarc;
        }
    }
}
void TopologicalSort(ALGraph G){
    int indegree[G.vexnum];//创建记录各顶点入度的数组
    FindInDegree(G,indegree);//统计各顶点的入度
    //建立栈结构,程序中使用的是链表
    stack *S;
    initStack(&S);
    //查找度为0的顶点,作为起始点
    for (int i=0; i<G.vexnum; i++) {
        if (!indegree[i]) {
            push(S, i);
        }
    }
    int count=0;
    //当栈为空,说明排序完成
    while (!StackEmpty(*S)) {
        int index;
        //弹栈,并记录栈中保存的顶点所在邻接表数组中的位置
        pop(S,&index);
        printf("%d",G.vertices[index].data);
        ++count;
        //依次查找跟该顶点相链接的顶点,如果初始入度为1,当删除前一个顶点后,该顶点入度为0
        for (ArcNode *p=G.vertices[index].firstarc; p; p=p->nextarc) {
            VertexType k=p->adjvex;
            if (!(--indegree[k])) {
                //顶点入度为0,入栈
                push(S, k);
            }
        }
    }
    //如果count值小于顶点数量,表明该有向图有环
    if (count<G.vexnum) {
        printf("该图有回路");
        return;
    }
}

int main(){
    ALGraph *G;
    CreateAOV(&G);//创建AOV网
    TopologicalSort(*G);//进行拓扑排序
    return  0;
}

❗8.2.5 拓扑排序代码流程

  • 将顶点入度为0的全部入栈,计数

image.png

  • 弹出栈顶,输出顶点到记录拓扑序列

image.png

  • 将当前i,2指向的顶点的入度减去1,如果减去后为0则入栈s

image.png

逻辑上相当于删除了2->3 ,2->4

  • 继续弹出栈顶,输出顶点到记录拓扑序列

image.png

  • 将当前i指向的顶点度减去1,判0入栈

image.png

  • 判断此时减去1的入度为0的顶点1压入栈

image.png

  • 然后顶点输出,即count=1,然后count++指向下一个

image.png

  • 此时对1指向的顶点进行减度,判空,为0则入栈,更新拓扑序列

image.png

  • 最后,4也这样,进行后结束

image.png

  • 然后这里注意下,此时count=5,大于顶点数vexnum时候说明拓扑排序正确。可以用这个来判断成功不

image.png


❗8.2.6 拓扑排序代码效率

  • 时间复杂度O(|V|+|E|)
  • 若采⽤邻接矩阵,则需O(|V|^2)

image.png


8.3 ✨ 逆拓扑排序


image.png


❗8.3.1 逆拓扑排序定义

对⼀个AOV⽹,如果采⽤下列步骤进⾏排序,则称之为逆拓扑排序

  • 从AOV⽹中选择⼀个没有后继(出度为0)的顶点并输出。
  • 从⽹中删除该顶点和所有以它为终点的有向边。
  • 重复①和②直到当前的AOV⽹为空

跟拓扑结构不同的是这里选择的是出度为0的顶点开始

  • 举例说明

动画.gif


❗8.3.2 逆拓扑排序实现(DFS算法)

与拓扑排序不同的是这里是找到所有指向i的顶点,然后删除它们连接的边。

image.png


下面使用DFS算法实现:

bool visited[MAX_VERTEX_NUM]; //访问标记数组

//处理非连通图的情况 
void DFSTraverse(Graph G){ //对图G进行深度优先遍历
	for(int i=0;i<G.vexnum;++i)
		visited[i] = false; //初始化都为false
	for(int i=0;i<G.vexnum;++i){   //从0号顶点开始遍历
		if(!visited[i])        //对每个连通分量调用一次DFS 不是true
			DFS(G,i);      //vi未被访问过,从i开始BFS
	}
        


//深度优先遍历
void DFS(Graph G,int v)   //从顶点V出发,深度优先遍历图G
{
	visit(v);//访问初始顶点V
	visited[v]=TRUE;//对V做已访问标记
        for(w = FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) //找到所有符合条件的邻接节点 
        if(!visited[w]){ //w为V的尚未访问的邻接顶点;
         DFS(G,w); }
         
         print(v);//输出顶点
}

这里只用加一个print(v);


下面进行流程模拟:

  • 从0号顶点开始,

image.png

  • 下一个顶点w=1

image.png

  • 继续往下类似

image.png

image.png

  • 这时候,4顶点没有相邻的顶点,出度为0,所以输出顶点4

image.png

  • for循环结束,返回上一层的dfs算法w

image.png

  • 继续

image.png

image.png

image.png

image.png

image.png

  • 然后重新选择开始点,dfstraver函数中找到没有被访问的顶点进行dfs

image.png


▶️ 9. 关键路径(最早发生时间、最迟发生时间)


image.png


9.1 ✨ AOE网(带权有向图)

  • AOE网:在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如 完成活动所需的时间),称之为⽤边表示活动的⽹络,简称AOE⽹ (Activity On Edge NetWork)

  • 其中没有入边的顶点称之为源点没有出边的点称之为终点


举例:炒鸡蛋番茄

如下图就是一个AOE网,其中v1​是源点,表示一个工程的开始,v4​是终点,表示整个工程的结束。顶点表示事件,边表示活动,其权值表示持续时间

image.png


AOE网具有的性质:

  • ① 只有在某顶点所代表的事件发⽣后,从该顶点出发的各有向边所代表的活动才能开始;
  • ② 只有在进⼊某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发⽣。 另外,有些活动是可以并⾏进⾏的

9.2 ✨ AOV网和AOE网的对比

AOV网AOE网
顶点表示活动顶点表示事件
边无权值,代表活动的先后顺序边有权值,代表活动的持续时间

9.3 ✨ 关键路径

❗9.3.1 关键路径定义

关键路径:从源点到汇点的有向路径可能有多条,所有路径中,具有最⼤路径⻓度的路径称为 关键路径,⽽把关键路径上的活动称为关键活动

关键路径直接决定了整个工程的时间长短,只有缩短关键路径上的关键活动时间才能减少整个工程时间

举例:

image.png

最短路径长度=a1+a3+a4=6


❗9.3.2 事件的最早发生时间

事件vk的最早发⽣时间ve(k)——决定了所有从vk开始的活动能够开⼯的最早时间

image.png

image.png

比如事件可以炒了的最早发生时间是4。 从开始到炒有二条路径,最少要4分钟才能二条路都通

又比如结束吃事件的最早发生时间是前面炒菜的最早发生时间4+2=6


❗9.3.3 活动的最早开始时间

活动ai的最早开始时间e(i)——指该活动弧的起点所表⽰的事件的最早发⽣时间

image.png

比如可以切了活动的最早开始时间就是开始这个起点的时间0

切番茄这个活动的最早开始时间是起点可以切了这个起点的最早发生时间1

炒菜最早是可以炒了这个起点的最早发生时间4


❗9.3.4 事件的最迟发⽣时间

事件vk的最迟发⽣时间vl(k)——它是指在不推迟整个⼯程完成的前提下,该事件最迟必须发⽣的时间

image.png

比如现在结束事件必须要最迟时间6分钟,炒菜需要2分钟,那么可以炒了事件的最迟发生时间是4


❗9.3.5 活动的最迟开始时间

活动ai的最迟开始时间l(i)——它是指该活动弧的终点所表示事件的最迟发⽣时间与该活动所需时间之差。

image.png

比如活动打鸡蛋:它的终点的最迟发生时间是4,然后减去打鸡蛋需要的时间2,最迟开始时间=2;

也就是说,我们从0开始,不需要一开始就打鸡蛋,打鸡蛋可以拖个2分钟。也就是说必须在2分钟时候进行打鸡蛋,之前可以干别的。


❗9.3.6 活动的时间余量

把前面的活动的最早开始时间和最迟开始时间对比。它们的差值表示你最多可以拖多久时间。

image.png

活动ai的时间余量d(i)=l(i)-e(i),表⽰在不增加完成整个⼯程所需总时间的情况下,活动ai可以拖延的时间 。

  • 若⼀个活动的时间余量为零,则说明该活动必须要如期完成,d(i)=0即l(i) = e(i)的活动ai是关键活动关键活动组成的路径就是关键路径.

必须如期完成的活动是关键活动,由它组成的路径是关键路径


9.4 ✨ 求关键路径

  • ① 求所有事件的最早发⽣时间 ve( )
  • ② 求所有事件的最迟发⽣时间 vl( )
  • ③ 求所有活动的最早发⽣时间 e( )
  • ④ 求所有活动的最迟发⽣时间 l( )
  • ⑤ 求所有活动的时间余量 d( )

然后

  • 时间余量d(i)=0的活动就是关键活动, 由 关键活动可得关键路径

image.png


9.4.1 求所有事件的最早发⽣时间

image.png


举例:

  • 初始,求出拓扑序列:v1,v3,v2,v5,v4,v6 不唯一

image.png

  • 按照序列求最早发生时间ve

image.png


9.4.2 求所有事件的最迟发生时间

image.png


逆拓扑序列:V6、V5、V4、V2、V3、V1 不唯一

按照逆拓扑排序进行求值:

image.png


9.4.3 求所有活动的最早发生时间

  • 求所有活动的最早发⽣时间 e( ):
    若边表⽰活动ai,则有e(i) = ve(k)

image.png


9.4.4 求所有活动的最迟发生时间

  • 求所有活动的最迟发⽣时间 l( )
    若边表⽰活动ai,则有l(i) = vl(j) - Weight(vk, vj)

image.png


9.4.5 求所有活动的时间余量

  • 求所有活动的时间余量 d( )
    d(i) = l(i) - e(i)

image.png


9.4.6 求得关键活动,关键路径

d(k)=0的活动就是关键活动, 由 关键活动可得关键路径

image.png


9.5 ✨ 关键活动、关键路径的特性

  • 若关键活动耗时增加,则整个⼯程的⼯期将增⻓

  • 缩短关键活动的时间,可以缩短整个⼯程的⼯期

  • 当缩短到⼀定程度时,关键活动可能会变成⾮关键活动

image.png

  • 原来的时候:

a2的最早发生时间是0,最迟发生时间是1-1=0,a2是关键活动,依次推出关键路径是a2,a3,a4

  • 现在改变a3=0.5:

缩短到一定程度时候,关键活动可能变为非关键活动。a3的最早发生时间1,最迟发生时间是2-0.5=1.5 ,时间余量不等于0,所以不是关键活动了。关键路径也发生变化。


  • 可能有多条关键路径,只提⾼⼀条关键路径上的关键活动速度并不能缩短整个⼯程的⼯ 期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短⼯期的⽬的

比如还是上面那个例子:

image.png

改变切番茄为a3=1;虽然a3这条路径的长度变小了,但是上面a1->a4还是不变为6,最终还是需要6长度


所以改进要不全部缩短,要不缩短共有的关键活动

也就是缩短共有的路径a4=2 炒菜,这样可以缩短长度。


9.6 参考