王道408数据结构——第六章 图

一、图的基本概念

图G有定点集V和边集E组成,记为G=(V,E),其中V(G)表示图G中顶点的有限非空集;E(G)表示图G中顶点之间的关系(边)集合。用V|V|表示图G中顶点个数,用E|E|表示图G中边的条数。图不能是空图,最少要有一个顶点。

对于无向图,|E|的取值范围为0到n(n1)/2n(n-1)/2,有n(n1)/2n(n-1)/2条边的无向图称为完全图
对于有向图,|E|的取值范围为0到n(n1)n(n-1),有n(n1)n(n-1)条弧的有向图称为完全图

若图G和图G’顶点相同,E(G’)是E(G)的子集,则成G’是G的生成子图

无向图中,任意两个顶点都是连通的。称为连通图。无向图中的极大连通子图称为连通分量。假设一个图有n个顶点,如果边数小于n-1,其必定是非连通图。

有向图中,如果有一对顶点v、w,从v到w和从w到v之间都有路径,称这两个顶点是强连通的。若图中任何一对顶点都是强连通的,称此图为前连通图。有向图的极大强连通子图称为强连通分量。假设一个图有n个顶点,至少需要n-1条边,构成强连通分量,此时为一个环路。

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

无向图的全部顶点之和等于边数的两倍,;
有向图全部顶点的入度之和与出度之和相等,并且等于边数。有向图顶点的度为入度和出度之和。

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

二、图的储存

邻接矩阵

用一个一维数组储存图中顶点的信息,有一个二维数组(邻接矩阵)储存图中边的信息。
无向图的邻接矩阵是对称矩阵,实际储存时只需储存三角矩阵元素。

邻接矩阵的空间复杂度为O(V2)O(|V|^2)

可以很方便地确定两个顶点之间是否有边相连,但要确定图中有多少条边,必须遍历每个元素。

适合表示稠密图

邻接矩阵储存结构定义如下

1
2
3
4
5
typedef struct{
vertexType vex[MaxVertexNum]; // 顶点表
edgeType edge[MaxVertexNum][MaxVertexNum]; // 邻接矩阵(边表)
int vexNum, edgeNum; // 当前顶点数和边数
}MGraph;

邻接表

对图中每个顶点建立一个单链表(边表、出边表),每个单链表中的结点表示依附与该顶点的边(对于有向图,表示以该顶点为尾的弧)。边表的头指针和顶点的数据采用顺序存储(顶点表)。

对于无向图,所需储存空间为O(2E+V)O(2|E|+|V|)
对于有向图,所需存储空间为O(E+V)O(|E|+|V|)

可以很方便找到一个顶点的所有临边。
在有向图中,求顶点的出度只需计算邻接表的结点个数,但求入度需要遍历所有邻接表。

适合表示稀疏图,能极大节省存储空间。

邻接表储存结构定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct{  // 顶点结点
vertexType data;
ArcNode *first; // 指向第一个依附该顶点的弧
}VerNode, VerList[MaxVertexNum];

struct ArcNode{ // 边结点
int vertex; // 该边指向的顶点序号
edgeType data;
struct ArcNode *next; // 指向下一个边
}

typedef struct{
VerList VerList; // 邻接表
int vexNum, edgeNum;
}AlGraph;

十字链表

是有向图的一种链式储存结构。每条弧和每个顶点都有一个结点表示。顶点结点顺序存储。

弧结点结构 tailvex headves hlink tlink info
作用 尾域,标识弧尾顶点 头域,标识弧头顶点 指向弧头相同的下一条弧 指向弧尾相同的下一条弧 携带弧的相关信息
顶点结点结构 data firstin firstout
作用 存放顶底数据信息 指向以该顶点为弧头的第一个弧结点 指向以该顶点为弧尾的第一个弧结点
在这里插入图片描述
在十字链表中,既容易找到以一个顶点为尾的弧,又容易找到以一个顶点尾头的弧,因而容易求得入度和出度。

邻接多重表

是无向图的一种链式存储结构。每条边和每个顶点也各用一个结点表示。

顶点结点结构 mark ivex ilink jvex jlink info
作用 标志域,记录该边是否被搜索过 标识边依附的第一个顶点 指向下一条依附ivex的边 标识边依附的另一个顶点 指向下一条依附jvex的边 携带相关信息
在这里插入图片描述
在邻接多重表中,所有依附域同一顶点的边串联在同一链表中。由于每条边依附于两个顶点,所以每个边结点同时链接在两个链表中。
对于无向图,其邻接多重表和邻接表的差别仅在于,同一条表在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。

三、图的基本操作

  • adjacent(G, x, y):判断图G中是否存在边<x, y>
  • neighbor(G, x):列出图G中于顶点x相邻的边
  • insertVertex(G, x):在图中插入顶点x
  • deleteVertex(G, x):在图中删除顶点x
  • addEdge(G, x, y):在图中添加边<x, y>
  • removeEdge(G, x, y):在图中删除边<x, y>
  • firstNerghbor(G, x):求顶点x的第一个邻接点
  • nextNerghbor(G, x, y):返回除y以外的顶点x的下一个邻接点
  • getEdgeValue(G, x, y)
  • setEdgeValue(G, x, y, v)

四、图的遍历

在遍历图的过程中,必须记下每个已访问过的顶点。

广度优先搜索(BFS)

类似树的层序遍历,广度优先搜索是一个分层的查找过程,没有回退的过程,必须借助一个辅助队列,记忆正在访问的顶点的下一层顶点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int visited[MAX_VERTEX_NUM];

void BFSTraverse(Graph G){
for(int i=0; i<G.vexNum; i++)
visited[i] = 0;
for(int i=0; i<G.vexNum; i++)
if( !visit[i] )
BFS(G, i);
}

void BFS(Graph G, int v){
initQueue(Q);
visit(v);
visited[v] = 1;
enQueue(Q, v);
while( !isEmpty(Q) ){
deQueue(Q, v);
for(w=firstNeighbor(G, v); w>=0; w=nextNeighbor(G, v, w)){
if(!visited[w]){
visit(w);
visited[w] = 1;
enQueue(Q, w);
}
}
}

}

无论采用邻接表还是邻接矩阵,BFS都需要借助一个辅助队列,所有顶点均需入队一次。最坏情况下,空间复杂度为O(V)O(|V|)
采用邻接表存储时,每个顶点均需被搜索依次,时间复杂度为O(V)O(|V|);在搜索任一顶点的边时,每一条边至少访问依次,总时间复杂度为O(E)O(|E|)。算法总的时间复杂度为O(V+E)O(|V|+|E|)
采用邻接矩阵存储时,查找每个顶点的所有邻接点的时间为O(V)O(V),算法总的时间复杂度为O(V2)O(|V|^2)

借助BFS,可以求解非带权图的单源最短路径问题。

在广度遍历中,可以得到一棵遍历树,称为广度优先生成树。邻接矩阵产生的生成树是唯一的,而邻接表产生的生成树不唯一。

深度优先搜索(DFS)

类似树的先序遍历,先尽可能“深”的搜索一个图,当不能继续向下访问时,依次退回最近被访问的顶点,若其还有未被访问的邻接顶点,则从这个邻接顶点继续该搜索过程。

DFS是一个递归的过程,需要借助一个递归工作栈,空间复杂度为O(V)O(|V|)
采用邻接表存储时,访问所有顶点的时间复杂度为O(V)O(|V|),查找所有顶点的邻接点的总时间复杂度为O(E)O(|E|),故算法总的时间复杂度为O(V+E)O(|V|+|E|)
采用邻接表存储是,查找一个顶点的所有邻接点所需时间为O(|V|),故总的时间复杂度为O(V2)O(|V|^2)

借助DFS,可以求解有向无环图的拓扑排序问题。

在深度遍历中,可以得到一棵遍历树,称为深度优先生成树。邻接矩阵产生的生成树是唯一的,而邻接表产生的生成树不唯一。

图的遍历和图的连通性

对于无向图,若图是连通的,则从任一顶点出发,仅需一次遍历就可以访问图中的所有顶点。
对于有向图,若从初始顶点到图中每个顶点都有路径,则能访问到图中的所有顶点。一个有向图的连通子图分为强连通分量和非强连通分量,非强连通分量调用一次搜索过程无法访问到所有顶点。

五、最小生成树

最小生成树具有如下特征:

  • 最小生成树不是唯一的。但图中各边的权值互不相等是,生成树唯一。
  • 若无向连通图的边数比顶点数少1,它本身就是其的最小生成树。
  • 最小生成树边的权值之和总是唯一的,且是最小的。

Prim算法

初始时从图中任取一顶点加入树T,此时树中只含有一个顶点。之后选择一个与T中顶点距离最近的顶点,将顶点和相应的边加入T中。每次操作,T中的顶点数和边数都增加1,直到所有顶点都加入T。
prim算法基于贪心策略,其简单实现如下

1
2
3
4
5
6
7
8
9
10
def prim(G):
T = set() # 初始化空边集
U = {w} # 初始化顶点集,添加任一顶点w
# 若树中未包含全部顶点,选择一个加入
while not (V - U):
# (u,v)是u∈U,v∈(V-U)且权值最小的边
# 此处最坏情况需要遍历所有n个顶点,时间复杂度为O(n)
find a edge (u, v)
U.add(v) # 将选定的点加入顶点集
T.add((u, v)) # 将选定的边加入边集

算法的时间复杂度为O(V2)O(|V|^2),适合求解稠密图的最小生成树。

Kruskal算法

初始时只有n个顶点而无边的非连通图T={ V,{ } },每个顶点自成一个连通分量。然后按边权值从小到大的顺序,查看当前为被选取且权值最小的边,若该边依附的两个顶点落在T中不同的连通分量中,则将此边加入T。
kruskal算法基于贪心策略,其简单实现如下

1
2
3
4
5
6
7
8
9
def kruskal(G):
T = V # 初始化树,仅含所有顶点
numS = n # 连通分量数
while numS > 1:
# 从边集中找到权值最小的边
pop a edge with shortest length (u, v)
if (u ,v) belong to different connected components:
T.add((u, v)) # 将此边加入生成树中
numS = numS - 1

通常在kruskal算法中,采用堆来存放边的集合,每次选择边只需O(logE)O(\log |E|)的时间。此外,由于生成树T中的所有边可视为一个等价类,每次添加新边的过程类似求解等价类的过程,可以采用并查集的数据结构来描述T,构造T总的时间复杂度为O(ElogE)O(|E|\log |E|)。适合求解稀疏图的最小生成树。

六、最短路径

把带权路径长度最短的那条路径称为最短路径
重要性质:两点之间的最短路径也包含了路径上其他点间的最短路径。

Dijkstra求单源最短路径

设置一个集合S,记录已求得的最短路径的顶点。初始时把源点v0v_0放入SS。集合S每并入一个新顶点viv_i,都要修改v0v_0到集合VSV-S中顶点的当前最短路径长度。
设置两个辅助数组:

  • dist[]:记录从源点v0v_0到其他各顶点当前的最短路径长度。它的初态为:若从v0v_0viv_i有弧,则dist[i]设为弧上权值,否则置为\infty
  • path[]:path[i]表示从源点到顶点i最短路径的前驱结点。在算法结束时,可根据其值追溯到源点的最短路径。

假设从顶点0出发,即v0=0v_0=0,集合SS最初只包含顶点0,邻接矩阵arcs表示带权有向图,arcs[i][j]表示有向边<i, j>的权值。若不存在有向边,arcs[i][j]置为\infty

Dijkstra算法基于贪心策略,步骤如下:

  1. 初始化:集合S初始为{0},dist[]初始值为dist[i] = arcs[0][i]。
  2. 从顶点集合VSV-S中选出vjv_jvjv_j是集合VSV-S中到v0v_0距离最短的顶点,即满足dist[j]=min{dist[i]viVS}dist[j] =min \{dist[i]\mid v_i\in V-S\},此时 vjv_j就是当前求得的一条从v0v_0出发的最短路径的终点。再令S=S{j}S=S\cup\{j\}
  3. 修改从v0v_0到集合VSV-S上所有顶点vkv_k可达的最短路径长度:
    若dist[j] + arcs[j][k] <dist[k], 更新dist[k] = dist[j] + arcs[j][k]。
  4. 重复二、三步n-1次,直到所有顶点都包含在S中。

Djkstra算法并不适用于带负权值的有向图

算法时间复杂度为O(V2)O(|V|^2)

Floyd算法求解各顶点间的最短路径问题

递推产生一个n阶方阵序列A(1),A(0),...,A(k),...,A(n1)A^{(-1)}, A^{(0)},...,A^{(k)},...,A^{(n-1)},其中A(k)[i][j]A^{(k)}[i][j]表示从顶点viv_ivjv_j的路径长度,k表示绕行第k个顶点的运算步骤。
初始时,对于任意两个顶点,若它们之间存在弧,则以此边上的权值作为它们之间的最短路径长度;若它们不存在有向边,则置为\infty
以后逐步尝试在原路径中加入顶点k作为中间顶点,若增加中间顶点后,它们之间的路径长度比原来的路径减少了,则以此新路径代替原路径。

算法描述如下:
定义一个n阶反正序列A(1),A(0),...,A(n1)A^{(-1)}, A^{(0)},...,A^{(n-1)},其中

  • A(1)[i][j]=arcs[i][j]A^{(-1)}[i][j]=arcs[i][j]
  • A(k)=min{A(k1)[i][j],A(k1)[i][k]+A(k1)[k][j]}A^{(k)}=\min\{A^{(k-1)}[i][j],A^{(k-1)}[i][k]+A^{(k-1)}[k][j]\}

Floyd算法是一个迭代的过程,每迭代一次,在从viv_ivjv_j的最短路径上就多考虑一个顶点。经过n次迭代后,所得到的A(n1)[i][k]A^{(n-1)}[i][k]就是viv_ivjv_j的最短路径,方阵A(n1)A^{(n-1)}保存了任意一对顶点之间的最短路径长度。

Floyd算法允许图中有带负权值的边,但不允许有包含带负权值边的回路。
Floyd算法同样适合带权无向图,因为无向图可视为权值相同往返二重边的有向图。

算法的时间复杂度为O(V3O(|V|^3),但对于中等规模的输入,仍然有效。

七、有向无环图(DAG图)描述表达式

DAG图是描述含有公共子式的表达式的有效工具。
用二叉树描述表达式时,相同的子式会重复出现,使用DAG图可以对相同子式的共享,从而节省存储空间。
在这里插入图片描述

八、拓扑排序

AOV网:若用DAG图表示一个工程,其顶点表示活动,用有向边<vi,vj><v_i,v_j>表示活动ViV_i必须先于ViV_i进行,则间这种有向图称为顶点表示活动的网络,记为AOV网。
拓扑排序:由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时:

  1. 每个顶点出现且仅出现一次;
  2. 若顶点A在序列中排在B的前面,则在图中不存在从B到A的路径。

称这个序列为该图的一个拓扑排序。每个AOV网都有一个或多个拓扑排序序列。

拓扑排序常用算法如下

  1. 从AOV网中选择一个没有前驱的顶点并输出;
  2. 从网中删除该顶点和以该顶点为起点的所有边;
  3. 重复前面两部直到AOV网为空或网中不存在无前驱的顶点位置。后一种情况说明有向图中存在环

由于输出每个顶点的同时还有删除以它为起点的边,故拓扑排序的时间复杂度为O(V+E)O(|V|+|E|)

由于AOC网中各顶点地位平等,每个顶点的编号都是人为的,因此可以按拓扑排序的结果重新编号,生成AOV网的新的邻接存储矩阵,这种邻接矩阵可以时三角矩阵。对于一个图,若其邻接矩阵是三角矩阵,其必存在拓扑排序。

九、关键路径

AOE网:在带权有向无环图中,若以顶点表示事件,以有向边表示活动,以边上的权值代表活动的开销,则称其为用边表示活动的图,记为AOE网。
在AOE网中仅有一个入度为0的点,称为开始顶点(源点),表示整个工程的开始;仅有一个出度为0的点,称为结束顶点(汇点),表示整个工程的结束。
从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径,它决定了完成整个工程的最短时间。关键路径上的活动称为关键活动

  • 事件最早发生时间ve(k)ve(k)
    指从源点viv_i到顶点vkv_k的最长路径长度,决定了所有从vkv_k开始的活动能够开工的最早时间。可以用下面的递推公式计算:

{ve(源点)=0ve(k)=max{ve(j)+weight(vj,vk)},vkvj的任意后继\left\{ \begin{array}{l} ve(源点)=0\\ ve(k)=\max\{{ve(j)}+weight(v_j,v_k)\},v_k为v_j的任意后继 \end{array} \right.

计算ve()ve()时,按从前往后的顺序进行,可以在拓扑排序的基础上进行。

  • 事件最迟发生时间vl(k)vl(k)
    指在不推迟整个工程完成的前提下(即保证它的后继事件在其最迟发生时间内能够发生),该事件最迟必须发生时间。可以用下面的递推公式计算:

{vl(汇点)=ve(汇点)vl(k)=min{vl(j)weight(vk,vj)}vkvj的任意前驱 \left\{ \begin{array}{l} vl(汇点)=ve(汇点) \\ vl(k)=\min\{vl(j)-weight(v_k,v_j)\},v_k为v_j的任意前驱 \end{array} \right.

在计算vl()vl()时,按从后往前的顺序进行,可以在逆拓扑排序的基础上计算(可以在计算ve()时设置一个栈记录拓扑序列,拓扑排序结束后从栈顶至栈底变为逆拓扑排序序列)

  • 活动最早开始时间e(i)e(i)
    指活动弧的起点所表示的事件的最早发生时间。若<vk,vj><v_k,v_j>表示活动aia_i,则有e(i)=ve(k)e(i)=ve(k)
  • 活动最迟开始时间l(i)l(i)
    指活动弧的终点所表示的事件的最迟发生事件与该活动所需时间的差值。若<vk,vj><v_k,v_j>表示活动aia_i,则有l(i)=vl(j)weight(vk,vj)l(i)=vl(j)-weight(v_k,v_j)
  • 活动最早开始时间和最迟开始时间的差额d(i)d(i)
    指该活动完成的时间余量,即在不增加整个工程所需总时间的情况下,活动aia_i可以拖延的时间。d(i)=l(i)e(i)d(i)=l(i)-e(i)
    若一个活动的时间余量为0,即l(i)=e(i)l(i)=e(i),说明该活动必须要如期完成,否则就会拖延整个工程的进度,称其为关键活动。

求解关键路径的算法步骤如下:

  1. 从源点出发,令ve(源点)=0ve(源点)=0,按拓扑有序求其余顶点的最早发生时间;
  2. 从汇点出发,令le(汇点)=ve(汇点)le(汇点)=ve(汇点),按逆拓扑有序求其余顶点的最迟发生时间;
  3. 根据各顶点的ve()ve()值求所有弧的最早开始时间;
  4. 根据各顶点的vl()vl()值求所有弧的最迟开始时间;
  5. 求AOE网中所有活动的差额,找出所有d()=0d()=0的活动,构成关键路径。

关键路径上的所有活动都是关键活动,可以通过加快关键活动来缩短整个工程的工期。但不能任意缩短关键活动,因为一旦缩短到一定程度,关键活动就可能会变成非关键活动。
AOE网中的关键路径不是唯一的,对于有多条关键路径的AOE网,只提高一条关键路径上的关键活动速度并不能缩短整个工期。

可以判断一个有向图是否有环的算法:

  • 深度优先遍历
  • 拓扑排序
  • 求关键路径(预先要使用拓扑排序判断是否有环)

王道408数据结构——第六章 图
https://buttering.github.io/EasyBlog/2021/09/27/王道408数据结构——第六章 图/
作者
Buttering
发布于
2021年9月27日
许可协议