第六章 图

图的基本概念

定义

图 G (Graph)由顶点集 V (Vertex)和边集 E (Edge)组成,记为 G = ( V, E ) ,其中 V(G) 表示图 G 中顶点的有限非空 集;E(G) 表示图 G 中顶点之间的关系(边)集合。若 V = {V1, V2, ..., Vn} ,则用 |V| 表示图 G 中顶点的个数,也称图 G 的阶,E = {(u, v) | u ∈ V,v ∈ V} , 用 |E| 表示图 G 中边的条数

E = {(u, v) | u ∈ V, v ∈ V} 是指对于图中的任意一条边,其两端必须连接着顶点 u、 v (u、v也可以是同一个顶点),并且两个顶点必须属于属于同一个顶点集 V 中

注意:线性表可以是空表,树可以是空树,但图不可以为空,因为 V 一定是非空集,而 E 可以是空集

例:

(a) 符合图的定义 (b) 中存在两条边,它们两端中有一端没有连接着顶点,所以不属于图 (c) 符合图的定义

有向图、无向图

无向图

若 E 是无向边 (简称 )的有限集合时,则图 G 为无向图 。边是顶点的无序对,记为 (v, w)(w, v) ,因为 (v, w) = (w, v) ,其中 v , w 是图中的两个顶点。可以说顶点 w 和顶点 v 互为邻接点。边 (v, w) 依附于顶点 w 和 v ,或者说边 (v, w) 和顶点 v , w 相关联

例:

G₂ = (V₂, E₂) V₂ = {A, B, C, D, E} E₂ = {(A, C), (B, E), (B, F), (E, C), (E, F), (F, C)}

有向图

若 E 是有向边 (也称 )的有限集合时,则图 G 为有向图 。弧是顶点的有序对,记为 <v, w> ,其中 v , w 是顶点,v 称为弧尾,w 称为弧头,<v, w> 称为从顶点 v 到(指向)顶点 w 的弧,也称 v 邻接到 w ,或 w 邻接自 v 。 <v, w> ≠ <w, v>

例:

G₁ = (V₁, E₁) V₁ = {A, B, C, D, E} E₁ = {<A, B>, <A, C>, <A, E>, <A, F>, <B, A>, <B, E>, <B, F>, <E, C>} 上图中,弧中没有箭头的一端对应的顶点叫做弧尾顶点,有箭头的一端对应的顶点叫做弧头顶点

简单图、多重图

简单图

① 不存在重复边

② 不存在顶点到自身的边

例:

多重图

图 G 中某两个结点之间的边数多于一条,又允许顶点通过同一条边和自己关联,则 G 为多重图

例:

数据结构只探讨简单图

顶点的度、入度、出度

对于无向图:顶点 v 的度是指依附于该顶点的边的条数,记为 TD(v)

在具有 n 个顶点、e 条边的无向图中,所有结点的度为2|E|

对于有向图:

​ 顶点 v 的入度是以顶点 v 为终点的有向边的数目,记为 ID(v)(箭头指向的顶点)

​ 顶点 v 的出度是以顶点 v 为起点的有向边的数目,记为 OD(v)(箭头出发的顶点)

​ 顶点 v 的度等于其顶点的入度与顶点的出度之和,即 TD(v) = ID(v) + OD(v)

在具有 n 个顶点、e 条弧的有向图中,所有结点的入度之和 = 所有结点的出度之和 = e

顶点和顶点之间关系的描述

路径

顶点 Vp 到顶点 Vq 之间的一条路径是指:顶点序列 Vp , Vi1 , Vi2 , ..., Vim , Vq

在无向图中,路径是无方向的

如下图中,左半部分的顶点 A 和顶点 C 的路径可以从 A → C , 也可以是 C → A

在有向图中,路径是有方向的,只能沿着弧指向的方向进行探索

如下图右半部分,顶点 B 和 顶点 F 之间,只存在路径从 B 到 F ,不存在 F 到 B 的路径

路径不一定存在

上面所说的右图中的顶点 F 到 顶点 B ,以及左图中顶点 F 和其他任何顶点都是没有路径的

回路

第一个顶点和最后一个顶点相同的路径称为回路或环

如上图中,无向图中的路径 C → E → F → C就是回路

有向图中的路径 A → B → A 也是回路

注意:构成环的边或弧必须多于 1 条(即回路中的每条边只能走一次)

简单路径、简单回路

简单路径 ------ 在路径序列中,不出现重复顶点的路径称为简单路径

简单回路 ------ 除第一个和最后一个顶点外,其余顶点不重复出现的回路称为简单回路

路径长度

路径上边的数目

点到点的距离

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

在上图的有向图中, A 到 F 的距离为 1 ,而 F 到 A 的距离是无穷

连通、强连通

无向图中,若从顶点 v 到顶点 w 有路径存在,则称 v 和 w 是连通

有向图中,若从顶点 v 到顶点 w 及顶点 w 到顶点 v 之间都有路径,则称这两个顶点是强连通

连通图、强连通图

若无向图 G 中任意两个顶点都是连通的,则称图 G 为连通图,否则称为非连通图

常见考点

对于 n 个顶点的无向图 G

若 G 是连通图,则最少要有 n-1 条边 (如下图中的 (a) )

分析:如下图 (a) 。仅存在一个顶点 v 与其他顶点都相连,此时其他任意两个顶点都可以通过顶点 A 找到另一个顶点,此时图为连通图,并且边数最少

若 G 是非连通图,则最多可能有 Cn−12 C_{n-1}^2 Cn−12条边

分析:如下图 (b) 。除了图中的某个顶点与其它顶点没有连接关系,剩余顶点两两之间都存在连接关系,此时图是非连通图,并且拥有最多条边
组合Cnm= n(n−1)(n−2)...(n−(m−1))m! = n!m!(n−m)! 组合C_n^m=\frac{n(n-1)(n-2)...(n-(m-1))}{m!}=\frac{n!}{m!(n-m)!} 组合Cnm=m!n(n−1)(n−2)...(n−(m−1))=m!(n−m)!n!


对于 n 个顶点的有向图 G,若有向图 G 中任何一对顶点都是强连通的,则称此图为强连通图

常见考点

对于 n 个顶点的有向图 G

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

子图

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

若有满足 V(G') = V(G) 的子图 G' ,则称其为 G 的生成子图

注意:并非任意挑选几条边、几个顶点都能构成子图,例如:

该图不满足图的定义

连通分量

无向图 中的极大连通子图 称为连通分量

极大连通子图:子图必须连通,且包含尽可能多的顶点和边。极大连通子图是不能再扩大的连通子图

强连通分量

有向图 中的极大强连通子图 称为有向图的强连通分量

极大强连通子图:子图必须强连通,且包含尽可能多的顶点和弧。极大强连通子图是不能再扩大的强连通子图

生成树

连通图生成树包含图中全部顶点的一个极小连通子图

极小连通子图:子图必须连通,但边尽可能的少

若无向图中顶点数为 n ,则它的生成树含有 n-1 条边。对生成树而言,若砍去它的一条边,则会变成非连通子图,若加上一条边则会形成一个回路

生成森林

在非连通图中,不同连通分量的生成树构成了非连通图的生成森林

生成树和生成森林是无向图特有的

边的权、带权图/网

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

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

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

几种特殊形态的图

无向完全图

无向完全图 ------ 无向图中任意两个顶点之间都存在边

对于无向图的顶点数 ∣V∣=n|V| = n ∣V∣=n,其 ∣E∣∈0,Cn2=0, n(n−1)2 |E|∈ 0,C_n\^2=0,\\frac{n(n-1)}{2} ∣E∣∈0,Cn2=0,2n(n−1)

∣E∣= n(n−1)2 |E|=\frac{n(n-1)}{2} ∣E∣=2n(n−1)时,无向图就为无向完全图

有向完全图

有向完全图 ------ 有向图中任意两个顶点之间都存在方向相反的两条弧

对于有向图的顶点数 ∣V∣=n|V|=n ∣V∣=n,其 ∣E∣∈0,2Cn2=0,n(n−1)|E|∈0,2C_n\^2=0,n(n-1) ∣E∣∈0,2Cn2=0,n(n−1)

∣E∣=n(n−1)|E|=n(n-1) ∣E∣=n(n−1)时,有向图就为有向完全图

稀疏图

边数很少的图称为稀疏图

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

稠密图

除去稀疏图之外的图就是稠密图

不存在回路 ,且连通无向图 ,也称为

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

对于一个含有n 个顶点的无向图,若 ∣E∣>n−1|E|>n-1 ∣E∣>n−1,则一定有回路,只要有回路,就一定不是树

有向树

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

注意:树是连通图,而有向树不是强连通图

常见考点

对于 n 个顶点的无向图 G

  • 所有顶点的度之和为 2|E|
  • 若 G 是连通图,则最少有 n-1 条边(树),若 |E| > n-1 ,则一定存在回路
  • 若 G 是非连通图,则最多可能有 Cn−12 C^2_{n-1} Cn−12条边
  • 无向完全图共有 Cn2 C^2_n Cn2 条边

对于 n 个顶点的有向图 G

  • 所有顶点的出度之和等于入度之和等于 |E|
  • 所有顶点的度之和等于 2|E|
  • 若 G 是强连通图,则最少有 n 条边(形成回路)
  • 有向完全图共有 2Cn2 2C^2_n 2Cn2条边

总结

图的存储 ------ 邻接矩阵法

不带权图

c 复制代码
#define MaxVertexNum 100						// 顶点数目的最大值
typedef struct {
    char Vex[MaxVertexNum];						// 顶点信息表&顶点编号表
    int Edge[MaxVertexNum][MaxVertexNum];		// 邻接矩阵,边表。行列顶点需与顶点编号表对应
    int vexnum, arcnum;							// 图的当前顶点数和边数/弧数
}MGraph;

结点数为 n 的图 G = (V, E) 的邻接矩阵 A 是 n×n 的。将 G 的顶点编号记为 v1, v2, ..., vn ,则

  • 若 (vi, vj) 或 <vi, vj> 是 E(G) 中的边,则 Aij = 1
  • 若 (vi, vj) 或 <vi, vj> 不是 E(G) 中的边,则 Aij = 0

图示:

对于不带权无向图,矩阵内元素为 1 的表示横坐标顶点存在一条边通向纵坐标顶点,0 则表示从横坐标顶点到纵坐标顶点不存在边

对于无向图,在给顶点i和j之间创建一条边时,需要将 Aij 和 Aji 都设置为1

计算一个顶点的度、入度、出度时,只需要统计顶点对应行或列中元素为1的总数即可,时间复杂度为O(n)=O(|V|)


对于非带权有向图,矩阵内元素为 1 的表示横坐标顶点存在一条弧指向纵坐标顶点,0 则表示从横坐标顶点到纵坐标顶点不存在弧

计算一个顶点的入度时,需要统计顶点对应列中元素为1的总数;计算出度时,需要统计顶点对应行中元素为1的总数;计算度则需要将入度和出度相加,时间复杂度为O(n)=O(|V|)

带权图

c 复制代码
#define MaxVertexNum 100					// 顶点数目的最大值
#define INFINITY MAX_INT					// 宏定义常量"无穷"
typedef struct {
    char Vex[MaxVertexNum];					// 顶点
    int Edge[MaxVertexNum][MaxVertexNum];	// 边的权
    int vexnum, arcnum;						// 图的当前顶点数和弧数
}MGraph;

当两个顶点之间存在边或弧,则将顶点之间的边或弧的权值作为矩阵元素的值存入,如果不存在,则存入0或无穷

图示:

性能分析

空间复杂度:O(n^2^) ------ 只和顶点数相关,和实际边数无关。所以邻接矩阵更适合存储稠密图

对于无向图,无向图的邻接矩阵是对称矩阵,所以可以使用压缩存储(只存储上三角区/下三角区)

性质

设图 G 的邻接矩阵为 AA A (矩阵元素为 0/1),则 AnA^n An 的元素 AnijA^nij Anij 可以表示由顶点 i 到顶点 j 的长度为 n 的路径的数目

AnijA^nij Anij 的计算公式是:

Anij=An−1i1×A11j+An−1i2×A12j+...+An−1ij−1×A1j−1j+An−1ij×A1jjA^nij = A^{n-1}i1 × A^11j + A^{n-1}i2 × A^12j + ... + A^{n-1}ij-1 × A^1j-1j + A^{n-1}ij × A^1jj Anij=An−1i1×A11j+An−1i2×A12j+...+An−1ij−1×A1j−1j+An−1ij×A1jj

其中 A1A^1 A1 就是邻接矩阵 AA A

A214A^214 A214为例:

A214A^214 A214表示的是从A到顶点D存在多少条路径长度为2的路径

A214=A11×A14+A12×A24+A13×A34+A14×A44A^214 = A11 × A14 + A12 × A24 + A13 × A34 + A14 × A44 A214=A11×A14+A12×A24+A13×A34+A14×A44

其中 A12=1A12=1 A12=1表示图中顶点A存在一条边通向顶点B,而 A24=1A24=1 A24=1表示图中顶点B存在一条边通向顶点D,因此就存在从顶点A到顶点D的一条长度为2的路径

将顶点 i 到顶点 j 的路径为 2 的路径总数记录在二阶矩阵A^2^中,则计算顶点 i 到 顶点 k 的路径长度为 3 的路径总数,就相当于计算满足从顶点 i 到顶点 j 路径长度为 2 的路径,再从顶点 j 到顶点 k 路径长度为 1 的路径要求的路径总数

图的存储 ------ 邻接表法

邻接表法

邻接矩阵法是使用二维数组进行实现的顺序存储结构,空间复杂度较高,不适合存储稀疏图

而邻接表是使用顺序存储 + 链式存储实现的存储方式

c 复制代码
// 出边(弧)
typedef struct ArcNode{
    int adjvex;						// 出边指向哪个顶点
    struct ArcNode* next;			// 指向下一条出边的指针
    // InfoType info;				// 边权值
}ArcNode;

// 顶点
typedef struct VNode{
	char data;						// 顶点信息
    ArcNode *first;					// 第一条出边
}VNode, AdjList[MaxVertexNum];

// 用邻接表存储图
typedef struct {
    AdjList vertices;
    int vexnum, arcnum;
}ALGaph;

一条无向边需要在邻接表中记录两次(如 A -> B 与 B -> A),导致在删除边或顶点时需要在邻接表中将正向和逆向处理两次

因此在使用邻接表存储无向图时,边结点数量是 2|E| ,整体空间复杂度为 O(|V| + 2|E|)

顶点的度、入度、出度即顶点对应的边链表中有效节点的个数


使用邻接表存储有向图时,边节点的数量是 |E| ,整体空间复杂度为 O(|V| + |E|)

顶点的出度即顶点对应的边链表中有效节点的个数;顶点的入度即所有顶点的边链表中指向该顶点的节点的个数;顶点的度等于入度加出度


图的邻接表表示方式不唯一,因为边链表中存储边节点的先后顺序可以不同

图的邻接矩阵表示方式,只要确定了顶点编号,图的邻接矩阵表示就是唯一的

总结

邻接表 邻接矩阵
空间复杂度 无向图 `O( V + 2 E ); 有向图O( V + E )` `O( V ²)`
适合用于 存储稀疏图和稠密图 存储稠密图
表示方式 不唯一 唯一
计算度/入度/出度 计算有向图的度、入度不方便,其余很方便 必须遍历对应行或列
找相邻的边 找有向图的入边不方便,其余很方便 必须遍历对应行或列

图的存储 ------ 十字链表、邻接多重表

十字链表存储有向图,邻接多重表存储无向图

十字链表存储有向图

顶点/边的删除

删除边 D -> A

删除顶点 D

性能分析

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

顺着绿色块找,可以找到指定顶点的所有出边

顺着橙色块找,可以找到指定顶点的所有入边

十字链表不适合存储无向图

邻接多重表存储无向图

邻接矩阵存储无向图,空间复杂度过高,为 O(|V|²)

邻接表存储无向图,因为每条边对应着两份冗余信息,导致删除顶点、删除边等操作的时间复杂度高

顶点/边删除

删除边 C -> E

删除顶点 E

邻接多重表表不适合存储有向图

总结

邻接矩阵 邻接表 十字链表 邻接多重表
空间复杂度 `O( V ²)` 无向图 `O( V + 2 E )有向图O( V + E )` `O( V + E )` `O( V + E )`
找相邻边 遍历对应行或列,时间复杂度为 `O( V )` 找有向图的入边必须遍历整个邻接表 很方便 很方便
删除边或顶点 删除边很方便,删除顶点需要移动大量数据 无向图中删除边或顶点都不方便 很方便 很方便
适用于 稠密图 稀疏图和稠密图 只能存有向图 只能存无向图
表示方式 唯一 不唯一 不唯一 不唯一

图的基本操作

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

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

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

DeleteVertex(G, x):在图 G 中删除顶点 x

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

RemoveEdge(G, x, y):若无向边 (x, y) 或有向边 <x, y> 存在,则从图 G 中删除该边

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

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

Get_edge_value(G, x, y):获取图 G 中边 (x, y) 或 <x, y> 对应的权值

Set_edge_value(G, x, y, v):设置图 G 中边 (x, y) 或 <x, y> 对应的权值为 v

本节只探讨基于邻接矩阵和邻接表存储的图的实现

图示

无向图
有向图

Ajdacent(G, x, y)

判断图 G 是否存在边 <x, y> 或 (x, y)

  • 邻接矩阵

    判断对应矩阵元素是否为1,时间复杂度为O(1)

  • 邻接表

    无向图:找顶点 x 或顶点 y 的边链表,搜索链表中是否存在与另一个结点编号的节点,最好时间复杂度为O(1),最坏时间复杂度为O(|v|)

    对于一个结点,它最多可以与其他各个结点都存在一条边,因此一个结点所连的边的数量最多为|v|-1条,因此搜索边链的最坏时间复杂度为O|v|

    有向图:找顶点 x 的边链表,搜索存在含 y 顶点编号的节点,最好时间复杂度为O(1),最坏时间复杂度为O(|v|)

Neighbors(G, x)

列出图 G 中与结点 x 邻接的所有边

  • 无向图

    邻接矩阵:遍历对应顶点的行或列 ,将所有为 1 的元素列出,时间复杂度为O(|V|)

    邻接表:完整遍历顶点的边链表,最好时间复杂度为O(1),最坏时间复杂度为O(|v|)

  • 有向图

    邻接矩阵:找出边需要遍历对应 ,找入边需要对应 ,将所有为 1 的元素列出,时间复杂度均为O(|V|)

    邻接表:找出边需要完整遍历顶点的边链表,最好时间复杂度为O(1),最坏时间复杂度为O(|v|);找入边需要将所有顶点的边链表都完整遍历,搜索出节点编号为该顶点的节点并列出,时间复杂度为 O(|E|)

InsertVertex(G, x)

在图 G 中插入顶点 x

对于新的顶点,刚插入到图 G 中时,是不会与其它顶点存在联系的,所以不管是邻接矩阵还是邻接表,插入新结点只需要在矩阵或表中增加一个顶点位即可,不需要做其它操作,时间复杂度均为O(1)

无向图:

由于初始化时已经将矩阵内可能包含的所有元素都初始化为 0 ,所以在新结点插入时,只需要在编号表中增加一个新结点为即可,矩阵就可以根据编号表自动确定结点行和列

有向图:

DeleteVertex(G, x)

在图 G 中删除顶点 x

  • 邻接矩阵

    移除矩阵中某个顶点的信息时,可能需要移动大量的元素(位于删除顶点之后的顶点元素都需要向上或向左移动)

    可以在定义结构体时,给顶点表的节点设置一个布尔类型的成员,当该成员为 true 时,表示该结点是存在,否则不存在,而要删除顶点时,只需要将顶点对应在矩阵中所有行和列的元素全部置为 0 ,并将顶点表的顶点的布尔类型成员置为 false 即可。时间复杂度为O(|V|)

  • 邻接表

    对于无向图,如果顶点对应的边链表为空,此时只需要将顶点表中的该顶点信息置空即可,时间复杂度为O(1);当顶点与其他顶点存在边,此时除了删除顶点在表中的信息外,还需要将与之关联的边删除,就需要遍历所有顶点的边链表,时间复杂度为O(|E|)

    删除有向图的顶点与顶点的出边和入边,删除节点以及节点的出边只需要将表中对应的信息置空即可,时间复杂度取决于边链表的长度,时间复杂度为O(1) ~ O(|V|);而删除出边,需要遍历所有顶点的边链表,把与该顶点相关联的边结点都删除,时间复杂度为O(|E|)

    删除链表时,需要手动free其中的每个节点,而不是直接将保存链表的变量置空

无向图:

有向图:

AddEdge(G, x, y)

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

  • 邻接矩阵:

    无向图,将对应 行, 列 和对应 列, 行 的元素数值改为 1 即可,时间复杂度为O(1)

    有向图,将对应 行, 列 的元素数值改为 1 即可,时间复杂度为O(1)

  • 邻接表:

    无向图,将 x, y 两个顶点的边链表中都增加一个边节点

    有向图,将 x 顶点的边链表中添加一个边节点即可

    使用头插法插入新结点,时间复杂度为O(1);使用尾插法插入新结点,时间复杂度为O(|V|)

RemoveEdge(G, x, y)

若无向边 (x, y) 或有向边 <x, y> 存在,则从图 G 中删除该边

与 AddEdge(G, x, y) 情况类似

FirstNeighbor(G, x)

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

  • 邻接矩阵

    无向图:扫描顶点对应的行或列,找到第一个值为 1 的元素的列或行顶点,最好时间复杂度为O(1),最坏为O(|V|)

    有向图:找出边邻接点就扫描行,找入边邻接点就扫描列,时间复杂度为O(1) ~ O(|V|)

  • 邻接表:

    无向图:找到顶点的边链表第一个边节点即可,时间复杂度为O(1)

    有向图:找出边邻接点需要找顶点的边链表的第一个边节点,时间复杂度为O(1);找入边邻接点就需要遍历所有顶点的边链表,时间复杂度为O(1) ~ O(|E|)

顶点 x 的出边邻接点:以顶点 x 作为弧尾的弧的弧头结点

顶点 x 的入边邻接点:以顶点 x 作为弧头的弧的弧尾结点

NextNeighbor(G, x, y)

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

和 FirstNeighbor(G, x) 情况类似,即在找到 y 的基础上向后继续寻找

获取和设置边的权值

Get_edge_value(G, x, y):获取图 G 中边 (x, y) 或 <x, y> 对应的权值

Set_edge_value(G, x, y, v):设置图 G 中边 (x, y) 或 <x, y> 对应的权值为 v

对于获取和设置某条边的权值,核心操作就是找到需要获取或设置的边,时间复杂度主要源于查找边的操作

图的广度优先遍历(BFS)

要点:

① 找到与一个顶点相邻的所有顶点

② 标记哪些顶点被访问过

③ 需要一个辅助队列

需要使用到:

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

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

此外,还需要准备一个数组,记录图中哪些顶点被访问过

c 复制代码
bool visited[MAX_VERTEX_NUM];		// 访问标记数组,初始值均为 false,表示均未被访问过

// 广度优先遍历
void BFS(Graph G, int v){			// 从顶点 v 触发,广度优先遍历图 G
	visit(v);						// 访问初始顶点 v
    visit[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)){
            // 检查 v 的所有邻接点
            if(!visited[w]){		// w 为 v 的尚未访问的邻接顶点
                visit(w);			// 访问顶点 w
                visited[w] = true;	// 对 w 做已访问标记
                EnQueue(Q, w);		// 顶点 w 入队
            }
        }
    }
}

遍历序列的可变性

对使用邻接矩阵存储的图进行广度优先遍历时,由于矩阵的表示形式是唯一的,所以对于的遍历序列也是唯一的

对使用邻接表存储的图,其表示方式不唯一,就会导致遍历序列不相同

同一个图的邻接矩阵表示方式唯一,因此广度优先遍历序列唯一

同一个图的邻接表表示方式不唯一,因此广度优先遍历序列不唯一

算法存在的问题

上面提到的代码只能遍历从一个顶点出发,遍历与其存在路径的顶点,因此算法无法正常遍历非连通图

可以利用标记数组来解决

标记数组记录了一个图的所有顶点编号,当从一个顶点开始,遍历为与它存在路径的所有顶点后,如果标记数组所有元素均为 true ,说明图的遍历已经完成

如果数组中仍存在元素为 false ,则表示还存在顶点没被遍历,需要继续调用 BFS 函数从该顶点开始继续遍历

c 复制代码
bool visited[MAX_VERTEX_NUM]			// 访问标记数组

void BFSTraverse(Graph G){				// 对图 G 进行广度优先遍历
    for(i = 0; i < G.vexnum; i++){
        visited[i] = false;				// 初始化访问标记数组
    }
    InitQueue(Q);						// 初始化辅助队列 Q
    for(i = 0; i < G.vexnum; i++){		// 从 0 号结点开始遍历
        if(!visited[i]){
            BFS(G, i);					// vi 未访问过,从 vi 开始 BFS
        }
    }
}

// 广度优先遍历
void BFS(Graph G, int v){			// 从顶点 v 触发,广度优先遍历图 G
	visit(v);						// 访问初始顶点 v
    visit[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)){
            // 检查 v 的所有邻接点
            if(!visited[w]){		// w 为 v 的尚未访问的邻接顶点
                visit(w);			// 访问顶点 w
                visited[w] = true;	// 对 w 做已访问标记
                EnQueue(Q, w);		// 顶点 w 入队
            }
        }
    }
}

因此,对于无向图,调用 BFS 函数的次数 = 连通分量数

复杂度分析

对于 BFS 算法的空间复杂度,其主要来自辅助队列,最坏的情况,其他所有顶点都围绕并连接着一个顶点,空间复杂度为O(|V|)

对于使用邻接矩阵存储的图,访问 |V| 个顶点需要O(|V|)的时间;查找每个顶点的邻接点需要O(|V|)的时间(遍历矩阵的一整行),而共有 |V| 个顶点,因此时间复杂度为O(|V|²)

对于邻接表存储的图,访问 |V| 个顶点需要O(|V|)的时间;查找所有顶点的邻接点共需要O(|E|)的时间,因此时间复杂度为O(|V|+|E|)

广度优先生成树

广度优先生成树由广度优先遍历过程确定。由于邻接表的表示方式不唯一,因此基于邻接表的广度优先生成树也不唯一

树生成方式:如果顶点 y 是通过寻找顶点 x 的邻接点时被入队的,那么在广度优先生成树中结点 x 就是结点 y 的父节点

对于该图,如果使用上面两者形式存储图,并且从 2 号顶点开始遍历,那么对应的广度优先生成树为:

如果将邻接表的 6 号顶点的边节点顺序改变一下(如上图),那对应的广度优先生成树就变为:

广度优先森林

对非连通图的广度优先遍历,可得到广度优先生成森林

假设该图的所有顶点的对应的邻接点结点是按顶点编号进行升序确定的

且连通图1从 2 号顶点开始遍历,连通图2从 9 号顶点开始遍历,则对应的广度优先生成森林:

上诉 BFS 都是基于无向图进行遍历的,那么如果使用 BFS 遍历有向图,会有什么不同呢?

假设遍历邻接点时,邻接点的编号顺序是由小到大递增的

总结

图的深度优先遍历(DFS)

c 复制代码
bool visited[MAX_VERTEX_NUM];			// 访问标记数组

void DFS(Grapg G, int v){				// 从顶点 v 出发,深度优先遍历图 G
    visit(G);							// 访问顶点 v
    visited[v] = true;					// 设置已访问标记
    for(w = FristNeighnor(G, v); w >= 0; w = NextNeighbor(G, v, w)){
        if(!visited[w]){
            DFS[G, W];					// w 为 v 的尚未访问邻接顶点
        }
    }
}

上述算法无法一次性遍历非连通图的所有顶点,需要利用标记数据进行多次DFS函数的直接调用

c 复制代码
bool visited[MAX_VERTEX_NUM];			// 访问标记数组

void DFSTraverse(Graph G){				// 对图 G 进行深度优先遍历
    for(v = 0; v < G.vexnum; v++){
        visited[v] = false;				// 初始化标记数组元素
    }
    for(v = 0; v < G.vexnum; v++){		// 从起始顶点编号开始遍历
        if(!visited[v]){
            DFS(G, v);
        }
    }
}

void DFS(Grapg G, int v){				// 从顶点 v 出发,深度优先遍历图 G
    visit(G);							// 访问顶点 v
    visited[v] = true;					// 设置已访问标记
    for(w = FristNeighnor(G, v); w >= 0; w = NextNeighbor(G, v, w)){
        if(!visited[w]){
            DFS[G, W];					// w 为 v 的尚未访问邻接顶点
        }
    }
}

复杂度分析

DFS 的空间复杂度,主要来自于函数自身的递归调用栈,最坏情况下,也就是当图的顶点是类似于串的形式排列时,递归深度最大,为 |V| 层,空间复杂度即为O(|V|),最好情况下,其他所有顶点都围绕并连接着一个顶点,递归深度最大为 2 层,此时空间复杂度为O(1)

对于 DFS 算法的时间复杂度,分析方式和 BFS 的时间复杂度一样,都可以分为访问各个顶点的时间和探索各条边的时间:

对于使用邻接矩阵存储的图,访问 |V| 个顶点需要O(|V|)的时间,查找每个顶点的邻接点都需要O(|V|)的时间,而共有 |V| 个顶点,因此时间复杂度为O(|V|²)

对于邻接表存储的图,访问 |V| 个顶点需要O(|V|)的时间,查找各个顶点的邻接点共需要O(|E|)的时间,因此时间复杂度为O(|V|+|E|)

深度优先遍历序列

对于此图,假设其存储的邻接点的顶点编号从左到右是递增的,那么

从 2 出发的深度优先遍历序列为:2, 1, 5, 6, 3, 4, 7, 8

从 3 出发的深度优先遍历序列为:3, 4, 7, 6, 2, 1, 5, 8

从 1 出发的深度优先遍历序列为:1, 2, 6, 3, 4, 7, 8, 5

和 BFS 一样,相同的图,相同的出发点,不同的邻接表表示方式的深度优先遍历序列也不相同

如果将上图的图的邻接表表示方式变为:

那么

从 2 出发的深度优先遍历序列为:2, 6, 7, 8, 4, 3, 1, 5

从 3 出发的深度优先遍历序列为:?

从 1 出发的深度优先遍历序列为:?

深度优先生成树

由深度优先遍历确定的树,不同的存储方式,深度优先生成树也不同

对于顶点 y ,如果它是通过顶点 x 寻找邻接点时被递归访问的,那么顶点 x 就是顶点 y 的父节点

上面形式存储到的图对应的深度优先生成树为:

深度优先生成森林

对非连通图进行深度优先遍历即可得到深度优先生成森林

假设该图的所有顶点的对应的邻接点结点是按顶点编号进行升序确定的

且连通图1从 2 号顶点开始遍历,连通图2从 9 号顶点开始遍历,则对应的深度优先生成森林:

图的遍历与图的连通性

无向图

对无向图进行 BFS / DFS 遍历,调用 BFS / DFS 函数的次数 = 连通分量数

对于连通的无向图,只需要调用 1 次 BFS / DFS

有向图

对有向图进行 BFS / DFS 遍历,调用 BFS / DFS 函数的次数要具体问题具体分析

若起始顶点到其他顶点都有路径,则只需调用 1 次 BFS / DFS 函数

如果有向图是强连通图,则从任意结点出发都只需要调用 1 次 BFS / DFS

总结

最小生成树

生成树

连通图 的生成树是包含图中全部顶点的一个极小连通子图

极小连通子图:边尽可能少,但要保持连通

若图中顶点数为 n,则它的生成树含有 n-1 条边。对于生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成回路

最小生成树(最小代价树)

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

最小生成树的特性:

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

Prim 算法(普里姆算法)

从图中任意一个顶点开始构建生成树,每次将代价最小的新顶点纳入到生成树中,直到将所有顶点都纳入为止

代价最小的顶点就是与当前生成树中任意一个结点相邻接的,同时两个顶点之间的边的权值最小的且还不在树中的顶点

最小生成树的形态并不唯一,这取决于具体代码实现

实现思路

初始化两个数组:isJoin、lowCost

  • isJoin :标记各个顶点是否已经加入树,✔️表示已在生成树中

  • lowCost :目前各个顶点加入树的最低代价,值为 ∞ 表示顶点与树中的任意结点都不存在直接相邻的关系

设图中有 n 个顶点,并以 V0 作为起始顶点,总共需要 n-1 轮处理

每一轮处理:循环遍历所有结点,找到 lowCost 最低的,且还没加入到树的顶点,把该顶点加入到树中。之后,需要检查未在树中的各个顶点,更新其 lowCost 值,如果新 lowCost 值更小,则更新

最终结果如下:

复杂度分析

普里姆算法的时间复杂度:O(|V|^2^)

因为每一轮处理需要加入一个新顶点,所以总共需要处理 n-1 轮,并且每轮处理都需要遍历图中所有顶点,从中找到对应 lowCost 值最小且没被加入的顶点,加入顶点后还需要更新 lowCost 数组,因此普里姆算法的平均时间复杂度为O(n-1) × O(2n) = O(n²)

因此算法适合用于边稠密图

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

每次选择一条权值最小的并且两端顶点未连通的,使这条边的两头连通,直到所有结点都连通

实现思路

初始化一个数组,用于记录每条边的权值,并按照边权值对数组元素升序排序

从上到下依次对 wList 记录的每条边进行检查,检查时判断对应的两个顶点是否连通,若不连通,则将两个顶点连通,直至将 wList 的所有元素遍历完

检查两个顶点是否连通可以利用并查集实现

复杂度分析

克鲁斯卡尔算法的时间复杂度:O(|E|log₂|E|)

数组 wList 的长度为边的数量,即 |E| ,而判断两个顶点是否连通的平均时间复杂度为O(log₂|E|),因此克鲁斯卡尔算法的平均时间复杂度为O(|E|log₂|E|)

所以算法适合用于边稀疏图

最短路径算法 ------ BFS 算法

最短路径问题

单源最短路径即从某个源点出发,到其它各个顶点的最短路径

最小生成树并不能保证图中任意两点之间的路径都是最短的,而单源最短路径则只关注从源点到其他顶点的最短路径,而不考虑整个图的连通性和代价

BFS算法适合求所有路径权值均相等的图的单源最短路径

BFS 求无权图的单源最短路径

步骤:

  1. 初始化两个数组d、path

    d 数组保存了当前顶点到原始顶点的路径长度,原始顶点就是第一个入队列的顶点(即"单源")

    path 数组保存了当前顶点的直接前驱顶点编号

  2. 如果顶点 y 是通过顶点 x 找邻接点时被入队的,那么 x 就是 y 的直接前驱

    原始顶点没有前驱,所以对应 path 始终为 -1

c 复制代码
// 求顶点 u 到其他顶点的最短路径
void BFS_MIN_Distance(Graph G, int u){
    // d[i] 表示从 u 到 i 结点的最短路径
    for(int i = 1; i < G.vexnum; i++){			// i 从几开始需要根据具体情况而定
        d[i] = ∞;								// 初始化路径长度
        path[i] = -1;							// 初始化顶点前驱
    }
    d[u] = 0;									// 起始顶点到自身的路径长度为0
    visited[u] = true;
    EnQueue(Q, u);
    while(!IsEmpty(Q)){							// BFS 算法主过程
        DeQueue(Q, u);							// 队头元素 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 入队
            }
        }
    }
}

BFS 求最短路径就是对 BFS 进行小的修改,在 visit 一个顶点时,修改其最短路径长度 d 并在 path 记录前驱结点

以该树为例,使用 BFS 算法得到的从 2 号顶点开始,到其他顶点的最短路径为:

从这两个数组可知:

2 到 8 的最短路径长度 = d8 = 3

通过 path 数组可知,2 到 8 的最短路径为: 2 → 6 → 7 → 8

BFS 算法与求广度优先生成树之间的关系

BFS 求某个顶点与起始顶点的最短路径,就相当于求该广度优先生成树中的某个顶点到根节点的路径长度,由于是最短路径,因此广度优先生成树一定是从源点(源点即根节点)出发,高度最小的生成树

某顶点的直接前驱,对应了二叉树中该节点的父节点

最短路径 ------ Dijkstra 算法

BFS 算法求单源最短路径只适用于无权图,或所有边权值都相同(且为正值)的图

Dijkstra 算法

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

此处以有向图作为对象进行探讨,无向图的每条边可以看作是有向图中相邻且方向相反的两条边

初始:

  • 假设从 V0 开始,初始化三个数组信息
  • final :当前顶点是否已经找到最短路径
  • dist :当前顶点目前的最短路径长度
  • path :当前顶点目前的直接前驱

之后:

  • 循环遍历所有结点,找到还没确定最短路径,且 dist 最小的顶点 Vi ,先令其 finali = true,再检查所有邻接自 Vi 的顶点,若其 final 值为 false ,则检查并更新 dist 和 path 信息,如果新的 dist 值比原始值小,则更新 dist 和 path 的值

该操作需要一直进行到将所有的 final 元素都设置为 true 为止

通过该图可知

V0 到 V2 的最短路径长度为 dist2 = 9

通过 path 可知,V0 到 V2 的最短路径为:V0 -> V4 -> V1 -> V2

代码实现思路

初始:

  • 若从 V0 开始,令 final0=true;dist0=0;path0=−1final0=true;dist0=0;path0=-1 final0=true;dist0=0;path0=−1
  • 其余顶点 finalk=false;distk=arcs0k;pathk=(arcs0k==∞)?−1:0finalk=false;distk=arcs0k;pathk=(arcs0k==∞)?-1:0 finalk=false;distk=arcs0k;pathk=(arcs0k==∞)?−1:0

n-1轮处理:

  • 循环遍历所有顶点,找到还没有确定最短路径,且dist 最小的顶点 Vi ,令 finali=truefinali=true finali=true。并检查所有邻接自 Vi 的顶点Vj,对于邻接自 Vi 的顶点 Vj ,若 finalj==falsefinalj==false finalj==false且 disti+arcsij<distjdisti+arcsij<distj disti+arcsij<distj,则令 distj=disti+arcsij;pathj=idistj=disti+arcsij;pathj=i distj=disti+arcsij;pathj=i

注:其中 arcsijarcsij arcsij表示 Vi 到 Vj 的弧的权值

复杂度分析

每一轮处理都需要找到数组中还没有确定最短路径且 dist 最小的顶点,该操作的时间复杂度为O(|V|),找到顶点后,还需要检查并更新与该顶点的所有邻接顶点的 dist 值,如果是使用邻接矩阵进行存储,找邻接点需要O(|V|)的时间,如果使用邻接表,查找邻接点需要O(|E|)的时间,最坏情况下,一个顶点的所有邻接点为除自身外其他所有顶点,所以 |E| 的最大值也为 |V| ,所以检查 Vi 的邻接点所需要的最坏时间复杂度为O(|V|),所以每处理一轮就需要O(2|V|)的时间复杂度,这种操作共需要进行 n-1(|V|-1) 轮,所以 Dijkstra 算法的时间复杂度为O(|V|²)

负权值的带权图

例:

如果使用 Dijkstra 算法得到的信息如下:

显然,从 V0 到 V2 的最短路径长度应该为 10 + (-5) = 5

Dijkstra 算法不适用于有负权值的带权图

最短路径 ------ Floyd 算法

Floyd 算法

功能:求出每一对顶点之间的最短路径

使用了动态规划思想,将问题的求解分为了多个阶段:

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

  • 初始:不允许在其他顶点中转,最短路径是?
  • #0:若允许在 V0 中转,最短路径是?
  • #1:若允许在 V0,V1 中转,最短路径是?
  • #2:若允许在 V0,V1,V2 中转,最短路径是?
  • ......
  • #n-1:若允许在 V0,V1,V2 ... V(n-1) 中转,最短路径是?

例:

  • 初始:不允许在其他顶点中转,最短路径为

矩阵 A 保存了目前阶段的各个顶点间的最短路径长度(初始阶段的矩阵 A^(-1)^ 其实就是带权图的邻接矩阵表示形式)

矩阵 path 保存着目前阶段的各个顶点间最短路径的中转点编号,值为 -1 表示横纵坐标对应的两顶点是相互邻接的

  • #0:若允许在 V0 中转,最短路径为

对于允许中转的情况,可以依照下面的公式来更新矩阵:
若Ak−1ij>Ak−1ik+Ak−1kj则Akij=Ak−1ik+Ak−1kj;pathkij=k否则Ak和pathk保持原值若A^{k-1}ij>A^{k-1}ik+A^{k-1}kj\\ 则A^{k}ij=A^{k-1}ik+A^{k-1}kj;path^kij=k\\ 否则A^k和path^k保持原值 若Ak−1ij>Ak−1ik+Ak−1kj则Akij=Ak−1ik+Ak−1kj;pathkij=k否则Ak和pathk保持原值

对于拥有 n * n 个元素的矩阵 A^-1^ ,对其每个元素都进行检查,就可以得到 A^0^ 和 path^0^:

  • #1:若允许在 V0、V1 中转,最短路径为
  • #2:若允许在 V0、V1、V2 中转,最短路径为

到此,从 A^-1^ 和 path^-1^ 开始,经过 n ( n 为顶点个数)轮递推,得到 A^n-1^ 和 path^n-1^

小技巧:

在使用 Floyd 算法分析目前阶段的矩阵 A 和矩阵 path 的变化时,可以省去不必要判断:

  • 省去判断首尾结点相同的最短路径

    判断两个顶点间的最短路径时,对于起始顶点与末尾顶点是同一个顶点的情况,可以直接跳过

    如 A00 、 A11

  • 省去判断行顶点和列顶点为当前阶段的中转顶点的矩阵元素

    中转顶点不需要以自身进行中转点,这是没有意义的

    如 #1 阶段的 A1x 、 Ax1 和 #2 阶段的 A2x 、 Ax2

说明:以上情况均只适用与存在最短路径的图,类似于下面所讲的"负权回路"图,此结论会出现问题

探讨:

列出 V0 和 V4 之间的最短路径,包含路径中所有中转顶点

以此图为例,到第 n-1 阶段时,两个矩阵的情况为:

通过 A 矩阵可以得到, V0 到 V4 的最短路径长度为 4 ,通过 path 矩阵可知, V0 到 V4 是经过了 V3 中转点;而从 V0 到 V3 的长度是 3 ,通过 path 矩阵可知, V0 到 V3 的途中又经过了 V2 中转点;从 V0 到 V2 的最短路径为 2 ,通过 path 矩阵可知,从 V0 到 V2 又经过了 V1 中转点;从 V0 到 V1的长度为 1,通过 path 矩阵可知,途中没有出现其它中转点,到此可以得到 V0 到 V4 的最短路径为:

V0 -> V1 -> V2 -> V3 -> V4

通过 path 矩阵递归地找到完整路径:

V0 V4 V0 V3 V4 V0 V2 V3 V4 V0 V1 V2 V3 V4

核心代码

c 复制代码
// 初始化:将矩阵 A(-1) 和矩阵 path(-1) 进行初始化
for(int k = 0; k < n; k++){								// 以 Vk 作为中转点
    for(int i = 0; i < n; i++){							// 遍历整个矩阵, i 为行号, j 为列号
        for(int j = 0; j < n; j++){
            if(A[i][j] > A[i][k] + A[k][j]){			// 以 Vk 为中转点的路径更短
                A[i][j] = A[i][k] + A[k][j];			// 更新最短路径
                path[i][j] = k;							// 中转点
            }
        }
    }
}

时间复杂度:O(n³),开销主要来自三层嵌套循环

空间复杂度:O(n²),开销主要来自两个 n*n 的矩阵

Floyd 算法用于负权值图

试 Floyd 算法模拟找该图的各个顶点间的最短路径

关于 Floyd 算法不能解决的问题

Floyd 算法不能解决带有"负权回路"的图,这种图有可能没有最短路径

分析此图会发现,从 V0 到 V1 的路径的权值为 -9,其绝对值 9 比从 V1 -> V0 的路径长度加起来还要大,所以如果在此环中走的边数越多,权值会越来越小,Floyd 是无法处理这种图的最短路径的,因为这种图可能根本就不存在最短路径

总结

BFS 算法 Dijkstra 算法 Floyd 算法
无权图 ✔️ ✔️ ✔️
带权图 ✔️ ✔️
带负权值图 ✔️
带负权值回路图
时间复杂度 O(|V|²) 或 O(|V| + |E|) O(|V|²) O(|V|³)
通常用于 求无权图的单源最短路径 求带权图的单源最短路径 求带权图中各顶点间的最短路径

注:也可以用 Dijkstra 算法求所有顶点间的最短路径,只需要对图中的每个顶点都执行一次 Dijkstra 算法,共重复 |V| 次,总的时间复杂度也是 O(|V|³)

有向无环图描述表达式

有向无环图(DAG)

若一个有向图中不存在环 ,则称为有向无环图,简称 DAG 图(Directed Acyclic Graph)

DAG 描述表达式

现有表达式: ((a+b)∗(b∗(c+d)+(c+d)∗e)∗((c+d)∗e))((a + b) * (b * (c + d) + (c + d) * e) * ((c + d) * e)) ((a+b)∗(b∗(c+d)+(c+d)∗e)∗((c+d)∗e))

对应的表达式树为:

该表达式树中存在许多重复的子树

可以将重复的子树剔除,只保留其中一颗,并将指向剔除的重复子树的指针指向保留的子树

拓扑排序

AOV 网

AOV 网(Activity On Vertex Network,用顶点表示活动的网):用 DAG 图表示一个工程。每一个顶点表示一种活动,有向边 <Vi , Vj > 表示活动 Vi 必须先于活动 Vj 进行

拓扑排序

在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:

① 一个顶点出现且只出现一次

② 若顶点 A 在序列中排在顶点 B 前面,则图中不存在从顶点 B 到顶点 A 的路径

或定义为:拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点 A 到顶点 B 的路径,则在排序中顶点 B 出现在顶点 A 的后面

上述番茄炒蛋工程的拓扑排序序列为:

每个 AOV 网都有一个或多个拓扑排序序列

以"番茄炒蛋"工程为例,序列的第一个顶点可以是准备厨具,也可以是买菜

拓扑排序的实现:

① 从 AOV 网中选择一个没有前驱(入度为 0)的顶点并输出

② 从网中删除该顶点和所有以它为起点的有向边

③ 重复 ① 和 ② 直到当前 AOV 网为空或当前网中不存在无前驱的顶点为止

如果图中所有的顶点的入度均大于0,则说明图中存在环路

c 复制代码
#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;						// 图的顶点数和弧数
}Graph;										// Graph 是以邻接表存储的图类型

bool TopologicalSort(Graph G){
    InitStack(S);							// 初始化栈,用于保存入度为 0 的顶点(也可以换为队列,会影响输出顺序)
    for(int i = 0; i < G.vexnum; i++){
        if(indegree[i] == 0){
            Push(S, i);						// 将所有入度为 0 的顶点出栈
        }
    }
    int count = 0;							// 计数,记录当前已经输出的顶点数
    while(!IsEmpty(S)){						// 栈不空,则存在入度为 0 的顶点
        Pop(S, i);							// 栈顶元素出栈
        print(count++) = i;					// 输出顶点 i
        for(p = G.vertices[i].firstarc; p; p = p->nextarc){
			// 将所有 i 指向的顶点的入度 -1 ,并且将入度结果为 0 的顶点压入栈 S
            v = p->adjvex;
            if(!(--indegree[v])){
                Push(S, v);					// 入度为 0,则入栈
            }
        }
    }
	if(count < G.vexnum){
        return false;						// 排序失败,有向图中有回路
    } else {
        return true;						// 拓扑排序成功
    }
}

indegree数组:记录图中所有顶点当前的入度

print数组:记录拓扑排序序列

由于需要对每个顶点和每条边都进行一次处理,所以:

  • 邻接表存储形式:时间复杂度:O(|V| + |E|)
  • 邻接矩阵存储形式:时间复杂度:O(|V|^2^)

逆拓扑排序

上述"番茄炒蛋"工程的逆拓扑排序序列为:

具体实现思路

对于 AOV 网,如果采用下列步骤进行排序,则称之为逆拓扑排序:

① 从 AOV 网中选择一个没有后继(出度为 0)的顶点输出

② 从网中删除该顶点和所有以它为终点的有向边

③ 重复 ① 和 ② 直到当前的 AOV 网为空

拓扑排序序列反转后得到的就是逆拓扑排序序列,反之同理,可以通过拓扑排序和逆拓扑排序的定义来证明

  • 拓扑排序:对于任意一条有向边u→v,在序列中,u一定出现在v前面
  • 逆拓扑排序:对于任意一条有向边u→v,在序列中,v一定出现在u前面
逆邻接表

邻接表中顶点的边节点保存的是出边顶点,即从当前顶点出发,指向的其它顶点

而逆邻接表的边节点是保存的是入边顶点,即指向自己的边的弧尾顶点

逆拓扑排序的 DFS 算法实现

c 复制代码
void DFSTraverse(Graph G){					// 对图 G 进行深度优先遍历
    for(int v = 0; v < G.vexnum; v++){
        visited[v] = false;
        flag[v] = false;					// 标志数组,用于判断顶点是否出栈
    }
    for(int v = 0; v < G.vexnum; v++){
        if(!visited[v]){
            if(DFS(G, v)){
                // 存在环路
                return true;
            }
        }
    }
}

void DFS(Graph G, int v){
    flag[v] = true;							// 顶点开始处理,入栈
    visited[v] = true;
    for(w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)){
        if(!visited[w]){
            if(DFS(G, w)){
                return true;
            }
        }else if(flag[v]){
            // 存在祖先关系的邻接点,说明存在环路
            return true;
        }
    }
    print(v);								// 输出顶点
    flag[v] = false;						// 顶点及其所有邻接点均处理完成,出栈
    return false;
}

DFS 实现逆拓扑排序是在顶点退栈前输出

DFS 也可实现拓扑排序

总结

注意:逆拓扑排序序列也可能是不唯一的;若图中有环,则不存在拓扑排序序列/逆拓扑排序序列,因此也常常利用拓扑排序/逆拓扑排序算法来判断图中是否存在环

关键路径

AOE 网

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

AOE 网具有以下两个性质:

① 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始

② 只有在进入某个顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生

另外,有些活动是可以同时进行的(如打鸡蛋和洗番茄这两个活动就是可以同时进行的)

相关概念

在 AOE 网中仅有一个 入度为 0 的顶点,称为开始顶点(源点),它表示整个工程的开始(如 V1 )

仅有一个 出度为 0 的顶点,称为结束顶点(汇点),它表示整个工程的结束(如 V4)

从源点到汇点的有向路径可能有多条,而在所有路径中,具有最大路径长度的路径称为关键路径 ,关键路径上的活动称为关键活动

完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程完成时间就会延长

求关键路径的几个参数

事件 vk 的最早发生时间 vek :它决定了所有从 vk 开始的活动能够开工的最早时间

活动 ai 的最早开始时间 ei:指该活动弧的起点所表示的事件的最早发生时间

事件 vk 的最迟发生时间 vlk:指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间

活动 ai 的最迟开始时间 li:指该活动弧的终点所表示的事件的最迟发生时间与该活动所需时间之差

活动 ai 的时间余量 di = li - ei :表示在不增加完成整个工程所需时间的情况下,活动 ai 可以拖延的时间

若一个活动的时间余量为 0 ,则说明该活动必须要如期完成(否则整个工程将会逾期才能完成),即 di = 0 的活动 ai 就是关键活动。由关键活动组成的路径就是关键路径

求关键路径的步骤

① 求所有事件的最早发生时间 ve

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

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

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

⑤ 求所有活动的时间余量 d

例:

① 求所有事件的最早发生时间 ve
按拓扑排序序列,依次求各个顶点的vek:ve源点=0vek=Max{vej+Weight(vj,vk)},vj为vk的任意前驱 按拓扑排序序列,依次求各个顶点的ve_k:\\ ve_{源点}=0\\ ve_k=Max\{ve_j+Weight(v_j,v_k)\},v_j为v_k的任意前驱 按拓扑排序序列,依次求各个顶点的vek:ve源点=0vek=Max{vej+Weight(vj,vk)},vj为vk的任意前驱

拓扑排序序列为:V1、V3、V2、V5、V4、V6

得到的结果为:

V1 V2 V3 V4 V5 V6
ve(k) 0 3 2 6 6 8

② 求所有事件的最迟发生时间 vl
按逆拓扑排序序列,依次求各个顶点的vlk:vl汇点=ve汇点vlk=Min{vlj+Weight(vk,vj)},vj为vk的任意后继 按逆拓扑排序序列,依次求各个顶点的vl_k:\\ vl_{汇点}=ve_{汇点}\\ vl_k=Min\{vl_j+Weight(v_k,v_j)\},v_j为v_k的任意后继 按逆拓扑排序序列,依次求各个顶点的vlk:vl汇点=ve汇点vlk=Min{vlj+Weight(vk,vj)},vj为vk的任意后继

逆拓扑排序序列为:V6、V5、V4、V2、V3、V1

得到的结果为:

V1 V2 V3 V4 V5 V6
vl(k) 0 4 2 6 7 8

③ 求所有活动的最早发生时间 e
若边<vj,vk>表示活动ai,则有ei=vej 若边<v_j,v_k>表示活动a_i,则有e_i=ve_j 若边<vj,vk>表示活动ai,则有ei=vej

a1 a2 a3 a4 a5 a6 a7 a8
e(k) 0 0 3 3 2 2 6 6

④ 求所有活动的最迟发生时间 l
若边<vj,vk>表示活动ai,则有li=vlk−Weight(vj,vk) 若边<v_j,v_k>表示活动a_i,则有l_i=vl_k-Weight(v_j,v_k) 若边<vj,vk>表示活动ai,则有li=vlk−Weight(vj,vk)

a1 a2 a3 a4 a5 a6 a7 a8
l(k) 1 0 4 4 2 5 6 7

⑤ 求所有活动的时间余量 d
di=li−ei d_i=l_i-e_i di=li−ei

a1 a2 a3 a4 a5 a6 a7 a8
d(k) 1 0 1 1 0 3 0 1

关键活动:a2,a5,a7

关键路径:V1 -> V3 -> V4 -> V6

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

若关键活动耗时增加,则整个工程的工期将增长

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

当关键活动的时间缩短到一定程度时,关键路径可能会变成非关键路径

可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的公共关键活动才能达到缩短工期的目的(或将所有关键路径上的非共同关键活动同时缩短也能达到缩短工期的目的)

总结

相关推荐
hold?fish:palm2 小时前
11 滑动窗口最大值
数据结构·算法
xvhao20132 小时前
T750414 【游戏】计算24点 题解
数据结构·c++·算法·游戏
自然常数e3 小时前
顺序表专题
c语言·数据结构·visual studio
小七在进步4 小时前
数据结构:树的遍历
数据结构
j7~4 小时前
【数据结构初阶】队列的实现(链式队列 + 循环队列)--详解
c语言·开发语言·数据结构·学习·队列·queue·c\c++
king_linlin4 小时前
算法基础——算法复杂度
c语言·开发语言·数据结构·算法
AndyHeee5 小时前
Linux 内核底层机制:设备树(Device Tree)的数据结构与内存组织
linux·前端·数据结构
king_linlin5 小时前
数据结构——顺序表(附图文讲解|超详细)
c语言·数据结构·算法
宵时待雨19 小时前
优选算法专题9:哈希表
数据结构·算法·散列表