(图源来自b站懒猫老师)
6.1图的定义和基本术语
6.1.1图的定义
图G由两个集合V(vertex),E(edge)组成,记为G=(V,E)其中V是顶点的有穷非空集合,E是边的有穷非空集合。E(G)可以是空集,此时G只有顶点没有边
对于图G,若边集E(G)为有向边的集合,则称该图为有向图,若边集E(G)为无向图的集合,则称该图为无向图
在有向图中,顶点对<x,y>是有序的,它称为从顶点x到顶点y的一条有向边。因此<x,y>,<y,x>是两条不相同的边。仅对<x,y>而言,x是有向边的始点,y是有向边的终点。<x,y>也称为一条弧,x为弧尾,y为弧头。
在无向图中,顶点对(x,y)是无序的,(x,y)和(y,x)是同一条边
6.1.2图的基本术语(n表示图中顶点数目,e表示边的数目)
(1)子图
假设有两个图G=(V,E)和G'=(V',E'),如果V'是V的子集,E'是E的子集,则G'为G的子图
(2)无向完全图和有向完全图
完全图即图中任意两点都有一条边相连。对于无向图,若具有n(n-1)/2条边,则称为无向完全图,对于有向图,若具有n(n-1)条弧,则称为有向完全图。(两顶点间有两个不同方向的弧,所以应为无向图的两倍)
(3)稀疏图和稠密图
有很少条边或弧[e<nlong2(n)]的图称为稀疏图,否则为稠密图
(4)权和网
图的每条边上可以标上具有某种含义的数值,称为边上的权(可以表示一个顶点到零一个顶点的距离或耗费)。带权的图称为网
(5)邻接和邻接点
邻接是有边或弧的两顶点的关系,称两顶点相邻接。特别的,对于有向图<vi,vj>称vi邻接到vj或vj邻接于vi
关联/依附:边或弧与对应顶点的关系
(6)度,入度和出度
顶点的度是指和顶点关联的边的数目,记为TD(v)。对于有向图,入度是以该顶点为弧头的弧的数目,记为ID(v),出度是以该顶点为弧尾的弧的数目,记为OD(v)。其中有向图的度等于其出度加上入度,即TD(v) = ID(v) + OD(v)
(7)路径和路径长度
顶点v到v'的路径是从v到v'经过的顶点序列(包括v,v')。如果G是有向图,则路径也是有向的。路径长度是一条路径上经过的边或弧的数目或权值之和
(8)回路或环
第一个顶点和最后一个顶点相同的路径称为回路或环
(9)简单路径,简单回路或简单环
序列中顶点不重复出现的路径称为简单路径。除了第一个和最后一个定点之外,其余顶点不重复的回路,称为简单回路或简单环
(10)连通,连通图和连通分量
在无向图G中,如果顶点v和v'有路径,则称v和v'是连通的。如果对于图中任意两个顶点都是连通的,则称G是连通图。连通分量是连通图G的一个极大连通子图(即再添加一个存在于G中且不存在于该子图的顶点时,该子图就不再连通)
(11)强连通图和强连通分量(有方向限制)
在有向图G中,如果对于每一对vi,vj(vi!=vj),从vi到vj和从vj到vi都有路径,则称G是强连通图。有向图中的极大强连通子图称为强连通分量(即将G的任何不在该子图中的顶点加入,子图不再是强连通图)。
(12)连通图的生成树
一个包含所有顶点的极小连通子图(再删去任何一个顶点则该图不连通),且只包含了只能形成一棵树的n-1条边。
如果在一棵生成树上加上一条边,必定构成一个环,因为这条边使得依附在其上的两个顶点有了另外一条路径
(13)有向树和生成森林
有一个顶点的入度为0,其他顶点的入度为1的图称为有向树
一个有向图的生成森林是由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的树的弧
6.2案例引入:六度空间理论
6.3图的数据类型定义:⭐建立图的算法
⭐遍历图的算法:DFS,BFS
6.4图的存储结构
图的存储结构:邻接矩阵,邻接表,十字链表,邻接多重表
6.4.1邻接矩阵
1.邻接矩阵表示法
邻接矩阵是表示顶点之间相邻关系的矩阵。G是具有如下性质的n阶方阵
(对应二维数组下标即表示两顶点在顶点表中的位置)
例(无向图G1)
其邻接矩阵为
无向图邻接矩阵特点
1.对角线元素均为0,因为自己不能和自己相连
2.无向图的邻接矩阵是对称的
3.顶点i的度为改行1的个数
特别的,完全无向图除了主对角线元素为0,其余元素均为1
例(有向图G2):
其邻接矩阵为
有向图邻接矩阵的特点
1.第i行表示以结点vi为尾的弧(出度边)
第j列表示以结点vj为头的弧(入度边)
2.有向图的邻接矩阵不一定是对称的
若G为网(带权图),则邻接矩阵可以定义为
表示边上的权值,
表示计算机允许的,大于所有边上权值的数
用邻接矩阵法表示图,除了用一个二维数组存储邻接矩阵外,还需要一个一维数组来存储顶点信息(顶点表)
网的类型定义:
#define MaxInt 32767 //最大权值∞
#define MVNum 100 //最大顶点数
typedef struct
{
VerTexType vexs[MVNum]; //顶点表
ArcType arcs[MVNum][MVNum]; //邻接矩阵
int vexnum,arcnum; //当前图的顶点数和边数
}AMGraph;
2.采用邻接矩阵表示法创建无向网
算法6.1采用邻接矩阵法创建无向网
算法步骤:1.输入总顶点数和边数
2.依次输入点的信息存入顶点表中
3.初始化邻接矩阵,使每个权值初始化为极大值
4.构造邻接矩阵,依次输入每条边依附的顶点和权值。确定两顶点在顶点表中的位置,使相应边赋予对应权值,同时赋予对称边相同的权值
算法描述:
status CreateUDN(AMGraph &G)
{
cin >> G.vexnum >> G.arcnum; //输入总顶点数和边数
for(int i = 0;i < vexnum;i++) //输入每个顶点的信息
{
cin >> G.vex[i];
}
for(int i = 0;i < vexnum;i++) //初始化邻接矩阵,初始化为极大值
for(int j = 0;j < vexnum;j++)
{
G.arcs[i][j] = MaxInt;
}
for(int k = 0;k < arcnum;k++) //构建邻接矩阵
{
cin >> v1 >> v2 >> w; //输入两顶点信息和该边权值
i = LocateVex(G,v1),j = LocateVex(G,v2);//定位两点的位置
G.arcs[i][j] = w; //给该边赋予权值
G.arcs[j][i] = G.arcs[i][j]; //无向网对应边权值依然为w
}
return OK;
}
该算法的时间复杂度为O(n^2),空间复杂度也为O(n^2)
若要建立无向图,有向图,有向图,只需做出一点改动即可
无向图:初始化邻接矩阵时,将极大值置为0。
构造邻接矩阵时,权值赋值为1
有向:不用对称赋权值
3.邻接矩阵表示法的优缺点
(1)优点
1.便于判断两顶点是否有边
2.便于计算各顶点的度
3.方便找任意顶点的所有邻接点
(2)缺点
1.不便于增加或删除顶点
2.不便于统计边的数目,需要扫描邻接矩阵所有元素才能统计完毕
3.空间复杂度高,对于稠密图尚可,对稀疏图浪费极大
6.4.2邻接表
1.邻接表表示法
邻接表是图的一种链式存储结构,对图的每个顶点vi建立一个单链表,把与vi邻接的顶点放在这个链表中。邻接表中每个单链表的第一个结点存放顶点的信息。即邻接表由两部分组成——表头结点和边表。
(1)表头结点表
由所有表头结点以顺序结构的形式存储,以便可以随机访问任一结点的边链表。表头结点包括数据域和链域两部分。其中,数据域用于存储顶点vi的名称和其他有关信息,链域用于指向链表中第一个结点。
(2)边表
由表示图中顶点关系的2n个边链表组成,边链表中边结点包括邻接点域,数据域和链域三部分。其中,邻接点域指示与vi邻接的点在图中的位置,数据域存储和边有关的信息。链域指示与顶点vi邻接的下一条边的结点。
例(无向图G1)
其邻接表为
无向邻接表的特点
(1)邻接表不唯一(边结点之间可以互换)
(2)若无向图有n个顶点,e条边,则其邻接表需要n个头结点和2e个边结点,适合存储稀疏图
(3)在无向图的邻接表中,顶点vi的度恰为第i个链表中的结点数
空间复杂度为O(n+e)
例(有向图G2)
其邻接表为
有向图邻接表特点
(1)第i个链表中的结点个数只是顶点vi的出度
(2)要求入度,必须遍历整个邻接表
空间复杂度为O(n+e)
为了方便求入度,可以采用逆邻接表存储有向图
依旧以有向图G2为例,其逆邻接表为
邻接表的数据类型定义:
#define MVNum 100
typedef struct ArcNode
{
int adjvex; //与第i个顶点邻接的点的下标
struct ArcNode* nextarc;//指向下一个边的指针
ElemType info; //边的权值
}ArcNode;
————————————————————————————边结点定义
typedef struct VNode
{
VerTexType data; //顶点数据
ArcNode *fristarc; //指向第一条边的指针
}VNode,AdjList[MVNum]; //AdjList v[MVNum]相当于VNode v——直接生成一个大小为MVNum的数组
————————————————————————————顶点结点定义
typedef struct
{
AdjList vertices; //顶点表
int vexnum,arcnum; //顶点数和边数
}ALGraph;
————————————————————————————邻接表的定义
2.采用邻接表表示法创建无向图
算法6.2采用邻接表表示法创建无向图
算法步骤:1.输入总顶点数和总边数
2.依次输入点的信息存入顶点表,使每个表头结点的指针域初始化为NULL
3.创建邻接表,依次输入每条边依附的两个顶点,确定这两个顶点的序号i,j将此边结点分别头插入vi和vj对应边链表中
算法描述:
status CreateUDG(ALGrapha &G)
{
cin >> G.vexnum >> arcnum; //输入总顶点数,总边数
for(int i = 0;i < vexnum;i++) //初始化顶点表,fristarc置为NULL
{
cin >> G.vertices[i].data;
G.vertices[i].fristarc = NULL;
}
for(int k = 0;k < G.arcnum;k++) //创建邻接表
{
cin >> v1 >> v2; //输入边依附的两点信息
i = LocateVex(G,v1),j = LocateVex(G,v2); //找到两点在顶点表(图)中位置
——————————————————————————————————————————————————//头插法把边结点插入顶点i后
p1 = new ArcNode;
p1->adjvex = j;
p1->nextarc = G.vertices[i].firstarc;
G.vertices[i].fristarc = p1;
——————————————————————————————————————————————————//头插法把边结点插入顶点j后
p2 = new ArcNode;
p2->adjvex = i;
p2->nextarc = G.vertices[j].fristarc;
G.vertices[j].fristarc = p2;
——————————————————————————————————————————————————//无向图要分别插入,有向图不需要
}
return ok;
}
该算法时间复杂度和空间复杂度均为O(n+e)
若要建立有向图的邻接表,只需头插在一个顶点后即可
若要创建网的邻接表,可以将边的权值存储在info域中
3.邻接表表示法的优缺点
(1)优点
1.便于增加和删除结点
2.便于统计边的数目,找到任一顶点的所有邻接点
3.空间效率高,适合存储稀疏图
(2)缺点
1.不便于判断顶点之间是否有边,要判断i,j是否有边需要扫描第i个边表
2.不便于计算有向图的度
小结:邻接表和邻接矩阵对比
1.联系:邻接表中每个链表对应邻接矩阵的一行,结点个数等于邻接矩阵一行非0元素个数
2.区别:①对任一确定的无向图,邻接矩阵唯一,邻接表不唯一
②邻接矩阵空间复杂度O(n^2),邻接表空间复杂度O(n+e)
3.用途:邻接表多用于稀疏图,邻接矩阵多用于稠密图
6.4.3十字链表——解决有向邻接表求结点的度困难
十字链表是有向图的一种链式存储结构,可以看成有向图的邻接表和逆邻接表的结合
结点结构:
例:见书p159
6.4.4邻接多重表——解决无向图邻接表每条边都要存储两遍
定点结构:
例(无向图G)书p160
6.5图的遍历
图的遍历也是从图中某一顶点出发,按照某种方法对图中所有顶点访问且只访问一次
由于图中可能存在环,导致在访问某个顶点,可能沿着某条路径继续搜索之后,又回到该顶点上。为了避免一个顶点被访问多次,可以设一个辅助数组visited[n],初始值置为false,一旦访问后便置为true
图的遍历主要有两种:深度优先搜索(DFS)和广度优先搜索(BFS)
6.5.1深度优先搜索(DFS)
1.深度优先搜索遍历的过程
(1)从图的某个顶点v出发,访问v
(2)找出刚访问的顶点的第一个邻接点,访问该结点。以该结点为新顶点重复此步骤,直至刚访问的顶点没有未被访问的邻接点为止
(3)返回前一个访问过且仍有未被访问的邻接点的顶点,找出该顶点的下一个未被访问的邻接点,访问该结点。
(4)重复(2)(3)直到图中所有结点都被访问过
如此会形成一个以出发点为根结点的树,称为深度优先生成树
2.深度优先搜索遍历的算法实现
显然,深度优先搜索是一个递归的过程,为了便于区分顶点是否已被访问,需设访问标志数组visit[n]来记录顶点是否被访问过
算法6.3深度优先搜索遍历连通图
算法步骤:①从图中某个顶点v出发,访问v,并置visit[v]为true
②依次检查v的所有邻接点w,如果visit[w]的值为false,再从w出发进行递归遍历,直 到图中所有顶点都被访问过
算法描述:
bool visit[MVNum]; //定义辅助数组
void DFS(Graph G,int v)
{
cout << v; //访问该结点
visit[v] = true; //表示已访问过结点v
for(w=FristAdjVex(G,v);w>=0;w=NextAdjVex(G,v,w))
{ //w为v的第一个邻接点,w>=0保证v有邻接点,通过NextAdjVex函数遍历所有邻接点
if(!visit[w]) //该结点未被访问过,则进行递归遍历
DFS(G,w);
}
}
如果是非连通图,在上述过程结束后一定还有未访问的结点,则任取一个未访问的结点重复DFS,知道所有结点都被访问即可
算法6.4深度优先搜索遍历非连通图
算法步骤:对未访问过的结点依次进行深度优先搜索
算法描述:
void DFSTraverse(Graph G)
{
for(int i = 0;i < G.vexnum;i++) //初始化标志数组
{
visit[i] = false;
}
for(int v = 0;v < G.vexnum;v++) //遍历结点,对每个未访问过的结点进行DFS
{
if(!visit[v])
DFS(G,v);
}
}
对于算法6.4,每调用一次算法6.3将遍历一个连通分量,所以调用多少次就有多少连通分量
下面介绍通过具体的存储结构进行DFS
算法6.5采用邻接矩阵表示图的深度优先搜索(适用于稠密图)
算法描述:
void DFS_AM(AMGraph G,int v)
{
cout << v; //访问结点v
visit[v] = true; //表示结点v已经被访问过
for(int w = 0;w < G,vexnum;w++) //在邻接矩阵中遍历v的邻接点(第v行的每一列)
{
if(G.arcs[v][w]!=0&&!visit[w])//v和w邻接(邻接矩阵第v行第w列不为0)并且w未被访问过
DFS_AM(G,w); //进行深搜
}
}
显然,该算法的时间复杂度为O(n^2),适合稠密图的遍历
算法6.6采用邻接表表示图的深度优先搜索(适用于稀疏图)
算法描述:
void DFS_AL(ALgraph G,int v)
{
cout << v; //访问结点v
visit[v] = true; //标志该结点已访问过
p = G.vertices[v].fristarc; //p是顶点v的第一条边
while(p!=nullptr) //保证边存在
{
w = p->adjvex; //w是v关于边fristarc的邻接点
if(!visit[w]) //如果邻接点w未被访问,则从w开始递归
DFS_AM(G,w);
p = p->nextarc; //递归完后再遍历v的其他边
}
}
3.深度优先搜索的算法分析
(1)当采用邻接矩阵表示图时,查找每个顶点的邻接点的时间复杂度为O(n^2)
(2)当以邻接表表示图时,查找邻接点的复杂度为O(e),e为图的边数。遍历顶点的复杂度为O(n),所以遍历图的复杂度为O(n + e)。
6.5.2广度优先搜索(BFS)
1.广度优先搜索遍历图的过程
(1)从图的某个顶点v出发,访问v
(2)依次访问v的各个为曾访问过的邻接点
(3)分别从这些邻接点出发依次访问它们的邻接点,并使“先被访问的顶点的邻接点”先于“后被访问的顶点的邻接点”被访问。重复(3),直至所有结点都被访问。
依照广度优先搜索会生成一条由v为根结点的树,称为广度优先生成树
2.广度优先搜索的算法实现
由于需要横向搜索,算法实现时需引进队列保存已被访问过的顶点
类似于树的层序遍历
算法6.7广度优先搜索遍历连通图
算法步骤:①从图中的顶点v出发,访问v,并置visit[v]为true,然后将v进队
②只要队列不空,则重复
(1)队头顶点u出队
(2)依次检查u的所有邻接点w,如果visit[w]为false,则访问w,并置visit[w]为true,然后将w进队
算法描述:
void BFS(Graph G,int v)
{
cout << v; //访问结点v
visit[v] = true; //标志结点v已被访问过
InitQueue(Q); //初始化队列
EnQueue(Q,v); //结点v进队
while(!QueueEmpty(Q)) //只要队列不为空,则执行
{
DeQueue(Q,u); //队头元素出队,以u储存
for(w=FristAdjVex(G,u);w>=0;w = NextAdjVex(G,u,w))
{ //遍历队头元素u的所有邻接点,记为w
if(!visit[w]) //没访问过就访问结点w
{
cout << w;
visit[w] = true;
EnQueue(Q,w); //w入队
}
}
}
}
若是非连通图,类比DFS的处理方法即可
3.广度优先搜索的算法分析
(1)使用邻接矩阵存储时,时间复杂度为O(n^2)
(2)使用邻接表存储时,时间复杂度为O(n + e)
6.5.3DFS与BFS的比较
*DFS和BFS的空间复杂度相同,都是O(n),分别借用了堆栈和队列
*时间复杂度只与存储结构有关,与搜索路径无关——邻接矩阵O(n^2)
——邻接表O(n + e)
*两种遍历方法的不同之处仅仅在于对顶点访问的顺序不同
6.6图的应用
图的几个常用算法:最小生成树,最短路径,拓扑排序,关键路径
6.6.1最小生成树
1.生成树和最小生成树
生成树包括:DFS生成树,BFS生成树,最小生成树等
生成树的特点:1.生成树的顶点和图相同
2.生成树是图的极小连通子图
3.n个顶点的连通图的生成树有n-1条边(但有n-1条边和n个顶点的不一定是生 成树)
4.生成树再添加一条边必然形成回路
5.生成树中任意两顶点的路径是唯一的
最小生成树:在一个连通网的所有生成树中,各边之和代价最小的那棵树称为该连通网的最 小生成树
2.MST性质
MST性质:假设N=(V,E)是一个连通网,U是顶点集V的一个非空子集。若存在一边(u,v)拥有最小权值(其中u在U中,v在V-U中),则必存在一棵最小生成树包含边(u,v)
MST(Minimun Spanning Tree)性质本质是一种贪心算法,多数最小生成树算法都用到了这个性质
MST性质解释:在生成树的构造过程中,图中n个顶点分属两集合
*已经落在生成树上的顶点集U
*尚未落在生成树上的顶点集V-U
接下来应在所有连通U中顶点和V-U中顶点的边中选取权值最小的边
⭐选取的边中不能使生成树出现回路,否则选取下一条权值次小的边
最小生成树是两种经典算法:普利姆算法(Prim)和克鲁斯卡尔算法(Kruskal)
3.Prim算法
(1)Prim算法的构造过程
假设N=(V,E)是连通网,TE是N上最小生成树中边的集合
①U={u0}(u0在V中),TE={}
②在U中所有u和V-U中所有v连成的边(u,v)中找到一条权值最小的边(u0,v0),将边(u0,v0)并 入TE中,将v0并入U中
③重复②,直至U=V为止
此时TE中必有n-1条边,则T=(V,TE)为N的最小生成树。可以看出,Prim算法逐步增加U中的顶点,可称为“加点法”。
Note:在选择最小边时,可能存在多条权值相同的边可供选择,任选其一即可。故最小生成树可能并不唯一
(2)Prim算法的实现
Prim算法适合以邻接矩阵实现。为实现这个算法,需要附设一个辅助数组closedge来记录从U到V-U中具有最小权值的边。它包括两个域:lowcost和adjvex。其中lowcost存储最小边上的权值,adjvex存储最小边在U中的那个顶点。
显然有:
(图源来自b站懒猫老师)
辅助数组定义:
struct
{
VerTexType adjvex; //最小边在U中的顶点(下标)
AcType lowcost; //最小边上的权值
}closedge[MVNum];
算法6.8Prim算法
算法步骤:1.首先将初始顶点u加入U中,对其余每一个顶点vj,将closedge[j]均初始化为vj到u的 边的信息
2.循环n-1次,执行
(1)从各组边closedge中选出最小边closedge[k],访问该边
(2)将k(vk)加入到U中
(3)更新剩余每组的最小边信息closedge[j],对于V-U中的每一条边,新增加了 一条从k到j的边。若新边的权值比closedge[j].lowcost小,则将 closedge[j].lowcost更新为新边的权值,否则不动
算法描述:
void MiniSpanTree_Prim(AMGraph G,VexTexType u)
{//无向网G以邻接矩阵形式存储,从u顶点出发,构造最小生成树
k = LocateVex(G,u); //k为顶点u的下标
for(j = 0;j < G.vexnum;j++) //初始化closedge数组,表示从集合U到V-U各顶点最短边长
{
if(j!=k)
closedge[j] = {u,G.arcs[k][j]};
}
closedge[k].lowcost = 0; //u加入U中
for(i = 1;i < G.vexnum;i++) //对剩下n-1个顶点
{
k = Min(closedge); //k为最短边连接的V-U中的顶点
if(closedge[k].lowcost==0) //确保该顶点不在U中
continue;
u0 = closedge[k].adjvex; //u0为最小边在U中的顶点
v0 = G.vexs[k]; //v0为最小边在V-U中的顶点,即k下标所指的顶点
cout << u0 << v0 << lowcost[k];//输出顶点即权值
closedge[k].lowcost = 0; //顶点v0加入U中
for(j = 0;j < G.vexnum;j++) //遍历新加入顶点邻接矩阵的每一列,更新lowcost
{
if(G.arcs[k][j]<closedge[j].lowcost) //如果新顶点有比原来从U到V-U各顶点路径
{ //更短的路径,则更新对应lowcost
closedge[j] = {G.vexs[k],G.arcs[k][j]};
}
}
}
}
(算法分析:Prim算法的时间复杂度为O(n^2),与边数无关,只与顶点数有关。因此适合求稠密网的最小生成树
4.Kruskal算法
(1)Kruskal算法构造过程
假设连通网N=(V,E),将N中的边按权值从小到大排列
①初始状态为只有n个顶点而无边的非连通图T=(V,{}),每个顶点自成一个连通分量
②在E中选择权值最小的边,若该边依附的两个顶点落在T中不同的连通分量上(即不形成回路),则将此边加入到T中,否则舍去此边选择下一条权值最小的边
③重复②,直至T中所有顶点都在同一个连通分量上为止
关键:1.图的存储问题
采用邻接矩阵和邻接表存储都不是特别合适,需要改进
2.边的排序问题
依靠权值排序
3.如何判别两顶点是否在同一个连通分量上
把连通分量看成树,看两顶点是否有相同的根结点。
可以看出,Kruskal生成最小生成树是在逐步增加边,可称为“加边法”,是更纯粹的贪心算法
(2)Kruskal算法的实现
算法的实现要引入以下辅助数据结构
①结构体数组Edge,存储边的信息,包括两顶点和权值
辅助数组Edge的定义:
struct
{
VerTexType Head; //边的始点
VerTexType Tail; //边的终点
ArcType lowcost; //边上的权值
}Edge[arcnum];
②Vexset[i]标识各个顶点所属的连通分量。在辅助数组存在一个相应元素Vexset[i]表示该顶点所属的连通分量。初始Vexset[i] = [i],表示顶点自成一个连通分量
辅助数组Vexset定义:
int Vexset[MVNum];
③也可另设Vexnum和Edgenum表示顶点和边的个数,若Edgenum==Vexnum-1即表示生成树生成完毕,可提前退出循环(次数不予演示)
算法6.9Kruskal算法
算法步骤:1.将数组Edge中元素按权值从小到大排序
2.依次查看Edge中的边,执行以下操作
①依次从排好序的数组Edge中选出一条边(v1,v2)
②在Vexset中分别查找v1,v2所在的连通分量vs1,vs2
*如果vs1和vs2不等,则表明两顶点所属不同的连通分量,输出此边,合并vs1和 vs2两个连通分量
*如果vs1和vs2相等,则表明两顶点从属同一个连通分量,舍去此边选择下一条权 值最小的边
算法描述:
void MiniSpanTree_Kruskal(AMGraph G)
{//无向网G以邻接矩阵形式存储
sort(Edge); //给每个边按权值排序
for(i = 0;i < G.vexnum;i++) //初始化Vexset数组,各个顶点自成连通分量
{
Vexset[i] = i;
}
for(i = 0;i < G.arcnum;i++) //从小到大遍历每条边
{
v1 = LocateVex(G,Edge[i].Head); //v1是最小边的始点
v2 = LocateVex(G,Edge[i].Tail); //v2是最小边的终点
vs1 = Vexset[v1]; //vs1是v1所属连通分量
vs2 = Vexset[v2]; //vs2是v2所属连通分量
if(vs1!=vs2) //如果两顶点所属连通分量不相等
{
cout << Edge[i].Head << Edge[i].Tail << Edge[i].lowcost; //输出该边
for(j = 0;j < G.vexnum;j++) //遍历顶点,合并两连通分量
{
if(Vexset[j]==vs2) //将根结点为vs2的顶点全部接到vs1上
Vexset[j] = vs1;
}
}
}
}
算法分析:Kruskal算法的时间复杂度为O(elog(2)e)(按堆排序O(elog(2)e))来算,与网的边数有关。因此相比Prim算法,Kruskal算法更适合稀疏图
6.6.2最短路径
交通网往往是用带权有向网表示。其中,顶点表示地点,弧表示两地点之间的连通路。弧上权值表示经过该路径的代价。习惯上称第一个顶点为源点,最后一个顶点为终点。
最短路径主要包括两种最常见的最短路径问题
1.从某个顶点到其余各顶点的最短路径
2.求每一对顶点之间的最短路径
最短路径与最小生成树不同,路径上不一定包含n个顶点,也不一定包含n-1条边
1.从某个源点到其余各顶点的最短路径——单源点最短路径
Dijkstra算法——提出了一个路径长度递增的次序产生最短路径的算法
(1)Dijkstra的算法思想
设置一个集合S表示已经找到最短路径的顶点,S的初始状态只包含源点v。对在V-S中的每个顶点vi,先假设从源点v到vi的有向边为最短路径(若没有路径则记为)。以后每求得一条最短路径v,......vk,就把v,....vk与原来的假设作比较,取较短者为最短路径。,并将新的顶点加入集合S中。重复直到所有顶点都加入到S中。
(图源来自b站懒猫老师)
(2)Dijkstra算法的实现
图的存储结构:选用带权的邻接矩阵arcs。G.arcs[i][j]表示弧<vi,vj>上的权值,若<vi,vj>之间的弧不存在,则记为,源点为v0。
算法实现需要引入以下数据结构:
①一维数组S[i],用来确定从v0到vi的最短路径是否已被确定,确定为true,未确定为false
(代表集合S)
②一维数组Path[i],记录从v0到终点vi的当前最短路径上vi的直接前趋顶点符号。初值为:若v0到vi有弧,则记为v0,否则记为-1
③一维数组D[i],记录当前从源点v0到终点vi的当前最短路径长度。其初值为:若从v到vi有弧,则记为权值,无弧记为。
显然,长度最短的一条最短路径必为(v0,vk),满足
求得顶点vk最短路径后,将其加入到S中。因为多了一个中转路径,所以要对到剩余各个顶点的最短路径长度进行更新。
即取min( D[i] , D[k]+G.arcs[k][i] )进行迭代D[i]。
更新后,再选择V-S中值最小的顶点加入S中,重复以上步骤
算法6.10 Dijkstra算法
①初始化
*将源点v0加入S,即S[v0] = true。
*将v0到各个顶点的最短路径长度初始化为权值,即D[i] = G.arcs[v0][vi],(viV-S);
*如果v0和顶点vi之间有弧,则将vi前驱置为v0,即Path[i] = v0。否则Path[i] = -1
②循环n-1次,执行以下操作
*选择下一条最短路径的终点,使得
*将vk加到S中,即S[vk] = true;
*根据条件更新从v0到集合V-S上任一顶点的最短路径长度,取
D[i] = min( D[i] , D[k]+G.arcs[k][i] )
同时更改vi的前驱为vk,即Path[i] = k;
算法描述:
void ShortestPath_DIJ(AMGraph G,int v0)
{
for(v = 0;v < G.vexnum;v++)
{
S[v] = false; //初始化S集合,只有一个v0
D[v] = G.arcs[0][v]; //初始化D[v],从v0到各顶点的权值
if(G.arcs[0][v]<MaxInt) //如果v0到v有弧,则v前驱初始化v0
Path[v] = 0;
else
Path[v] = -1; //没弧初始化为-1;
}
S[0] = true; //S初始只含有v0
D[0] = 0; //v0到v0距离为0
——————————————————————————————————————//初始化结束,开始n-1次循环
for(v = 1;v < G.vexnum;v++) //找出v0到每个顶点的最短路径
{
min = MaxInt; //min先记为∞
for(w = 0;w < G.vexnum;w++) //循环邻接矩阵第v行的每一列
{
if(!S[w]&&D[w]<min) //如果顶点w不在S中并且从v0到w的直接权值小于当前最小值
{
v = w;min = D[w]; //更换最短的顶点和当前最小值
}
}
S[v] = true; //将最后选出来的最短的路径的顶点加入S
for(w = 0;w < G.vexnum;w++) //扫描新加入顶点v的各出度边
{
if(!S[w]&&D[v] + G.arcs[v][w]<D[w])//选最小的更新D[i]数组,即最短路径
{
D[w] = D[v] + G.arcs[v][w]; //更新最短路径长度
Path[w] = v; //更新路径
}
}
}
}
算法完成后,如何找到最短路径长度和路径?
1.最短路径长度直接访问D数组即可得到
2.路径可以先访问Path数组的下标,得到其直接前驱,然后依据得到的数字在访问其前驱,直到找到源点为止
例:
若要找v0到v6的路径,倒序路径为:8->7->9->0
故路径为0->9->7->8
(3)算法分析:Dijkstra的算法时间复杂度显然为O(n^2),即使只找一条,也是和找出所有一样复杂, 同样为O(n^2)
2.每一对顶点之间的最短路径——多源最短路径求解
求解每一对顶点之间最短路径有两种方法:一种是以每个顶点为源点调用Dijkstra算法,另一种则是使用Floyd算法。两种算法的时间复杂度均为O(n^3),但Floyd算法形式较为简单
(1)Floyd算法基本思想
对于从vi到vj的弧,进行n次试探。首先考虑vi,v0,vj路径是否存在,如果存在,则比较vi,vj和vi,v0,vj的路径长度,取较短者为从vi到vj的中间顶点的序号不大于0的最短路径。再在路径上增加一个v1,以此类推。最后所得即是vi到vj的最短路径
(2)算法实现
图的存储方式:带权的邻接矩阵
数组D[i][j]存放迭代过程中得到的从vi到vj的最短路径
迭代公式:
数组Path[i][j]存放从vj的直接前趋。
算法6.11Floyd算法
算法描述:
void ShortestPath_Floyd(AMGraph G)
{
for(i = 0;i < G.vexnum;i++) //对每一对顶点初始化
{
for(j = 0;j < G.vexnum;j++)
{
D[i][j] = G.arcs[i][j]; //每队顶点最短距离假设为权值
if(D[i][j]<MaxInt&&i!=j) //对于有弧的顶点对设置前驱顶点下标
Path[i][j] = i;
else
Path[i][j] = -1;
}
}
——————————————————————————————————————————//初始化完成,开始对每一对顶点进行试探
for(k = 0;k < G.vexnum;k++) //要加入的顶点
for(i = 0;i < G.vexnum;i++) //起点i
for(j = 0;j < G.vexnum;j++) //终点j
{
if(D[i][k] + D[k][j]<D[i][j])//比较两种情况,找出较小的距离进行迭代
{
D[i][j] = D[i][k] + D[k][j];//更新最短距离D
Path[i][j] = Path[k][j]; //更新j的直接前趋下标为k
}
}
}
(3)算法分析
显然算法时间复杂度为O(n^3)
6.6.3拓扑排序
1.AOV-网
一个无环的有向图称为有向无环图,简称DAG图。有向无环图是描述一项工程的进行过程的有效工具。通常把计划,施工过程,生产流程,程序流程等都当成一个工程。一般的工程可分为若干个称为活动的子工程,并且这些子工程中一般存在某种约束。
用顶点表示活动,弧表示活动之间优先关系的有向图称为AOV-网。在图中,若顶点vi到顶点vj有一条有向路径,则vi是vj的前趋,vj是vi的后继。若<vi,vj>是图中的一条弧,则vi是vj的直接前趋,vj是vi的直接后继。
AOV网的特点:1.AOV网中弧表示活动之间存在的某种制约关系
2.AOV网中不能出现回路。
若出现回路,则表示某个活动以自己为先决条件,这显然不可能。因此,工程合理的关键是判断AOV网中是否存在环。而判断AOV网中是否存在环的方法是对AOV网所有顶点进行拓扑排序
2.拓扑排序
(1)拓扑排序,拓扑序列
拓扑排序是将AOV网中所有顶点排成一个线性序列(拓扑序列),这个线性序列满足:若在AOV网中存在一条vi到vj的路径,则在该序列中vi必定在vj之前。
(2)拓扑排序的过程
1.在有向图中选择一个无前趋的结点并输出它
2.从图中删除该顶点和所有以他为弧尾的弧
3.重复1,2直至图中不存在无前趋的顶点
4.若此时输出的顶点数小于有向图的顶点数,则说明图中有环。否则输出的顶点即为一个拓扑序列
(3)拓扑排序的实现
图的存储方式:邻接表
引入数据结构
①一维数组indegree[i]
存放各顶点的入度,没有前趋的顶点就是入度为0的顶点。删除顶点及以他为尾的弧,可不必对图的存储结构做改变,只要对弧头顶点的入度减一即可
②堆栈S
暂存所有入度为0的(无前驱)顶点,这样可以避免重复扫描数组degree,检测入度为0的顶点
③一维数组topo[i]
记录拓扑排序的顶点序号
算法6.12拓扑排序
算法步骤:
(图源来自b站懒猫老师)
算法描述:
status TopologicalSort(ALGraph G,int topo[i])
{
InitStack(S); //初始化栈
count = 0; //计数器初始化为0
FindInDegree(G,indegree); //找出所有顶点的入度,放入数组degree中
for(i = 0;i < G.vexnum;i++) //将无前驱的顶点压栈
{
if(!degree[i])
Push(S,i);
}
while(!StackEmpty(S)) //若栈非空,循环
{
Pop(S,up); //将栈顶元素弹栈
topo[count] = up; //栈顶元素存入拓扑序列
cout << up; //输出该顶点
count++; //计数器+1
p = G.vertices[up].fristarc;//p记为弹出顶点的第一条弧
while(p!=nullptr) //扫描以up为顶点的弧并删除这些弧
{
k = p->adjvex; //k表示p的对应弧的邻接点
indegree[k]--; //这个邻接点的入度-1(即删除从up到k的弧)
if(!indegree[k]) //若邻接点k入度为0,则将其压栈
Push(S,k);
p = p->nextarc; //依次访问其他弧
}
}
if(count<G.vexnum) //参与拓扑排序的顶点数小于图顶点数,说明有回路
return error;
else //否则得到拓扑排序
return OK;
}
算法分析:
对n个顶点和e条边的图来说,求各顶点入度的时间复杂度为O(e),建立零入度顶点栈的时间复杂度为O(n),在拓扑排序中,若有向图无环,则每个顶点进栈一次,出栈一次,入度减一的操作共执行e次,所以总的算法时间复杂度为O(n + e)
6.6.4关键路径
1.AOE网
AOE网是一个带权有向无环图(AOV不带权)。其中顶点表示事件,弧表示活动,权值表示活动的进行时间。
AOE网的性质:(1)只有在某顶点所代表的事件发生后,从该顶点出发的各活动才能开始
(2)只有在进入某顶点的各活动都结束,该顶点所代表的事件才能发生
AOE网的作用:1.估算完成整项工程至少需要的时间
2.判断哪些活动是影响工程进度的关键
图源来自b站懒猫老师
2.关键活动和关键路径
源点:即AOE图的起点,是一个入度为0的顶点
汇点:即AOE图的终点,是一个出度为0的顶点
带权路径长度:一条路径上的权值之和
关键路径:一条从源点到汇点的带权路径长度最长的路径
ps:因为要估算工程完成的最短时间,就要保证关键活动不拖后
关键活动:关键路径上的活动。
这些活动是影响工程进度的关键,它们的提前或拖延会导致整个工程的提前或拖延
3.关键路径的算法思想
一.关键路径的描述量
确定关键路径需要定义四个描述量
(1)事件vi的最早发生时间ve(i)
进入事件vi的每一活动都结束,vi才可发生。所以ve(i)是从源点到vi的最长路径长度
迭代公式:
(图源来自b站懒猫老师)
(2)事件vi的最迟发生时间vl(i)
不推迟整个工期的前提下,事件vi允许的最晚发生时间。vi的最迟发生时间不得迟于其后继事件vk的最迟发生时间减去<vi,vk>的持续时间
迭代公式:
(图源来自b站懒猫老师)
例:图中易得工程最早完成时间需要10天,所以显然v2只需要在第八天之前完成即可。故8就是v2的最迟发生时间。而8 = 10 - 2;(Min(vl(4) - w_{2,4}))
(3)活动ai = <vj,vk>的最早开始时间ee(i)
只有事件vj发生了,活动a才能开始。所以ai的最早开始时间就是vj的最早发生时间
迭代公式:
(4)活动ai = <vj,vk>的最晚开始时间el(k)
活动ai的开始时间需要保证不延误事件vk的最迟发生时间。所以活动ai的最晚开始时间el(i)等于事件vk的最迟发生时间vl(k)减去活动ai的持续时间w_{j,k};
迭代公式:
显然,对于关键活动而言,ee(i) = el(i),即活动ai没有任何拖延的余地,一旦拖延必定会影响工期。对于非关键活动,el(i) - ee(i)的值是该工程的期限余量,它是不拖延工期的情况下,活动ai可以拖延的时间
二.求解关键路径的过程
(1)对图中顶点进行拓扑排序,在排序中按拓扑排序求出每个事件的最早发生时间ve(i)
(2)按逆拓扑序列求出每个事件的最迟发生时间vl(i)
(3)求出每个活动的最早开始时间ee(i)
(4)求出每个活动的最晚开始时间el(i)
(5)找出ee(i)==el(i)的活动ai,即为关键活动。由关键活动形成的源点到汇点的每一条路径就是关键路径。(关键路径可能不止有一条)
3.关键路径的算法实现
图的存储结构:邻接表(需要进行拓扑排序)
引入以下数据结构
①一维数组ve[i],事件vi的最早发生时间
②一维数组vl[i],事件vi的最迟发生时间
③一维数组topo[i],记录拓扑排序序号
算法6.13关键路径算法
算法步骤:①调用拓扑排序算法,使拓扑排序保存在topo中
②将每个事件的最早发生时间ve[i]初始化为0,ve[i]=0
③根据topo中的值,按从前向后的拓扑次序,依次求每个事件的最早发生时间,循环 n次,执行以下操作
(1)取得拓扑排序中的顶点序号k,k = topo[i]
(2)用指针p依次指向k的每个邻接顶点,取得每个邻接顶点序号j = p->adjvex,依 次更新顶点j的最早发生时间ve[j]
if(ve[j]<ve[k] + p->weight) ve[j] = ve[k] + p->weight;
④将每个事件的最迟发生时间vl[j]初始化为汇点的最早发生时间,vl[i] = ve[n-1]
⑤根据topo中的值,按从后向前的逆拓扑次序,依次求每个事件的最迟发生时间,循 环n次,执行以下操作
(1)取得拓扑序列中顶点序号k,即k = topo[i]
(2)用指针p依次指向k的每个邻接顶点的序号j = p->adjvex,依次根据k的邻接点, 更新k的最迟发生时间vl[k]
if(vl[j]>vl[k] - p->weight) vl[j] = vl[k] - p->weight;
⑥判断某一活动是否为关键活动,循环n次,执行:对于每个顶点i,用指针依次指向i 的每个邻接顶点,取得每个邻接顶点的序号j = p->adjvex,分别计算活动<vi,vj>的 最早开始时间ee[i]和最迟开始时间el[i]
ee[i] = ve[i];el[i] = vl[j] - p->weight;
若ee[i]=el[i],输出弧<vi,vj>
算法描述:
status CriticalPath(ALGraph G)
{//以邻接表G存储图
if(!TopoLogicalOrder(G,topo)) return error;//判断G是否为AOE网(是否有环)
n = G.vexnum;
for(i = 0;i < n;i++) //初始化ve[i]为0
{
ve[i] = 0;
}
————————————————————————————————————————//按拓扑序列求所有事件的最早发生时间
for(i = 0;i < n;i++) //遍历顶点
{
k = topo[i]; //取得拓扑排序的顶点k
p = G.vertices[k].fristarc; //指针p负责指向顶点k的所有边
while(p) //遍历顶点k的所有边
{
j = p->adjvex; //j代表k通过边*p的邻接顶点
if(ve[j]<ve[k] + p->weight) //迭代更新顶点j的最早发生时间
ve[j] = ve[k] + p->adjvex;
p = p->nextarc; //检查下一边
}
}
for(i = 0;i < n;i++) //给每个事件的最迟发生时间初始化为ve[n-1]
{
vl[i] = ve[n-1];
}
————————————————————————————————————————//按逆拓扑序列求所有事件的最迟发生时间
for(i = n-1;i >= 0;i--) //逆序遍历拓扑序列
{
k = topo[i]; //取得逆拓扑序列的顶点k
p = G.vertices[k].fristarc; //指针p负责指向顶点k的所有边
while(p) //遍历k的邻边
{
j = p->adjvex; //j表示k通过*p的邻接顶点
if(vl[k] > vl[j] - p->weight)//迭代更新顶点k的最迟发生时间
vl[k] = vl[j] - p->weight;
p = p->nextarc; //p指向下一边
}
}
————————————————————————————————————————//求活动的最早发生时间和最迟发生时间
for(i = 0;i < n;i++) //遍历顶点
{
p = G.vertices[i].fristarc; //p指向顶点i的邻边
while(p) //遍历i的所有邻边
{
j = p->adjvex; //j表示k通过*p的邻接顶点
ee[i] = ve[i] //求出活动的最早发生时间
el[i] = vl[j] - p->weight; //求出活动的最迟发生时间
if(ee[i]==el[i]) //判断是否为关键路径
cout << G.vertices[i].data << G.vertices[j].data;//输出关键路径
p = p->nextarc; //检查下一边
}
}
}
算法分析:
1.再求每个事件的最早和最迟发生时间及活动的最早和最晚开始时间时,都要对所有顶点及每个顶点的边结点进行检查,所以求取关键路径的算法时间复杂度为O(n + e)
2.若网中有几条关键路径,单单提高一条关键路径上的关键活动并不能缩短工期,必须提高所有关键路径上关键活动的速度才能有效缩短工期。