第5章 图
5.1 图的基本概念
5.1.1 图的定义
图的顶点集V一定非空,但边集可以E为空。
1.有向图
有向边也称为弧。
2.无向图
因为(v, w) = (w, v),其中v、w是顶点,可以说顶点w和顶点互为邻接点,边(v, w)和顶点v、w相关联。
3.简单图
- 不存在重复边;
- 不存在顶点到自身的边
4.多重图
允许图中某两个结点之间的边数多于一条,有允许顶点通过同一条边和自己关联。
5.完全图(简单完全图)
- 在无向图中,若任意两个顶点之间都存在边,则称该图为无向完全图。含有n个顶点的无向完全图有n(n-1)/2条边。
- 在有向图中,若任意两个顶点之间都存在方向相反的两条弧,则称该图为有向完全图。含有n个顶点的无向完全图有n(n-1)条边。
6.子图
- 生成子图需满足V(G’) = V(G),对边没有要求。
- 并非V和E的任何自己都能构成G的子图,因为这样的子集可能不是图。
7.连通、连通图和连通分量
在无向图中:
-
若从顶点v到顶点w有路径存在,则称v和w是连通的。
-
若图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图。
-
极大连通子图要求该连通子图包含其所有的边
-
极小连通子图要求保持图连通且使得边数最少的子图。
-
无向图中的极大连通子图称为连通分量。
-
若G是连通图,则最少有n-1条边。
-
若一个图有n个顶点,并且边数小于n-1,则此图必是非连通图
-
若一个图有n个顶点,则当边数至少为n-1时,才可能是连通图,也可能是包含环的非连通图。
-
若G是非连通图,则最多可能有 C n − 1 2 C^2_{n-1} Cn−12条边(n-1个结点组成了完全图)。
8.强连通图、强连通分量
在有向图中:
- 若从顶点v到顶点w和顶点w到顶点v之间都有路径存在,则称v和w是强连通的。
- 若图中任意一对顶点都是强连通的,则称此图为强连通图。
- 对于n个顶点的有向图G,若G是强连通图,则最少有n条边(形成回路)。
- 有向图中的极大强连通子图称为有向图的强连通分量。
9.生成树、生成森林
- 连通图的生成树是包含图中全部顶点的一个极小连通子图。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成回路(生成树不唯一)。
- 在非连通中,连通分量的生成树构成了非连通图的生成森林。
10.顶点的度、入度和出度
- 💡对于无向图,全部顶点的度的和等于边数的2倍
- 对于有向图,顶点v的度等于其入度和出度之和。且有向边的全部顶点的入度之和=出度之和=边数。
11.边的权和网、带权路径长度
- 边上带权值的图称为带权图,也称为网。
- 当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度。
12.稠密图、稀疏图
边数很少的图称为稀疏图,反之称为稠密图。一般当图G满足|E|<|V|log|V|时,可以将G视为稀疏图。
13.路径、路径长度、回路
- 路径是由顶点和相邻顶点序偶构成的边所形成的顶点序列;
- 路径上边的数目称为路径长度;
- 第一个顶点和最后一个顶点相同的路径称为回路或环。若一个图有n个顶点,并且有大于n-1条边,则此图一定有环。
14.简单路径、简单回路
- 顶点不重复出现的路径称为简单路径;
- 除第一个顶点和最后一个顶点外,其余顶点不重复出现的的回路称为简单回路。
15.距离
从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离。若从u到v根本不存在路径,则记该距离为无穷。
16.有向树
一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树。(有向树不是强连通图)
【注意】
- 非连通图无法遍历图中所有顶点。
- 非对称的邻接矩阵说明图是有向图。
- 要确保具有n个顶点的无向图是连通图,则需前n-1个顶点构成完全图,最后一个的顶点与前n-1个顶点中任意一个连接起来。
- 因为n个顶点构成环有n条边,去掉其中任意一条便是生成树,所以有n种情况,即n个生成树。
【题型】
- 如何对无环有向图中的顶点号重新安排可使得该图的邻接矩阵种所有的1都集中到对角线以上?
答:采用拓扑排序对顶点重新编号。 - 在图状结构中每个结点的前驱结点和后继结点可以任意多个。
5.2 图的存储及基本操作
5.2.1 邻接矩阵
邻接矩阵是顺序存储结构。
#define MAX_VETER_NUM 100
typedef char VertexType;
typedef int EdgeType;
typedef struct{
char Vex[MAX_VETER_NUM]; //顶点表
int Edge[MAX_VETER_NUM][MAX_VETER_NUM]; //邻接矩阵,边表(可以用占用空间小的bool型或枚举型变量表示边)
int vexnum, arcnum; //图的当前顶点数和弧数
}MGraph;
- 当邻接矩阵中的元素仅表示相应的边是否存在时,EdgeType可定义为值为0和1的枚举类型。
- 无向图的邻接矩阵是对称矩阵,可以考虑压缩存储上(或下)三角矩阵的元素。
- 邻接矩阵表示法的空间复杂度为O(n2),其中n为图的顶点数|V|。
- 对于无向图,邻接矩阵的第i行(列)非零(∞)元素的个数正好是第i顶点的度。
- 对于有向图,邻接矩阵的第i行(列)非零(∞)元素的个数正好是第i顶点的出度(入度)。
- 邻接矩阵表示法容易确定图中任意两顶点之间是否有边相连。但确定边的个数需要按行或列遍历整个矩阵,时间复杂度高,效率低。
- 稠密图适合用邻接矩阵法存储。
- 设图G的邻接矩阵为A,An的元素An[i][j]表示由顶点i到顶点j的长度为n的路径的数目。(无向图的边可以重复来回计算)
5.2.2 邻接表法
邻接表法结合了顺序存储和链式存储。
所谓邻接表,是指对图G中的每个顶点vi建立一个单链表,第i个单链表中的结点表示依附于顶点vi的边(对于有向图则是以顶点vi为尾的弧),这个单链表就称为顶点vi的边表(对于有向图则称出边表)。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点表结点和边表结点。
#define MAX_VETERX_NUM 100 //图中顶点数目的最大值
typedef struct ArcNode{ //边表结点
int adjvex; //该弧所指向的顶点的位置
struct ArcNode * next; //指向下一条弧的指针
//InfoType info; //网的边权值
}ArcNode;
typedef struct VNode{ //顶点表结点
VetexType data; //顶点信息
ArcNode * firstarc; //指向第一条依附该顶点的弧的指针
}VNode, AdjList[MAX_VETEX_NUM];
typedef struct ALGraph{
AdjList vertices; //邻接表
int vexnum, arcnum //图的顶点数和弧数
}ALGraph; //ALGraph是以邻接表存储的图类型
- 若G为无向图,则所需的存储空间为O(|V|+2|E|);若G为有向图,则所需的存储空间为O(|V|+|E|)。
- 稀疏图适合用邻接表发存储。
- 邻接表,给定一顶点,可以通过读取邻接表快速找出它的所有邻边。而邻接矩阵中相同的操作需要扫描一行,时间复杂度为O(n)。
- 邻接表无法像邻接矩阵一样以O(1)的时间复杂度判断两顶点之间是否存在边。
- 在邻接表中,求顶点的入度需要遍历全部邻接表。可以采用逆邻接表的存储方式来加速求解给定顶点的入度。
- 图的邻接表表示不唯一,因为在每个顶点对应的单链表中,各边结点的链接次序可以是任意的,它取决于建立邻接表的算法及边的输入次序。
5.2.3 十字链表法
十字链表法是有向图的一种链式存储结构。j改进了邻接表求顶点入度较麻烦的问题。
弧头相同的弧在同一条链表上,弧尾相同的弧也在同一条链表上。弧结点中有5个域:
- tailvex(尾域):指向弧尾(箭头出发的结点);
- headvex(头域):指向弧头(箭头指向的顶点);
- hlink(链域):指向弧头相同的下一条弧;
- tlink(链域):指向弧尾相同的下一条弧;
- info:指向该弧的相关信息。
顶点结点之间是顺序存储的。顶点结点中有3个域:
- data:存放顶点相关的数据信息;
- firstin:指向以该顶点为弧头的第一个弧结点;
- firstout:指向以该顶点为弧尾的第一个弧结点。
#define MAX_VERTEX_NUM 100
//边表结点
typedef struct ArcNode{
int tailvex, headvex;
struct ArcNode *hlink, tlink;
//InfoType info;
}ArcNode;
//顶点表结点
typedef struct VNode{
VertexType data;
ArcNode *firstin, firstout;
}VNode;
//邻接表
typedef struct GLGraph{
VNode xlist[MAX_VERTEX_NUM];
int vexnum, arcnum;
}GLGraph;
十字链表中,既容易找到vi为尾的弧,也容易找到vi为头的弧,因为容易求得顶点的出度和入度。
图的十字链表法表示不唯一,但一个十字链表表示确定的一个图。
5.2.4 邻接多重表
邻接多重表是无向图的一种链式存储结构。
边结点有6个域:
-
mark(标志域):用以标记该条边是否被搜索过;
-
ivex:边的一个顶点;
-
jvex:边的另一个顶点;
-
ilink:指向下一条依附于顶点ivex的边;
-
jlink:指向下一条依附于顶点jvex的边;
-
info:指向和边的相关信息。
顶点结点有2个域:
- data:存储顶点相关信息;
- firstedge:指示第一条依附于该顶点的边。
在邻接多重表中,所有依附于同一顶点的边串联在同一链表中,由于每条边依附于两个顶点,因此每个边结点同时链接在两个链表中。
#define MAX_VERTEX_NUM 100
typedexf struct ArcNode{
bool mark;
int ivex, jvex;
struct ArcNode *ilink, *jlink;
//InfoType info;
}ArcNode;
typedef struct VNode{
VertexType data;
ArcNode * firstedge;
}VNode;
typedef struct AMLGraph{
VNode adjmulist[MAX_VERTEX_NUM];
int vexnum, arcnum;
}AMLGraph;
邻接矩阵 | 邻接表 | 十字链表 | 邻接多重表 | |
---|---|---|---|---|
空间复杂度 | O ( ∣ V ∣ 2 ) \text{O}(|V|^2) O(∣V∣2) | 无向图
O
(
∣
V
∣
+
2
∣
E
∣
)
\text{O}(|V|+2|E|)
O(∣V∣+2∣E∣) 有向图 O ( ∣ V ∣ + ∣ E ∣ ) \text{O}(|V|+|E|) O(∣V∣+∣E∣) | O ( ∣ V ∣ + ∣ E ∣ ) \text{O}(|V|+|E|) O(∣V∣+∣E∣) | O ( ∣ V ∣ + ∣ E ∣ ) \text{O}(|V|+|E|) O(∣V∣+∣E∣) |
找相邻边 | 遍历对应行或列 | 找有向图的入边必须遍历整个邻接表 | 很方便 | 很方便 |
删除边或顶点 | 删除边很方便,删除顶点需要大量移动数据 | 无向图中删除边或顶点都不方便 | 很方便 | 很方便 |
适用于 | 稠密图 | 稀疏图和其他 | 只能存有向图 | 只能存无向图 |
表示方法 | 唯一 | 不唯一 | 不唯一 | 不唯一 |
5.2.5 图的基本操作
图的基本操作独立于图的存储结构。对于不同的存储方式,操作算法的具体实现会有着不同的性能。
- adjEdge(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。
int nextNeighbor(MGraph &G, int x, int y){
//以邻接矩阵作为存储结构
if(x != -1; && y != -1)
{
for(int col = y + 1; col < G.vexnum; ++col)
{
if(G.edge[x][col] > 0 && G.edge[x][col] < MaxWeight) //MaxWeight代表无穷
return col;
}//for
}//if
return -1;
}
int nextNeighbor(ALGraph &G, int x, int y){
//以邻接表作为存储结构
if(x != -1)
{
ArcNode *p = G.vertices[x].first;
while(p != NULL && p->data != y)
{
p = p->next;
}
if(p != NULL && p->next != NULL)
return p->next->data;
}
return -1;
}
- getEdgeValue(G, x, y):获取图G中边(x, y)或边<x, y>对应的权值。
- setEdgeValue(G, x, y, v):设置图G中边(x, y)或边<x, y>对应的权值为v。
【注意】
-
AOV网:有向图。用顶点表示活动,用边表示活动之间的顺序关系的图。
-
AOE网:有向图。用顶点表示状态,用边表示状态之间的活动以及伴随这些活动的代价。
-
根据题目要求画出有向图或无向图时,应当避免边交叉。
5.3 图的遍历
5.3.1 广度优先搜索(BFS)
广度优先搜索(Breadth First Search, BFS)类似于二叉树的层序遍历算法。为了实现逐层访问,算法必须接著一个辅助队列,以记忆正在访问的顶点的下一层顶点。
Dijkstra单源最短路径算法和Prim最小生成树算法应用了类似广度优先搜索的思想。
#define MAX_VERTEX_NUM 50
#define TRUE 1
#define FALSE 0
bool visited[MAX_VERTEX_NUM]; //访问标记数组
SqQueue Q; //辅助队列Q
void BFSTraverse(Graph G){
//对图G进行广度优先遍历,设访问函数为visit()
int i;
for(i = 0; i < G.vexnum; ++i)
{
visited[i] = FALSE; //访问标记数组初始化
}
initQueue(Q);
for(i = 0; i < G.vexnum; ++i)
{
if(!visited[i])
BFS(G, i);
}
}
void BFS(Graph G, int v){
//从顶点v出发,广度优先遍历图G,算法借助一个辅助队列Q
visit(v);
visited[v] = TRUE;
enQueue(Q, v);
while(!queEmpty(Q))
{
deQueue(Q, v);
//检查v的所有邻接点
for(w = firstNeighbor(G, v); w >= 0; w = nextNeighbot(G, v, w))
{
if(!visited[w])
{
visit(w);
visited[w] = TRUE;
enQueue(Q);
}//if
}//for
}//while
}
1.BFS算法的性能分析
-
空间复杂度:无论是邻接表还是邻接矩阵存储方式,BFS算法都需要借助一个辅助队列Q,n个顶点均需入队一次,在最坏情况下,空间复杂度为O(|V|)。
-
时间复杂度:
- 采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次),故时间复杂度为O(|V|),在搜索任一顶点的邻接点时,每条边至少访问一次,故时间复杂度为O(|E|),算法的总时间复杂度为O(|V|+|E|)。
- 采用邻接矩阵存储方式时,查找每个顶点的邻接点所需的时间为O(|V|),故算法总的时间复杂度为O(|V|2)。
2.BFS算法求解单源最短路径问题
使用BFS,可以求解一个非带权图的单源最短路径问题,这是由广度优先搜索总是按照距离由近到远来遍历图中每个顶点的性质决定的。
#define INFINITE 2147483647
#define TRUE 1
#define FALSE 0
void BFSMinDistance(Graph G, int u){
//d[i]表示从u到i的结点的最短路径
int i;
for(i = 0; i < G.vexnum; ++i)
{
d[i] = INFINITE; //初始化路径长度
}
visited[u] = TRUE;
d[u] = 0;
enQueue(Q, u);
while(!queEmpty(Q))
{
deQueue(Q, u);
for(w = firstNeighbor(G, u); w >= 0; w = nextNeighbor(G, u, w))
{
if(!visited[w])
{
visited[w] = TRUE;
d[w] = d[u] + 1;
enQueue(Q, w);
}//if
}//for
}//while
}
3.广度优先生成树
在广度遍历的过程中, 可以得到一棵遍历树,称为广度优先生成树。
- 一给定图的邻接矩阵表示是唯一的,故其广度优先生成树也是唯一的;
- 由于邻接表存储表示不唯一,故其广度优先生成树不唯一。
5.3.2 深度优先搜索(DFS)
深度优先搜索(Depth-First-Search, DFS)类似于树的先序遍历。
#define TRUE 1
#define FALSE 0
bool visited[MAX_VERTEX_NUM];
void DFSTraverse(Graph G){
//对图G进行深度优先遍历,访问函数为visit()
int i;
for(i = 0; i < G.vexnum; ++i)
{
visited[i] = FALSE;
}
for(i = 0; i < G.vexnum; ++i)
{
if(!visited[i])
{
DFS(G, i);
}
}
}
void DFS(Graph G, int v){
//从顶点v出发,采用递归思想,深度优先遍历图G
visit(v);
visited[v] = TRUE;
for(w = firstNeighbor(G, v); w >= 0; w = nextNeighbor(G, v, w))
{
if(!visited[w])
{
DFS(G, w);
}//if
}//for
}
对于同一个图,基于邻接矩阵的遍历所得到的DFS序列和BFS序列是唯一的,基于邻接表的遍历所得到的DFS序列和BFS序列是不唯一的。
1.DFS算法的性能分析
- 空间复杂度:DFS算法是一个递归算法,需要借助一个递归工作栈,故空间复杂度为O(n);
- 时间复杂度:
- 采用邻接表存储方式时,访问所有顶点所需的时间复杂度为O(|V|),查找每个顶点的邻接点所需的时间复杂度为O(|E|),算法的总时间复杂度为O(|V|+|E|)。
- 采用邻接矩阵存储方式时,查找每个顶点的邻接点所需的时间为O(|V|),故算法总的时间复杂度为O(|V|2)。
2.深度优先的生成树和生成森林
仅对连通图调用DFS才能产生深度优先生成树,否则产生的将是深度优先生成森林。
基于邻接表存储的深度优先生成树不唯一。
5.3.3 图的遍历与图的连通性
- 对于无向图来说,若无向图是连通的,则从任一结点出发,仅需一次遍历就能够访问图中所有顶点;若无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点。
- 对于有向图来说,若从初始点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。
- 对于无向图,BFSTraverse()或DFSTraverse()调用BFS(G, i)或DFS(G, i)的次数等于该图的连通分量数
- 对于有向图,因为一个连通的有向图分为强连通的和非强连通的,它的连通子图也分为强连通分量和非强连通分量。
【注意】
- 拓扑排序、深度优先遍历算法可以判断有向图中是否存在回路。
- 在无回路的有向网络中,假设只有一个入度为0的顶点(源点)和一个出度为0的顶点(汇点),则从源点到汇点之间的最长路径称为关键路径。
- 一个无向图G是一棵树的条件是,G必须是无回路的连通图或有n-1条边的连通图。
5.4 图的应用
5.4.1 最小生成树
相对于无权图的生成树,最小生成树(Minimum-Spanning-Tree, MST)xing是针对带权无向图的。它是带权连通无向图G的所有生成树中,边的权值之和最小的那棵生成树。
- 当G中的各边权值互不相等时,G的最小生成树唯一,否则最小生成树的树形不唯一。
- 若无向连通图G的边数比顶点数少1,即G本身是一棵树时,则G的最小生成数是它本身。
- 最小生成树的边的权值之和是唯一的。
- 最小生成树的边数为顶点数减1.
GENERIC_MST(G){
T = NULL;
while(T未形成一棵生成树)
{
找到一条最小代价边(u, v)并且加入T后不会产生回路;
T = T∪(u, v);
}
}
1.Prim算法
Prim算法的执行非常类似于寻找最短路径的Dijkstra算法。
- 在图中挑选任一顶点v0准备扩展;
- 每次扩展一个从未扩展的顶点,要求所连的边必须是已扩展的点连向所有未扩展的点中,边的权值最小的一条。
- 重复步骤2,直至图中所有的顶点都被扩展过。
void Prim(G, T){
T = NULL;
U = {w};
while((V - U) != NULL)
{
找出(u, v)是使 u∈V 与 v∈(V-U),且权值最小的边;
T = T ∪ {(u, v)};
U = U ∪ {v};
}
}
Prim算法的时间复杂度为O(|V|2),不依赖于|E|,因此它使用与求解边稠密的图的最小生成树。
2.Kruskal算法
Kruskal算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。
- 使图中的每个顶点构成一棵独立的树,此时图是仅含顶点的森林。
- 从边集中找出这样一条边:权值最小,且边的两个端点来自不同的树。将这条边加入图中。
- 重复步骤2,直至图中仅含一棵树。
void Kruskal(V, T){
T = V; //初始化树,仅含顶点
numS = n; //连通分量数
while(numS > 1)
{
从E中取出权值最小的边(v, u);
if(v和u属于T中不同的连通分量)
{
T = T ∪ {(u, v)};
--numS;
}
}
}
通常在Kruskal算法中,采用堆来存放边的集合,因此每次选择最小权值的边的时间复杂度为O(log|E|)。又由于生成树T中的所有边可视为一个等价类,因此每次添加新的边的过程类似于求解等价类的过程,可以采用并查集的数据结构来描述T,从而构造T的时间复杂度为O(log|E|)。因此,Kruskal算法适合求解边稀疏的图的最小生成树。
5.4.2 最短路径
- 带权路径长度:从顶点u到顶点v的一条路径(可能不止一条)所经过的边的权值之和。
- 最短路径:带权路径长度最短的那条路径。
1.单源最短路径问题——Dijkstra算法
- dist[]:记录从源点到其他个顶点当前度最短路径长度。
- path[]:path[i]记录从源点到顶点i之间的最短路径的前驱结点。
- s[]:s[i] == 1时表示顶点i的dist已经被确定,即顶点i的最短路径已经找到。
Dijkstra算法文字描述如下:
- 为每个顶点添加属性dist。入度为0的点作为源点,初始时,将源点的dist设为0,其他顶点的dist设置为∞。
- 找出所有未扩展的顶点中dist值最小的点,此时该点的dist值确定为源点到这一点的最短路径长度。
- 从该点出发,若存在有向边指向图中其他没有确定dist值的任意顶点,则判断该有向边的起点dist加有向边的权值是否小于终点的dist。如若小于则更新终点dist值。
- 重复步骤2、3共n-1次,直至所有顶点的dist都被确定。
Dijkstra算法基于贪心策略。算法的主要部分为一个双重循环,外层循环内又两个并列的单层循环,任取一个循环内的操作为基本操作时,基本操作执行的总次数为双重循环执行的次数。
- 使用邻接矩阵表示时,其时间复杂度为O(|V|2)。
- 使用带权的邻接表表示时,虽然修改dist[]的时间可以减少,但由于在dist[]中选择最小分量的时间不变,故其时间复杂度仍为O(|V|2)。
要找出所有结点对之间的最短路径,需要对每个结点运行一次Dijkstra算法,时间复杂度为O(|V|3)。当边上带有负权值时,Dijkstra算法不再适用。
2.任意两点间最短路径问题——Floyd算法
递推产生一个n阶方阵序列A(-1), A(0), …, A(k), …, A(n-1),其中A(k)[i][j]表示从顶点vi到顶点vj的路径长度,k表示绕行第k个顶点的运算步骤。初始时,对于任意两个顶点vi和vj,若它们之间存在边,则以此边上的权值作为它们之间的最短路径长度;若它们之间不存在有向边,则以∞作为它们之间的最短路径长度。以后逐步尝试在原路径中加入顶点k(k = 0, 1, …, n-1)作为中间顶点。若增加中间顶点后,得到的路径比原来的路径长度减少了,则以此新路径代替原路径。
Floyd算法严格描述:
定义一个n阶方阵序列A(-1), A(0), …, A(n-1),其中
A ( − 1 ) [ i ] [ j ] = a r c s [ i ] [ j ] A ( k ) [ i ] [ j ] = M i n A ( k − 1 ) [ i ] [ j ] , A ( k − 1 ) [ i ] [ k ] + A ( k − 1 ) [ k ] [ j ] , k = 0 , 1 , ⋅ ⋅ ⋅ , n − 1 A^{(-1)}[i][j] = arcs[i][j]\\ A^{(k)}[i][j] = Min{A^{(k-1)}[i][j], A^{(k-1)}[i][k] + A^{(k-1)}[k][j], k = 0, 1, ···, n-1} A(−1)[i][j]=arcs[i][j]A(k)[i][j]=MinA(k−1)[i][j],A(k−1)[i][k]+A(k−1)[k][j],k=0,1,⋅⋅⋅,n−1
式中,A(k)[i][j]是从顶点vi到vj、中间顶点序号不大于k的最短路径长度。
Floyd算法的时间复杂度为O(|V|3)。它允许图中带有负权值的边,但不允许有包含带负权值的边组成的回路。Floyd算法同样适用于带权无向图,因为带权无向图可视为有往返二重边的有向图。
//Floyd算法实现
//……准备工作,根据图的信息初始化矩阵A和path
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; //记录中转点
}
}
}
}
BFS算法 | Dijkstra算法 | Floyd算法 | |
---|---|---|---|
无权图 | ✔️ | ✔️ | ✔️ |
带权图 | ❌ | ✔️ | ✔️ |
带负权值的图 | ❌ | ❌ | ✔️ |
带负权回路的图 | ❌ | ❌ | ❌ |
时间复杂度 | O ( ∣ V ∣ 2 ) \text{O}(|V|^2) O(∣V∣2)或 O ( ∣ V ∣ + ∣ E ∣ ) \text{O}(|V|+|E|) O(∣V∣+∣E∣) | O ( ∣ V ∣ 2 ) \text{O}(|V|^2) O(∣V∣2) | O ( ∣ V ∣ 3 ) \text{O}(|V|^3) O(∣V∣3) |
通常用于 | 求无权图的单源最短路径 | 求带权图的单源最短路径 | 求带权图中各顶点间的最短路径 |
有向无环图应用——描述表达式
step1:把各个操作数不重复地排成一排;
step2:标出各个运算符的生效顺序(先后顺序有点出入无所谓);
step3:按顺序加入运算符,注意“分层”;
step4:从底向上逐层检查同层的运算符,看是否可以合并。
5.4.3 拓扑排序
- 有向无环图,简称DAG图。
- AOV网:若用DAG图表示一个工程,其顶点表示活动,用有向边<Vi, Vj>表示活动Vi必须先于活动Vj进行的这样一种关系。则将这种有向图称为顶点表示活动的网络,即AOV网。在AOV网中活动Vi是Vj的直接前驱,Vj是Vi的直接后继,这种前驱和后继关系具有传递性,且活动Vi不能以它自己作为自己的前驱或后继。
- 拓扑排序:有一个DAG图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑序列:
- 每个顶点出现且仅出现一次。
- 若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径
拓扑排序常用算法:
- 从DAG图中选择一个没有前驱的顶点并输出。
- 从图中删除该顶点和所有以它为起点的有向边。
- 重复步骤1、2,直到当前的DAG图为空或当前图中不存在无前驱的顶点为止。后一种情况说明有向图中必然存在环。
bool topoLogicalSort(Graph G){
//若G存在拓扑序列,返回true;否则返回false,这时G中存在环
SqStack S;
initStack(S);
for(int i = 0; i < G.vexnum; ++i)
{
if(indegree[i] == 0)
{
push(S, i);
}
}
int count = 0;
while(!stackEmpty(S))
{
pop(S, i);
print[count++] = i;
for(p = G.vertices[i].firstarc; p = p->nextarc)
{
//将所有i指向顶点的入度减1,并且将入度减为0的顶点压入栈S
v = p->adjvex;
if(!(--indegree[v])) //入度减1,判断顶点入度是否为0
{
push(S, v);
}
}//for
}//while
if(count < G.vexnum)
return false;
else
return true;
}
由于输出每个顶点的同时还要删除以它为起点的边,故拓扑排序的时间复杂度为O(|V| + E)。
- 若一个顶点有多个直接后继,则拓扑排序的结果通常不唯一;若各个顶点已经排在一个线性有序的序列中,每个顶点有唯一的前驱后继关系,再做拓扑排序时,则排序的结果是唯一的。
- 对于一般的图而言,若其邻接矩阵是三角矩阵,则存在拓扑序列;反之若邻接矩阵不是三角矩阵,则不一定存在拓扑序列。
- 拓扑排序、逆拓扑排序序列可能不唯一。
- 若图中有环,则不存在拓扑排序序列、逆拓扑排序序列。
逆拓扑排序的实现(DFS算法)
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(Graph G, int v){ //从顶点v出发,深度优先遍历图G
visited[v] = TRUE; //设已访问标记
for(w=FirstNeighbor(G, v); w>=0; w=NextNeighbor(G, v, w))
if(!visited[w]){ //w为u的尚未访问的邻接顶点
DFS(G, w)
}
print(v); //输出顶点
}
5.4.4 关键路径
在带权有向图中,以顶点表示时间,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),则称这种有向图为用边表示活动的网络,即AOE网。
- 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
- 只有在进入某一顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
在AOE网中,有些活动是可以并行进行的。从源点到汇点的有向路径可能有多条,并且这些路径长度可能不同,完成不同路径上的活动所需的时间虽然不同,但是只有所有路径上的活动都已完成,整个工程才算结束。因此,从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径,把关键路径上的活动称为关键活动。记住一个原则:事件发生需要事件前的活动都结束。
- 事件最早发生时间ve(k):从源点到Vk的最长路径长度。从前往后计算。
- 事件最迟发生时间vl(k):工期 - 从Vk到汇点的最大路径长度。从后往前计算。
- 活动的最早开始时间e(i):弧尾事件的最早发生时间。
- 活动的最迟开始时间l(i):弧头事件的最迟发生时间 - 弧的权值。
- 活动完成的时间余量d(i):活动的最迟开始时间 - 活动的最早开始时间。
求关键路径的算法步骤如下:
- 求AOE网的所有事件的ve(k)。
- 求AOE网的所有事件的vl(k)。
- 求AOE网的所有活动的e(i)。
- 求AOE网的所有活动的l(i)。
- 求AOE网的所有活动的d(i),找出所有d() = 0的活动构成关键路径。
- 若关键活动耗时增加,则整个工程的工期将增长。
- 缩短关键活动的时间,可以缩短整个工程的工期。
- 当关键活动的时间缩短到一定程度时,关键活动可能会变成非关键活动。
- AOE网的关键路径可以不唯一,对于有多条关键路径的网,只有加快所有关键路径上的关键活动才能达到缩短工期的目的。
【注意】
-
无当带权连通图的任意一个环中所包含的边的权值互不相同,或该连通图本身就是一棵树时,其最小生成树唯一。
-
用Floyd算法求两个顶点的最短路径时,当最短路径发生改变时,pathk-1就不是pathk的子集。
-
深度优先遍历和拓扑排序可以判断出一个有向图是否有环。而对于关键路径,求关键路径的第一步就是拓扑排序,求关键路径的算法本身无法判断是否有环。
-
若有向图的顶点不能排在一个拓扑排序中,则表明图中必定存在回路,该回路构成一个强连通分量。
-
在拓扑排序算法,因为入度为零的点前后关系任意,因此暂存入度为零的点既可以使用栈也可以使用队列。
-
拓扑序列唯一不一定都是线性的有向图。
-
对于一个有向图,当某个顶点入度为0时,其他顶点无法到达这个顶点,不可能与其他顶点和边构成连通分量(这个单独的顶点构成一个强连通分量)。通过依次删除图中入度为0的点以及所有以之为尾的弧,判断有向图的强连通分量数目。
-
关键路径是顶点序列。
-
路由器也是一个节点,从路由器向其相邻节点发送数据会消耗1个单位的TTL。
-
判断一个点是否为割点的一个方法是,先把这个点和所有与它相关的边从图中去掉,再用深搜或广搜来判断剩下的图的连通性。