一、图的定义
图G由顶点集V和边集合E组成,|V|表示顶点个数,又称为图G的阶,|E|表示图G的边的条数
线性表可以是空表,树可以是空树,但是图不可为空,也就是V一定是非空集合,但是E可以是空集合
相关概念:
- 度:顶点的度表示依附于该顶点的边数,对于有向图又分为入度和出度
- 路径:顶点到顶点之间一条路径经过的顶点序列,定点之间可能不存在路径
- 回路/环:第一个顶点和最后一个顶点相同的路径称为回路
- 简单路径:途径顶点不重复出现的路径
- 简单回路
- 路径长度:路径上边的数目
- 点到点距离:从定点u出发到顶点v的最短路径,如果不存在则路径距离为无穷
- 连通:如果两个顶点之间有路径存在,则称两个顶点连通,如果在有向图中,两个顶点是双向连通的,则称两个顶点是强联通的
- 连通图:如果图中任意两个顶点都是联通的,则图为连通图。如果一个n个顶点的图是连通图,那么最少有n-1条边。如果是非连通图,则最多可能有
C
n
−
1
2
C^2_{n-1}
Cn−12条边
- 强连通图:如果图中任何一对顶点是强联通的,则图为强连通图。对于n个定点的有向图,如果是强连通图,则最少有n条边。
- 子图:设两个图G(V,E)和G’(V’,E’),如果V’是V的子图,E’是E的子图,则称G’是G的子图
- 连通分量:一个连通分量是图中的子图,并且是极大连通子图,也就是要包括尽可能多的边和顶点
- 强连通分量:有向图中的极大强联通子图
- 生成树/极小连通子图:包含图中全部顶点的极小连通子图
- 生成森林:在非连通图中,连通分量的生成树构成的非连通图为生成森林
图的节点数量相关规律:
- n个节点的连通图至少有n-1个边,此时增加一条边会成环,减少一条边会不连通。如果是非连通图,则最多可能有 C n − 1 2 C^2_{n-1} Cn−12条边。
- n个结点组成的图可能有0到n(n-1)/2个边,有n(n-1)/2个边的无向图称为完全图,此时每个节点的度为n-1。有向图边数范围为0到n(n-1),而有n(n-1)条弧的有向图称为有向完全图,此时每个节点的出度和入度均为n-1。
- 图的总度数是边数的2倍,因为一条边连接两个顶点。
- 对于连通无向图,边最少的情况是一棵树的情形,为n-1条;对于连通有向图,边最少的情况是成为一个有向环的情况,边数为n
二、图的分类
1.无向图和有向图
如果E是无向边的有限集合的话,则图G为无向图,边是顶点的无序对,记作(v,w),其中v和w是两个定点
如果E是有向边的有限集合时,图G为有向图,此时的边又称为弧,弧是顶点的有序对,记作<v,w>,其中v称为弧尾,w称为弧头,也称为v邻接于w
2.简单图、多重图
简单图:不存在重复的边,也不存在指向自身的边
多重图:无向图某两个结点之间的边数多于1条,或有向图某两个结点之间的边数多于2条,并且允许边指向自身结点
3. 带权图
图中每一条边都有对应的权值,这种图称之为带权图,又称为网。
带权路径长度是一条路径上所有边的权值之和。
4.无向完全图/有向完全图
无向完全图:无向图中任意两个顶点之间都存在边
有向完全图:有向图中任意两个顶点之间存在方向相反的两条弧
5.树
不存在回路并且连通的无向图。n个顶点的树必有n-1条边,如果边数大于n-1则一定有回路
有向树种,一个顶点的入度为0,其余顶点的入度均为1
三、图的存储
1.邻接矩阵法
存储图
一个n x n的二维矩阵,其中第i行第j列为1表示第i个顶点到第j个顶点有弧
typedef struct {
char Vex[MaxVertexNum]; // 顶点表
int Edge[MaxVertexNum][MaxVertexNum]; // 邻接矩阵
int vexnum, arcnum; //图的顶点数和边数
}MGraph;
在有向图中:
第i个结点的出度=第i行的非零元素的个数
第i个结点的入度=第i列的非零元素的个数
第i个结点的度=第i行、第i列的非零元素的个数之和
存储带权图
第i行第j列的值为无穷,表示第i个顶点到第j个顶点没有弧
第i行第j列的值为常数k,表示第i个顶点到第j个顶点有弧,并且权重为k
顶点到顶点自身的值是0,因此第i行第i列的值为0
性能分析
空间复杂度O(|V|2),只和顶点数相关,和边数无关,因此适合存储稠密图,存储稀疏图会导致空间浪费。对于无向图可以采用矩阵压缩
假设图G的邻接矩阵为A,矩阵元素为0/1。则An的元素
A
n
[
i
]
[
j
]
A^n[i][j]
An[i][j]表示的是由顶点i到顶点j的长度为n的路径的数目
a
1
,
2
=
1
a_{1,2}=1
a1,2=1表示第一个节点到第二个节点之间有通路,
a
2
,
4
=
1
a_{2,4}=1
a2,4=1表示第二个节点到第四个节点之间有通路,因此
a
1
,
2
⋅
a
2
,
4
=
1
a_{1,2}\cdot a_{2,4}=1
a1,2⋅a2,4=1表示的是从第一个节点经过第二个节点到第四个节点有路径。
2.邻接表法
顺序存储+链式存储存储图
邻接表代码
// 图的邻接表法
// 边/弧的数据结构
typedef struct ArcNode{
int adjvex; //边/弧指向哪个节点
struct ArcNode *next; //指向下一条弧的指针
}ArcNode;
// 顶点数据结构
typedef struct VNode{
int data; // 顶点信息
ArcNode *first; // 第一条边/弧
}VNode, AdjList[MaxVertexNum];
// 用邻接表存储的图
typedef struct{
AdjList vertices; // 顶点列表
int vexnum, arcnum; //图的顶点数和边数
}ALGraph;
无向图的邻接表中,边节点的数量是2|E|,整体空间复杂度为O(|V|+2|E|)
有向图的邻接表中,边节点的数量是|E|,整体空间复杂度为O(|V|+|E|)
在有向图邻接表中,想要找到出度很容易,但是想要找入度,就只能遍历寻找
3.十字链表
邻接表和邻接矩阵都有他对应的缺陷,十字链表法则优化了两者的缺陷,需要注意的是,十字链表仅用于有向图
十字链表及其结点的结构
十字链表的代码实现
性能
空间复杂度为O(|E|+|V|),并且可以很方便地找到入边和出边
4. 邻接多重表
用邻接表存储无向图的主要问题是,每条边对应两份冗余信息,占用了空间,并且删除顶点、删除边等操作时间复杂度高。
优点是空间复杂度很优秀,并且删除边和删除节点的操作很方便
三、图的基本操作
1.Adjacent(G,x,y):判断是否存在边<x,y>或者(x,y)
邻接矩阵:直接查找邻接矩阵
a
[
i
]
[
j
]
a[i][j]
a[i][j]的值,时间复杂度为O(1)
邻接表:遍历查找,最好为O(1),最坏情况O(n)
2.Neighbors(G,x):列出图G和结点x临接的边
在无向图中
邻接矩阵:遍历第x行或者第x列,时间复杂度O(|V|)
邻接表:直接找到对应顶点,遍历其对应的边链表,时间复杂度O(1)~O(|V|)
在有向图中:
邻接矩阵:查找出度或入度时间为O(|V|)
邻接表:找出边则直接找到对应顶点,遍历其对应的边链表,时间复杂度O(1)~O(|V|)。找入边需要遍历邻接表,时间复杂度O(|E|)
InsertVertex(G,x):插入顶点x
邻接矩阵:O(1)
邻接表:O(1)
DeleteVertex(G,x)
邻接矩阵:将x对应的行和列都清零,并且将x节点的有效性标记为false(需要添加数据结构)
邻接表:
无向图中,删除表中对应顶点,释放该顶点的边链表,并且遍历其他顶点的边链表,将含有x的边结点删除,时间复杂度O(1)~O(|E|)
有向图中,删除出边时间复杂度O(1)~O(|E|),删除入边为O(|E|)
AddEdge(G,x,y):在x和y之间新增一条边
邻接矩阵:O(1)
邻接表:找到x和y的边链表,进行头插法或者尾插法插入新的边结点
FirstNeighor(G,x):第一个与x相连的顶点
邻接矩阵:O(1)~O(|V|)
邻接表:
无向图:O(1)
有向图中,找出边是O(1),找入边是O(1)~O(|E|)
NextNeighbor(G,x,y):和x相连的,除了y之外的下一个顶点
邻接矩阵:O(1)~O(|V|)
邻接表:O(1)
四、图的遍历
1.广度优先遍历
代码实现
bool visited[MaxVertexNum] //访问标记数组,默认全为false
// 从顶点v出发,广度优先遍历图G
void BFS(MGraph G, int v){
visit(v); // 访问结点v
visited[v]= true; // 标记当前节点已访问
Enqueue(Q,v) // 当前节点进入队列Q的队尾
while (!isEmpty(Q)){ // 如果队列非空则一直循环
DeQueue(Q,v);
// 遍历v所有临接顶点
for (w=FirstNeighbor(G,v); w>=0; w=NextNeighbor(G,v,w)) {
if(!visited[w]){ //如果该顶点未被访问
visit[w];
visited[w]=true;
EnQueue(Q,w);
}
}
}
}
用邻接矩阵实现的广度优先遍历的过程是确定的,但是由于邻接表表现方式的不一样,因此邻接表实现的广度优先遍历的过程是不确定的
该代码无法访问非连通的结点,因此需要额外添加一段代码:
void BFSTraverse(MGraph G){
for (int i = 0; i < G.vexnum; ++i)
visited[i]= false;
InitQueue(Q);
for (int i = 0; i < G.vexnum; ++i) {
if (!visited[i])
BFS(G,i);
}
}
该段代码的意义在于遍历visited数组,检查经过BFS后是否仍有顶点未被访问。如果有,则在未被访问的顶点处再开始一次BFS
性能
空间复杂度:主要的空间消耗为辅助队列,需要O(|V|)
时间复杂度:主要来自于访问定点和探索各条边的时间。
- 如果使用邻接矩阵,那么查找每个顶点的临接点需要O(|V|)的 时间,而总共有|V|个点,则时间复杂度为O(|V|2)
- 如果使用邻接表存储图,访问|V|个顶点需要O(|V|)的时间,查找各个顶点的临接电需要O(|E|)时间,时间复杂度为O(|V|+|E|)
广度优先生成树
广度优先搜索的路径可以将图化作为一棵树,这种树又称为广度优先生成树。对于有若干个连通分量的图,会有数棵广度优先生成树,因此这种又称为广度优先森林
对于有向图来说,其有弧连接的顶点不一定是强连通的,比如下图
从1号顶点到8号顶点是没有路径的,因此从1号顶点开始的BFS不能通过一次执行就遍历整个图。也就是说,如果有向图中各个顶点之间是非强连通的,那么也会需要执行多次BFS,存在多颗广度优先生成树
2.深度优先遍历
代码实现
bool visited[MaxVertexNum] //访问标记数组,默认全为false
void DFS(MGraph G, int v){
visit(v);
visited[v] = true;
for (w=FirstNeighbor(G,v); w >=0; w=NextNeighbor(G,v,w)){
if (!visited[v]){
DFS(G,w);
}
}
}
如果是非联通图,则无法通过一次DFS遍历所有结点
性能
空间复杂度:
主要开销来自于函数调用栈,最坏情况为O(|V|),最好情况为O(1)
时间复杂度:和BFS一样
邻接表的形式不唯一,因此其深度搜索序列也不唯一
深度优先生成树
对于有向图来说,如果非强连通图,那么需要执行多次DFS,会有多棵生成树
缺乏内容:图的拓扑序列和横向对比各个算法时间复杂度🚧
五、图的应用
1.最小生成树
在一个带权的连通无向图G=(V,E)中,有多个生成树,如果某个生成树T中边的权值之和最小,那么T为G的最小生成树
- 最小生成树可能有多个
- 最小生成树的边数=顶点数-1,也就是砍掉任意一条边都会不连通,增加任意一条都会出现回路
普利姆(Prim)算法
普里姆算法执行类似于寻找图的最短历经的迪科斯特拉算法。
思想如下:
初始的时候从图中任取任意节点加入树T,之后选择一个与当前树T中顶点集合距离最近的任意顶点,并且该顶点加入T。
需要一个isJoin数组标记各个顶点是否已经加入树;需要一个lowCost数组标记当前数到各个节点的最小代价。每加入一个新顶点,lowCost中的值都可能发生改变,因此新的顶点加入后,需要遍历更新lowCost。
每一轮的时间复杂度为O(2n),总共需要n-1轮处理,因此时间复杂度为O(|V|2),适用于边稠密图
克鲁斯卡尔(Krushal)算法
克鲁斯卡尔算法是一种按权值的递增次序选择合适边构造最小生成树的方法。初始的时候为只有n个顶点的无边非连通图,每个顶点是一个连通分量,然后按照边的权值由小到大的顺序,不断选取当前未被选取过的而且权值最小的边,并且要求边连接的两个节点是未连通的。以此类推,直到图中所有顶点都在一个连通分量上。
在结构上,会创建一个并查集,并且按照权值从小到大排序。一个数据单元中存放有三个元素:顶点V1、顶点V2和这两个顶点之间的边的权值W,执行时只需要从小到大遍历,如果顶点V1、顶点V2未连通则将他们选中,遍历完成即可。
在该算法中,通常采用堆来存放边集合,因此每次选择最小权值边只需要O(|E|log|E|)。因此,该算法适用于边稀疏但顶点多的图。
2.最短路径
(1)单源最短路径
BFS算法
求无带权图单源最短路径
// 广度优先求最短路径
// 从顶点v出发,广度优先遍历图G
int d[MaxVertexNum]; //路径长度数组
int path[MaxVertexNum]; //上一个经过的结点是谁
void BFS_1(MGraph G, int v){
for (int i = 0; i < G.vexnum; ++i) {
d[i]= INT16_MAX;
path[i]=-1;
}
d[v]=0;
visit(v); // 访问结点v
visited[v]= true; // 标记当前节点已访问
Enqueue(Q,v) // 当前节点进入队列Q的队尾
while (!isEmpty(Q)){ // 如果队列非空则一直循环
DeQueue(Q,v);
// 遍历v所有临接顶点
for (w=FirstNeighbor(G,v); w>=0; w=NextNeighbor(G,v,w)) {
if(!visited[w]){ //如果该顶点未被访问
d[w] = d[v]+1;
path[w]=v;
visit[w];
visited[w]=true;
EnQueue(Q,w);
}
}
}
}
迪杰斯特拉(Dijkstra)算法
BFS不适合带权图
Dijkstra需要三个数组:
final数组表示是否已经找到最短路径
dist数组表示最短路径长度
path数组用于记录路径上的直接前驱
每一轮都遍历所有顶点,找到还没确定最短路径,并且dist最小的顶点Vi,令final[i]=true,表示到达该顶点的最短路径已经找到。
然后检查所有临接顶点Vi,如果其final值为false,并且途径顶点Vi的路径长度更小的话,则更新dist和path的信息。
可以看出,每一轮处理就可以确定一个顶点的最短路径,需要执行n轮,每轮遍历n个结点,因此时间复杂度为O(|V|2)
但是迪杰斯特拉算法无法处理有带负权的图
弗洛伊德(Floyd)算法
使用动态规划思想求解每一对顶点对之间的最短路径。
该算法较为复杂,在这只是简单叙述,更详细的请查看其他内容。
该算法可以得出任意两点之间的最短路径,也支持解决带负权值的图,但是不支持带负权回路的图
在该算法中,需要两个矩阵:
矩阵A存放的是当前状态下各个顶点之间的最短路径,矩阵path存放的是两个顶点之间的中转点
初始数据结构设计完毕后,分别按以下步骤执行:
初始状态:不允许在其他顶点中转,最短路径是?
状态0:如果允许在V0中转,最短路径是?
状态1:允许在V0, V1为中转点,最短路径是?…
继续上述操作直到状态n
可以看出,在初始状态时,A中存放的顶点路径是不经过任何其他顶点中转的最短路径
然后在此状态上,可设如果经过0号顶点中转的最短路径分别是多少,经过计算后,A中的值为经过0号顶点中转的最短路径
继续执行,则在经过0号顶点中转的最短路径的情况下,经过1号顶点的中转的最短路径是多少,以此类推。
代码实现
void Floyd(MGraph G){
int n = G.vexnum;
int[G.vexnum][G.vexnum] A;
int[G.vexnum][G.vexnum] path;
for (int k = 0; k < n; ++k) {
for (int i = 0; i < ; ++i) {
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(|V|3),因为算法中有3层关于顶点的for循环
空间复杂度:O(|V|2),因为需要2个n*n的矩阵作为辅助数据结构
总结
3.有向无环图
有向无环图,又称为DAG图,该有向图中不存在环。DAG可以将运算中需要重复运算的部分进行合并,比如将下图式子
合并为如下图
在最简的有向无环图中(也就是结点最少的),顶点是不会出现重复的操作数的。有关于求一条式子的结点最少的有向无环图的方法,由于比较复杂在此不作叙述,请自行查找其他资料。
拓扑排序
AOV网是一种由DAG图表示的一个工程,用于表示工程的各个活动的先后顺序,有向边 < V i , V j > <V_i,V_j> <Vi,Vj>表示活动i必须先于活动j进行,一个顶点表示活动,AOV网不允许环。
用日常的例子来说,AOV网中进行拓扑排序就是用于找到做事的先后排序的。在图论中,拓扑排序既可以定义为一个DAG图的顶点组成的序列,也可以定义为对DAG图的一种排序
如果网中不存在无前驱的顶点,那么证明原图有回路。 同时,拓扑/逆向拓扑的序列都是不唯一的。
代码实现
拓扑排序的时间复杂度为O(|V|+|E|),因为需要对每条边和每个顶点都处理一次。如果采用邻接矩阵,那么需要的时间复杂度为O(|V|2)。该算法也可以用于判断图是否有环
逆拓扑排序:
删除的是没有后继的结点,也就是删除出度为0的顶点,并且输出。
逆邻接表:和顶点相连的边链表中存储的是指向顶点的边。
拓扑排序/逆拓扑都可以使用DFS算法来进行实现。在DFS中,在递归函数体中的for循环执行结束后print(v),就可以打印出逆拓扑序列;在for循环之前执行print(v),则可以打印出拓扑排序。
关键路径
AOE网是以顶点表示时间,以有向边表示活动,以边上权值表示完成该活动的开销的一个带权有效图。
性质:
- 只有在某个顶点所代表的事件发生后,从该顶点出发的各条有向边的时间才可以开始执行
- 只有在进入某顶点的各个有向边代表的活动都结束之后,该顶点所代表的时间才可以发生
AOE网中仅有一个入度为0的顶点,称之为开始顶点,又称为源点,是整个工程的开始
也仅有一个出度为0的顶点,称之为结束定点,又称为汇点,他表示整个工程的结束。
从源点到汇点的有向路径可能有多条,所有路径中,最大路径长度的路径称为关键路径,而关键路径上的活动称之为关键活动。完成整个工程的最短时间是关键路径活动,如果关键活动不能按时完成,那么整个工程的完成时间就会完成。
在一个工程中,找到了关键路径就是找到了控制该工程时间长短的关键。
需要用算法执行关键路径算法,还需要以下概念:
- 事件Vk的最迟发生时间 v l ( k ) vl(k) vl(k)——指在不推迟整个工程的前提下,改时间最迟必须发生的事件
- 活动ai的最迟开始时间 l ( i ) l(i) l(i)——指该活动弧的终点所表示事件最迟发生时间和该活动所需时间的差值
- 活动ai的最早开始时间 e ( i ) e(i) e(i)——指该活动弧的起点所表示的时间都最早发生事件
- 时间余量:
d
(
i
)
=
l
(
i
)
−
e
(
i
)
d(i)=l(i)-e(i)
d(i)=l(i)−e(i),表示在不增加整个工程需要的总时间的情况下,活动ai可以拖延的时间,如果一个活动的时间余量为0,则证明ai为关键活动,各个关键活动组成关键路径
如果关键活动耗时增加,则整个工程工期会增长
缩短关键活动的时间,可以缩短整个工程工期
当缩短到一定程度时,关键活动可能会变成非关键活动。
可能存在多条关路径,这样的话需要缩短所有关键路径才能缩短工期