前言
本章主要详解了图的基本概念和存储,以及增删改查等操作实现,还有图的遍历和经典应用。其中包括使用邻接表存储图和邻接矩阵存储图、图的深度优先遍历和广度优先遍历,以及很重要的图的相关应用算法—最短路径求解(BFS算法、Dijkstra算法、Floyd算法),拓扑排序的算法和实现,关键路径的求解算法等内容。
总览:
① 图的基本概念
图的定义:
图G由顶点集V
和边集E
组成,记为G= (V, E),其中V(G)表示图G中顶点的有限非空集: EIG)表示图G中顶点
之间的关系(边)集合。若V=(V1, V2,…, V n),则用IV|表示图G中顶点的个数,也称图G的阶,
E={(u,v),u∈V,v∈V},用|El表示图G中边的条数。
【注意】:线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集,但是E可以是空集。
无向图:
若E是无向边(简称边)的有限集合时,则图G为无向图。边是顶点的无序对,记为(v, w)或(w, v),因为(v, w)=(w,v),其中v, w是顶点。可以说顶点w和顶点v互为邻接点。边(v, w)依附于顶点w和v,或者说边(v, w)和顶点v,w相关联。
如上图的例子所示:
G2=(V2, E2)
V2 = {A, B, C, D, E}
E2 = {(A, B), (B, D), (B, E), (C, D), (C, E), (D, E)}
有向图:
若E是有向边(也称弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为<v,w>,其中v、w是顶点,v称为弧尾, w称为弧头
, 称为从顶点v到顶点w的弧,也称v邻接到w,或w邻接自v。
如上图的例子所示:
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>}
子图:
设有两个图G=(V,E)和G’= (V’, E’),若v’是v的子集, 且E’是E的子集,则称G’是G的子图
。
顶点的度、入度、出度:
对于无向图:
顶点v的度是指依附于该顶点的边的条数,记为TD(V)。
对于有向图:
入度是以顶点v为终点的有向边的数目,记为ID(v)。
出度是以顶点v为起点的有向边的数目,记为OD(v)。
顶点v的度等于其入度和出度之和,即TD(v)= ID(v) + 0D(v)。
顶点-顶点的关系描述:
路径–顶点v p,到顶点v q,之间的一条路径是指顶点序列,
回路–第一个顶点和最后一个顶点相同的路径称为回路或环。
简单路径–在路径序列中,顶点不重复出现的路径称为简单路径。
简单回路–除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。
路径长度–路径上边的数目。
点到点的距离–从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离
。若从u到v根本不存在路径,则记该距离为无穷(∞)
。
无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的。
有向图中,若从顶点v到顶点w和从顶点w到顶点v都有路径,则称v和w是强连通的
。
连通分量:
无向图中的极大连通子图称为连通分量。
有向图中的极大连通子图称为有向图的强连通分量。
生成树:
连通图的生成树是包含图中全部顶点的一个极小连通图
。
若图中顶点数为n,则它的生成树含有n-1条边。
对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。
生成森林:
在非连通图中,连通分量的生成树
构成了非连通图的生成森林。
常见考点的总结:
对于n个顶点的无向图G
:
1.若G是连通图,则最少有n-1条边。
2.若G是非连通图,则最多可能有 C n − 1 2 \mathbf{C}_{\mathbf{n}-1}^{2} Cn−12条边。
3.所有顶点的度之和=2|E|。
4.若G是连通图,则最少有n-1条边(树) ,若|E|>n-1,则一定有回路。
5.无向完全图共有 C n 2 \mathbf{C}_{\mathbf{n}}^{2} Cn2条边。
对于n个顶点的有向图G
:
1.若G是强连通图,则最少的有n条边(形成回路)。
2.所有顶点的度之和=2|E|。
3.所有顶点的出度之和=入度之和=|E|。
3.有向完全图共有 C n 2 \mathbf{C}_{\mathbf{n}}^{2} Cn2条边。
②图的储存及基本操作
邻接矩阵存储图:
邻接矩阵存储无权图:
【注】:0代表不邻接,1代表邻接。
无向图:
第i个结点的度=第i行(或第i列)的非零元素个数。
有向图:
第i个结点的出度=第i行的非零元素个数。
第i个结点的入度=第i列的非零元素个数。
第i个结点的度=第i行、第i列的非零元素个数之和。
邻接矩阵的结构体:
#define MaxVertexNum 100
//顶点数目的最大值
typedef struct{char Vex[MaxVertexNum];//顶点表
int Edge[MaxVertexNum][MaxVertexNum];//邻接矩阵,边表
int vexnum, arcnum;//图的当前顶点数和边数/弧数
}MGraph;
【注】: 由于int Edge中的值仅为0/1,所以可将int类型修改为bool,减小结构体占用的内存空间。
结点数为n的图G=(V,E)的邻接矩阵4是nxn的。将G的顶点编号为v1, v2…v n,则:
A [ i ] [ j ] = { 1 , 若 ( v i , v j ) 或 < v i , v j > 是 E ( G ) 中的边 0 , 若 ( v i , v j ) 或 < v i , v j > 不 是 E ( G ) 中的边 \mathbf{A}\left[ \mathbf{i} \right] \left[ \mathbf{j} \right] =\begin{cases} 1,\text{若}\left( \mathbf{v}_{\mathbf{i}},\mathbf{v}_{\mathbf{j}} \right) \text{或}<\mathbf{v}_{\mathbf{i}},\mathbf{v}_{\mathbf{j}}>是\mathbf{E}\left( \mathbf{G} \right) \text{中的边}\\ 0,\text{若}\left( \mathbf{v}_{\mathbf{i}},\mathbf{v}_{\mathbf{j}} \right) \text{或}<\mathbf{v}_{\mathbf{i}},\mathbf{v}_{\mathbf{j}}>\text{不}是\mathbf{E}\left( \mathbf{G} \right) \text{中的边}\\\end{cases} A[i][j]={1,若(vi,vj)或<vi,vj>是E(G)中的边0,若(vi,vj)或<vi,vj>不是E(G)中的边
邻接矩阵存储有权图:
相应的邻接矩阵结构体:
#define MaxVertexNum 100 //顶点数目的最大值
#define INFINITY 最大的int值 //宏定义常量“无穷”
typedef char VertexType; //顶点的数据类型
typedef int EdgeType; //有权图中边上权值的数据类型
typedef struct{
VertexType Vex[MaxVertexNum]; //顶点
EdgeType Edge[MaxVertexNum][MaxVertexNum]; //边的权
int vexnum,arcnum; //图的当前顶点数和弧数
}
邻接矩阵法的性质:
设图G的邻接矩阵为A (矩阵元素为0/1 ) ,则An的元素An[ i ] [ j]等于由顶点到顶点的长度为n的路径的数目。
以上图为例:A2 (1,4)= a1,1 a1,4 + a1,2 a2,4 + a1,3 a3,4 + a1,4 a4,4 = 1
邻接表法存储图:
邻接表的结构体:
//顶点
typedef struct Vnode{
VertexType data;//顶点信息
Arcnode* first;//第一条边/弧
}Vnode,Adlist[MaxvertexNum];
//边/弧
typedef struct ArcNode{
int adjvex;//边/弧指向哪个结点
struct Arcnode* next;//指向下一条弧的指针
InfoType info;//边权值
}Arcnode;
//用邻接表存储的图
typedef struct{
Adjlist vertices;
int vexnum,arcnum;//图的当前顶点数和弧数
}Algraph;
邻接表和邻接矩阵的对比:
邻接表 | 邻接矩阵 | |
---|---|---|
空间复杂度 | 无向图O(|V|+2|E|);有向图O(|V|+|E|) | O(|V|2) |
适用于 | 存储稀疏图 | 存储稠密图 |
表示方式 | 不唯一 | 唯一 |
计算度/出度/入度 | 计算入度不方便 | 必须遍历对应行或列 |
找相邻的边 | 找入边不方便 | 必须遍历对应行或列 |
图的基本操作:
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。
③图的遍历算法
广度优先算法(BFS):
标记数组
:
对于结点的处理采用队列的设计
:
连通图的代码段如下:
bool visited[MAX_VERTEX_NUM]; //访问标记数组,初始全为false;
//广度优先遍历
void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
visit(v); //访问初始顶点v
visited[v]=TRUE; //对v做已访问标记
Enqueue(Q,v);
while(!isEmpty(Q)){ //顶点v入队列
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入队列
} //if
} //while
}
遍历序列的可变性:
同一个图的邻接矩阵表示方式唯一
,因此广度优先遍历序列不唯一
;
同一个图邻接表表示方式不唯一
,因此广度优先遍历序列不唯一
。
非连通图的代码段如下:
//循环检查visited数组中是否存在false的结点
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
BFS(G,i); //vi未访问过,从vi开始BFS
}
//广度优先遍历
void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
visit(v); //访问初始顶点v
visited[v]=TRUE; //对v做已访问标记
Enqueue(Q,v);
while(!isEmpty(Q)){ //顶点v入队列
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入队列
} //if
} //while
}
结论:对于无向图,调用BFS函数的次数=连通分量数。
复杂度分析:
[注]:时间复杂度=访问结点的时间复杂度+访问边的时间复杂度
邻接矩阵存储的图:
访问|V|个顶点需要O(|V|)的时间
查找每个顶点的邻接点都需要0(|V|)的时间,而总共有|V|个顶点
时间复杂度=O(|V|2)
邻接表存储的图:
访问|V|个顶点需要O(|V|)的时间
查找各个顶点的邻接点共需要O(|E|)的时间,时间复杂度=O(|V|+|E|)
深度优先算法(DFS):
标记数组
:
对于结点的处理采用队列的设计
:
bool visited [MAX_VERTEXNUM]; //访问标记数组
void DFSTraverse(Graph G){ //对图G进行深度优先遍历
for(v=0; v<G.vexnum; ++v)
visited[v]=FALSE; //初始化已访问标记数据
for(v=0;v<G.vexnum;++v) //本代码中是从v=0开始遍历
if(!visited[v])
DFS(G,v);
}
void DFS(Graph G,int v){//从顶点v出发,深度优先遍历图
Gvisit(v);//访问顶点v
visited[v]=TRUE;//设已访问标记
for(w=FirstNeighbor(G,v);wo=0;w=NextNeighor(G,v,w))
if( !visited[w]){//w为u的尚未访问的邻接顶点
DFS(G,w);
}//if
}
复杂度分析:
[注]:时间复杂度=访问结点的时间复杂度+访问边的时间复杂度
邻接矩阵存储的图:
访问|V|个顶点需要O(|V|)的时间
查找每个顶点的邻接点都需要0(|V|)的时间,而总共有|V|个顶点
时间复杂度=O(|V|2)
邻接表存储的图:
访问|V|个顶点需要O(|V|)的时间
查找各个顶点的邻接点共需要O(|E|)的时间,时间复杂度=O(|V|+|E|)
④图的经典应用
最小生成树(最小代价树):
最小生成树的定义:
对于一个带权连通无向图G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的生成树
,则T称为G的最小生成树(Minimum-Spannine-Tree, MST)
。
•最小生成树可能有多个,但边的权值之和总是唯一且最小的。
•最小生成树的边数=顶点数-1,砍掉一条则不连通,增加一条边则会出现回路。
•如果一个连通图本身就是一棵树,则其最小生成树就是它本身。
•只有连通图才有生成树,非连通图只有生成森林。
Prim算法:
从某一个顶点开始构建生成树每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。
时间复杂度:O(|V|2)适合用于边稠密图,适合|E|边比较大的图。
Kruskal算法 :
每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选),直到所有结点都连通。
时间复杂度:O(|E|log2|E|)适合用于边稀疏图,适合|V|比较大的图。
最短路径:
BFS算法(无权图):
//求顶点u到其他顶点的最短路径
void BFS_MIN_Distance(Graph G,int u){
//d[i]表示从0到i结点的最短路径
for(i=0; i<G.vexnum; ++i){
d[i]= ∞; //初始化路径长度
path[i]= -1; //最短路径从哪个顶点过来
}
d[u]=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入队
} //if
} //while
}
广度优先算法不适合有权图,只适用于无权图。
Dijkstra算法(带权图、无权图):
初始:从Vo开始,初始化三个数组信息如下:
第1轮:循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点V, 令final[j]=true。
检查所有邻接自Vi的顶点,若其final值为false,则更新dist和path信息。
第2轮:循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点Vi, 令final[j]=true。
第3轮:循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点Vi, 令final[i]=true。
第4轮:循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点Vi, 令final[i]=true。
时间复杂度:O(n2)
【注意】:Dijkstra算法不适用于带负权值的带权图。
Floyd算法(带权图、无权图):
有向无环图(DAG):
有向无环图:若一个有向图中不存在环,则称为有向无环图,简称DAG图(Directed Acyclic Graph) 。
Step 1:把各个操作数不重复地排成一排。
Step 2:标出各个运算符的生效顺序(先后顺序有点出入无所谓)。
Step 3:按顺序加入运算符,注意"分层"。
Step 4:从底向上逐层检查同层的运算符是否可以合体。
拓扑排序/逆拓扑排序:
AOV网(Activity on Vertex Network,用顶点表示活动的网):
用DAG图(有向无环图)表示一个工程。顶点表示活动,有向边<Vi, V j>表示活动Vi必须先于活动V j进行。
拓扑排序的算法:
①从AOV网中选择一个没有前驱的顶点并输出。
②从网中删除该顶点和所有以它为起点的有向边。
③重复①和②直到当前的AOV网为空
或当前网中不存在无前驱的顶点为止
。
拓扑排序的代码实现:
# def 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,则入栈
}
}//while
if(count<G.vexnum)
return false;//排序失败,有向图中有回路
else
return true;//拓扑排序成功
}
}
逆拓扑排序的算法:
对一个AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序:
①从AOV网中选择一个没有后继(出度为0)的顶点并输出。
②从网中删除该顶点和所有以它为终点的有向边。
③重复1和2直到当前的AOV网为空。
逆拓扑排序的实现(DFS算法):
void DFSTraverse(Graph G){//对图G进行深度优先遍历
for(v=0;v<G.vexnum;++v)
visited[v]=FALSE;//初始化已访问标记数据
for(v=0;v<G.vexnum;++v)//本代码中是从v=0开始遍历
if(!visited[v])
DFS(G,v);
}
void DFS(Graph G,int v){//从顶点v出发,深度优先遍历
visit(v);//访问顶点v
visited[v]=TRUE;//设已访问标记
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
if(!visited[w]){//w为u的尚未访问的邻接顶点
DFS(G,w);
}//if
}
关键路径:
活动ai的最早开始时间e(i)—指该活动弧的起点所表示的事件的最早发生时间。
活动ai的最迟开始时间l(i)一它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。
活动ai的时间余量d(i)=l(i)-e(i),表示在不增加完成整个工程所需总时间的情况下,活动ai可以拖延的时间若一个活动的时间余量为零,则说明该活动必须要如期完成, d(i)=0即l(i)=e(i)的活动ai是关键活动,由关键活动组成的路径就是关键路径。
关键路径的求解算法:
①求所有事件的最早发生时间ve()
②求所有事件的最迟发生时间v()
③求所有活动的最早发生时间e()
④求所有活动的最迟发生时间l()
⑤求所有活动的时间余量d()
【注意】:d(i)=0的活动就是关键活动,由关键活动可得关键路径
。
关键活动的特性:
若关键活动耗时增加,则整个工程的工期将增长。
缩短关键活动的时间,可以缩短整个工程的工期。
当缩短到一定程度时,关键活动可能会变成非关键活动。