目录
6.4.4 关键路径
(重点内容:深搜和广搜;图的基本概念及其性质;图的存储结构及特性;基于存储结构上的遍历和各种应用;图的相关算法的思想)
6.1 图的基本概念
·Def:图G由顶点集V和遍集V组成,记为G=(V,E);V表示顶点(数据元素)的有穷非空集合;E表示边的有穷集合。(图不能为空,图中不能一个顶点也没有,边集可以为空,此时只有顶点而没有边)
·有向图:E为有向边(弧)的有限集合,有向边是顶点的有序对,记为<v,w>,称为从v到w的弧,或v邻接到w
·无向图:E是无向边的有限集合
·完全图:图中任意两个顶点都一条边相连。对于无向图完全图有n(n-1)/2条边;有向完全图有n(n-1)条弧
·网:边上带有权值的图称为网,也叫带权图
·顶点的度:与该顶点相关联的边的数目,记为TD(v),有向图中,顶点的度为该点的入度ID(v)与出度OD(v)之和
·路径:接续的边构成的顶点序列,路径上的边的数目/权值之和为路径长度
·回路:第一个顶点与最后一个顶点相同的路径称为回路或环(若图有n个顶点,并且有大于n-1条边,则此图一定有环);除路径起点和终点可以相同外,其余顶点均不相同的路径为简单路径,除路径起点和终点相同外,其余顶点均不相同的路径为简单环
·连通图(强连通图):在无(有)向图G=(V, {E} )中,若对任何两个顶点v、u都存在从v到u的路径,则称G是连通图(强连通图)
·连通分量(强连通分量):无向图G的极大连通子图称为G的连通分量。
极大连通子图是该子图是G连通子图,将G的任何不在该子图中的顶点加入,子图不再连通;极小连通子图则是在改子图中删除任一条边则不再连通(有向图中同理)
·生成树、森林:生成树为包含无向图G所有顶点的极小连通子图;生成森林是对非连通图,由各个连通分量的生成树的集合。
6.2 图的存储及操作
6.2.1 邻接矩阵
·Def:用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息,这个存储顶点之间的邻接关系的二维数组称为邻接矩阵
结点数为n的图G=(V,E)的邻接矩阵A是n×n的,定义当(vi,vj)∈E,则A[i][j]=1,否则=0;而对于带权图,邻接矩阵中应存放改边对应的权值,用∞代表两顶点间不存在边:
·特点:
1)无向图的邻接矩阵是对称的,因此实际存储时只需存储上or下三角矩阵元素;顶点i的度=第i行(列)中1的个数;
2)有向图的邻接矩阵可能是不对称的,顶点的出度=第i行元素之和,顶点的入度=第i列元素之和,顶点的度=第i行元素之和+第i列元素之和
3)完全图的邻接矩阵中,对角元素为0,其余1
·邻接矩阵存储结构定义:
#define MaxInt 32767 //表示极大值,即∞
#define MVNum 1 00 //最大顶点数
typedef char VerTexType; //设顶点的数据类型为字符型
typedef int ArcType; //假设边的权值类型为整型
typedef struct{
VerTexType vexs[MVNum]; //顶点表
ArcType arcs[MVNum][MVNum];//邻接矩阵
int vexnum, arcnum; //图的当前点数和边数
}AMGraph; // Adjacency Matrix Graph
·无向网图的创建:
(1)输入总顶点数和总边数。
(2)依次输入点的信息存顶点表中。
(3)初始化邻接矩阵,使每个权值初始化为极大值。
(4)构造邻接矩阵。
/*建立无向网图的邻接矩阵表示*/
void CreateMGraph (MGraph *G )
{
int i,j,k,w;
printf ( "输入顶点数和边数:\n") ;
scanf ("&d, d", &G->numVertexes, &G->numEdges) ;/*输入顶点数和边数*/
for(i = 0;i <G->numVertexes;i++) /*读入顶点信息,建立顶点表*/
scanf ( &G->vexs[i] ) ;
for (i = 0;i <G->numVertexes;i++ )
for(j=0;j <G->numVertexes;j++ )
G->arc[i][j]- INFINITY; /* 邻接矩阵初始化*/
for (k .0;k <G->numEdges;k++) /* 读入numEdges条边,建立邻接矩阵*/
{
printf ("输入边(vi,vj)上的下标1,下标和权w:\n") ;
scanf ("ed,td,各d",G主,&j,&W) ; /*输入边(vi,vj)上的杈w */
G->arc[i][j1-w;
G->arc[j][i]= G->arc[i][j]; /*因为是无向圈,矩阵对称*/
}
}
6.2.2 邻接表
当一个图为稀疏图时,使用邻接矩阵法显然会浪费大量的存储空间,而邻接表法结合顺序存储和链式存储,大大减少了这种不必要的浪费
·Def:图中顶点用一维数组或单链表来存储(数组更易获取信息较为方便),每个顶点除了存储本身外,还需要存储指向第一个邻接点的指针来查找该顶点的边信息;每个顶点的所有邻接点构成线性单链表,在无向图中称为顶点的边表,有向图中称为顶点作为弧尾的出边表;所以在邻接表中存在两种结点:顶点表结点和边表结点
·邻接表的结构:
·特点:
1)无向图的邻接表不唯一,若有n个顶点,e条边,则需要n个顶点表结点和2e个边表结点,即O(n+2e),适合存储稀疏图;顶点的度为顶点边表中结点的个数
2)有向图中所需的存储空间为O(n+e),顶点的出度为顶点出边表中结点个数,而入度需要遍历所有的邻接表,找到邻接点域值是i-1的结点个数,介于此可用逆邻接表的方式来迅速求解顶点的入度
·邻接表的结构定义:
typedef char VertexType; /* 顶点类型应由用户定义*/
typedef int EdgeType; /*边上的权值类型应由用户定义*/
typedef struct EdgeNode /*边表结点*/
{
int adjvex; /*邻接点城,存储该顶点对应的下标*/
EdgeType we ight; /*用于存储权值,对于非网图可以不需要*/
struct EdgeNode *next; /*链域,指向下一个邻接点 */
}EdgeNode;
typedef struct VertexNode /* 顶点表结点*/
{
VertexType data; /*顶点域,存储顶点信息+/
EdgeNode * firstedge; /*边表头指针*/
}Ver texNode, AdjList [MAXVEX] ;
typedef struct
{
AdjList adjList;
int numVertexes, numEdges; /*图中当前顶点数和边数*/
}GraphAdiList;
·创建无向网:
(1)输入总顶点数和总边数。
(2)建立顶点表
依次输入点的信息存入顶点表中
使每个表头结点的指针域初始化为NULL
(3)创建邻接表
依次输入每条边依附的两个顶点
确定两个顶点的序号和j,建立边结点
将此边结点分别插入到vi和vj对应的两个边链表的头部
6.2.3 十字链表
对于有向图来说,邻接表易算出度而难算入度,而逆邻接表易算入度难算出度,而十字链表可以有效的将二者结合
·Def:重新定义顶点表和边表结点结构,firstin表示入边表头指针,指向该顶点入边表的第一个结点,firstrout表示出边表头指针,指向改顶点的出边表中的第一个结点;tailvex是弧起点在顶点表的下标,headvex是弧终点在顶点表的下标,headlink是入边表的指针域,指向终点相同的下一条边,taillink是边表指针域,指向起点相同的下一条边
·十字链表结构:
6.2.4 邻接多重表
对于无向图来说,邻接表容易求得顶点和边的信息,但对于一些操作如删除一条边则需要找到表示这条边的两个结点,比较麻烦,所以引入邻接多重表
·Def: 同样仿照十字链表重新定义边表结点的结构,顶点结点保持不变
·邻接多重表的结构:
6.3 图的遍历
6.3.1 深度优先搜索DFS
·基本思想:DFS其实就是一个递归的过程,类似于树的先序遍历。它从某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发访问任意顶点w,再访问w的....重复上述过程。直到不能再向下访问时,退回最近被访问的顶点,若它还有其他邻接点未被访问,则继续搜索过程,直到图中所有顶点均被访问为止
·算法:
typedef int Boolean; /* Boolean是布尔类型,其值是TRUE或FALSE */
Boolean visited [MAX]; /*访问标志的数组*/
/*邻接矩阵的深度优先递归算法*/
void DFS (MGraph G,int 1)
{
int j;
visited[i] = TRUE;
printf (“%C ",G.vexs[1]) ; /*打印顶点,也可以其他操作*/
for (j=0;j < G.numVertexes; j++ )
if (G.arc[i][j] 16& !visited[j] )
DFS(G, j) ; /*对为访问的邻接顶点递归调用*/
/*邻接矩阵的深度遍历操作*/
void DFSTraverse (MGraph G)
{
int i;
for(i=0; i< G.numVertexes; 1++ )
visited[i]=FALSE; /*初始所有顶点状态都是来访问过状态*/
for(i=0; 1< G. numVertexes; i++)
if(!visited[i]); /*对来访问过的顶点调用DES,若是连通图,只会执行一次*/
DFS(G, i);
}
如果图是邻接表结构,遍历操作中代码几乎相同,只是在递归中因将数组换成了链表而不同
·算法分析:用邻接矩阵来表示图,遍历图中每一个顶点都要从头扫描该顶点所在行,时间复杂度为O(n2);用邻接表来表示图,虽然有2e个表结点,但只需扫描e个结点即可完成遍历,加上访问n个头结点的时间,时间复杂度为O(n+ e)。
所以稠密图适于在邻接矩阵.上进行深度遍历;稀疏图适于在邻接表上进行深度遍历
6.3.2 广度优先搜索BFS
·基本思想:BFS是一种分层查找的过程,类似于二叉树的层序遍历,从图的某一顶点出发,依次访问该点的所有邻接顶点V1 V2, ... Vn再按这些顶点被访问的先后次序依次访问与它们相邻接的所有未被访问的顶点,重复过程直至所有顶点被访问为止。
可以看出广度搜索过程每向前走一步可能访问一批顶点,不会有回退的过程,因此它不是递归的,该算法需要借助辅助队列来记忆正在访问的顶点的下一层顶点
·算法:
/*邻接表的广度遍历算法*/
void BFSTraverse (GraphAdjList GL)
{
int i;
EdgeNode *P;
Queue Q;
for(i=0;i < GL->numVertexes; i++)
visited[i] - FALSE;
InitQueue (&Q) ;
for(i=0; i< GL->numVertexes; i++)
{
if( !visited[i])
{
visited[i]=TRUE;
printf (&C ",GL->adjList[i].data) ; /*打印顶点,也可以其他操作*/
EnQueue (&Q,i) ;
while ( !QueueEmpty(Q) )
{
DeQueue (6Q,6i) ;
P =GL->adjList [i].firstedge; /*找到当前顶点边表链表头指针*/
while (p )
{
if (!visited[p->adjvex]) /* 若此顶点未被访问*/
{
visited [p->adjvex]=TRUE;
printf ("%c",GL->adjList [p->adjvex].data) ;
EnQueue (&Q,p->adjvex); /* 将此顶点入队列*/
}
P = P->next; /*指针指向下一个邻接点*/
}
}
}
}
}
·算法分析:若使用邻接矩阵,则BFS对于每-一个被访问到的顶点,都要循环检测矩阵中的整整一行( n个元素),总的时间代价为O(n2);若用邻接表来表示图,虽然有2e个表结点,但只需扫描e个结点即可完成遍历,加上访问n个头结点的时间,时间复杂度为O(n+e)。
6.4 图的应用
6.4.1 最小生成树
·生成树:所有顶点均由边连在一起,但不存在回路的图,一个图可以有许多不同的生成树。
其特点有:
- 生成树是图的极小连通子图,去掉一条边则非连通,再加一条边必然形成回路
- 生成树的顶点个数与图的顶点个数相同
- n个顶点的连通图的生成树有n-1条边;n个顶点n-1条边的图不一定是生成树
- 生成树中任意两点的间的路径唯一
·最小生成树:对于给定的无向网图,在该网的所有生成树中,使得各边权值之和最小的那棵生成树称为最小生成树
性质:
- 最小生成树的边数为顶点数减一
- 最小生成树不唯一,但是其对应边的权值和总是唯一的,且为最小
- 当图中各边权值互不相等时,图的最小生成树是唯一的;若无向连通图的边数比顶点少一,即该图本身就是一棵树时,其最小生成树就是它本身
·MST性质:设N= (V,E) 是一个连通网, U是顶点集V的一个非空子集。若边(u,v)是一条具有最小权值的边,其中u∈U,v∈(V-U)则必存在一棵包含边(u, v)的最小生成树。
·通用算法:
在生成树的构造过程中,图中n个顶点分属两个集合:
已落在生成树上的顶点集:U
尚未落在生成树上的顶点集: V-U
接下来则应在所有连通U中顶点和V-U中顶点的边中选取权值最小的边
GENERIC MST (G) {
T=NULL;
while T术形成一棵生成树;
do找到一条最小代价边(u, v)并且加入T后不会产生回路;
T=T∪{u,v};
}
基于以上性质的最小生成树算法主要有prim算法和kruskal算法
·Prim算法:
➢设N=(V, E)是连通网,TE是N上最小生成树中边的集合;
➢初始令U={u0}, (u0∈V), TE={};
➢在所有u∈U, V∈V-U的边(u, v)∈E中,找一条代价最小的边(u0, v0);
➢将(u0, v0)并入集合TE,同时 v0并入U;
➢重复上述操作直至U=V为止,则T=(V, TE)为N的最小生成树;
简单的实现如下,prim算法的时间复杂度为0(|V|2),不依赖于E,因此适用于求解边稠密的图的最小生成树
void Prim(G,T) {
T=Ø; //初始化空树
U={w}; //添加任一顶点w
while((V-U)!=Ø) { //若树中不含全部顶点
设{u,v)是使u∈U与v∈{V-U},且权值最小的边;
T=T∪{ (u,v) } ; //边归入树
U=U∪{v} ; //顶点归入树
}
}
·Kruskal算法:该算法与prim算法从顶点开始扩展不同,它按权值的递增次序选择合适的边来构造,但所选取的边不能构成环
➢设连通网N= (V, E),令最小生成树初始状态为只有n个顶点而无边的非连通图T=(V, { }),
每个顶点自成一个连通分量;
➢在E中选取代价最小的边,若该边依附的顶点落在T中不同的连通分量上(即无环)
则将此边加入到T中;否则舍去此边,选取下一条代价最小的边;
➢依此,直至T中所有顶点都在同一连通分量上为止,即n-1条边;
简单实现如下,通常在k算法中采用堆来存放边的集合,所以每次选择最小权值的边只需要O(log|E|),由于生成树中所有的边可视为一个等价类,从而构造T的时间复杂度为O(|E|log|E|),所以k算法适合边稀疏而顶点较多的图
void Kruska1 (V,T) {
T=V ; //初始化树T.仅含顶点
numS=n; //连通分量数
while (numS>1) { //若连通分数大于1
从E中取出权值最小的边(v,u) ;
if(v和u属于T中不同的连通分量) {
T=T{(v,u)}; //将此边加入生成树中
numS-- ; //连通分量数减1,
}
}
6.4.2 最短路径
·Dijkstra算法--解决单源最短路径问题
首先将V分成两组S和T集合:
S为已求出最短路径的顶点的集合; T=V-S 为尚未确定最短路径的顶点集合。
再将T中顶点按最短路径递增的次序加入到S中,保证从源点V到S中各顶点的最短路径长度都不大于从V到T中任何顶点的最短路径长度。
与此同时设置两个辅助数组dist[]和path[]:
dist[]: 记录从源点v0到其他各顶点当前的最短路径长度,它的初态为:若从v0到vi有弧,则dist[i]为弧上的权值;否则置dist[i]为∞。
path[]: path[i]表示从源点到顶点i之间的最短路径的前驱结点。在算法结束时,可根据其值追溯得到源点v0到顶点vi的最短路径。
算法思想:
1)初始化S={V}, T= {其余顶点}。T中顶点对应的距离值用辅助数组dist[]存放,若<v0, vi>存在,dist[i]为其权值,否则为∞;
2)从T中选取出一个其距离值最小的顶点vj ,满足dist[j]=Min{dist[i]|vi∈T},加入S;
3)对T中顶点的距离值进行修改,若加进vj,作中间顶点,从v0,到vi的距离值比不加vj的路径要短,则修改此距离值;
4)重复2)3),操作共n-1次,直到S= V为止
·Floyd算法--解决所有顶点间最短路径问题
解决所有顶点间的最短路径问题,可以每次以一个顶点为源点,重复执行dijkstra算法n次,算法复杂度为n×O(n^2),这样就麻烦了;下面介绍的floyd算法可较好的解决这个问题,但时间复杂度上依然是O(n^3)
算法思想:
6.4.3 拓扑排序
·AOV网:用一个有向无环图表示一个工程的各子工程及其相互制约的关系,其中以顶点表示活动,弧表示活动之间的优先制约关系,称这种有向图为顶点表示活动的网,简称AOV网(Activity On Vertex)。AOV网中不允许有回路,若存在回路表明某项活动以自己为先决条件,这显然是不可能的
·Def:拓扑排序就是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面,每个AOV网都有一个或多个拓扑排序序列
·拓扑排序的方法:
- 从AOV网中选择一个没有前驱的顶点并输出
- 从网中删除该顶点和所有以它为起点的有向边
- 重复上述步骤,直到全部顶点输出或图中不存在无前驱的顶点
·算法实现:
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; //拓扑排序成功
}
在输出每个顶点的同时还要删除以该顶点为起点的所有边,所以拓扑排序的时间复杂度为O(n+e)
【注意】:
①若一个顶点有多个直接后继,则拓扑排序的结果通常不唯一;但若各顶点已经排在一个线性有序的序列中,每个顶点有唯一的前驱后继关系,则拓扑排序结果唯一
②由于AOV网中各顶点地位平等,因此按拓扑排序的结果重新编号,生成AOV网的新的邻接矩阵可以是三角矩阵;对于一般的图,若其邻接矩阵为三角矩阵,则存在拓扑排序,反之不一定
6.4.4 关键路径
·AOE网:用一个有向图表示一个工程的各子工程及其相互制约的关系,其中以弧表示活动,顶点表示活动的开始或结束事件,称这种有向图为边表示活动的网,简称AOE网(Activity On Edge),AOE网中的边有权值。
·关键路径:路径上各个活动所持续的时间之和为路径长度,从源点到终点的所有路径中,具有最大路径长度的路径称为关键路径,关键路径上的活动称为关键活动
·几个参数:
1)事件vk的最早发生时间ve(k):指的是从源点v1到顶点vk 的最长路径长度,它决定了所有从vk开始的活动能够开工的最早时间
从ve(源点) = 0 开始向前递推,ve(k)=Max{ve(j)+weight<vj,vk,>}
2)事件vk的最迟发生时间vl(k):指在不推迟整个工程完成的前提下,保证其后继事件vj在其最迟发生时间vl(j)能够发生时,该事件最迟必须发生的时间
从vl(汇点) = ve(汇点) 开始向后递推vl(k)=Min{vl(j)-weight<vk,vj>}
3)活动ai的最早开始时间e(i):指该活动弧的起点所表示的事件的最早发生时间,若<vk,vj>表示ai,则e(i)=ve(k)
4)活动ai的最迟开始时间l(i):指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差,若<vk,vj>表示ai,则l(i)=vl(j)-weight(vk,vj)
5)活动ai的时间余量:在不增加完成整个工程所需总时间的情况下,ai可以拖延的时间,d(i)=l(i)-e(i),关键活动就是时间余量为0的活动
【注意】:
①关键路径上的所有活动都是关键活动,它是决定工期的关键因素,因此可以通过加快关键活动来缩短整个工程的工期。但缩短的程度有限,一旦缩短到一定程度关键活动可能变成非关键活动
②关键路径不唯一,对于包含多条关键路径的网,只提高一条关键路径的关键活动速度并不能提高整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能缩短工期
·求解关键路径步骤:
1)从源点出发,令ve(源点)=0,按拓扑有序求其余顶点的最早发生时间ve;
2)从汇点出发,令vl(汇点)=ve(汇点),按逆拓扑有序求其余顶点的最迟发生时间vl;
3)根据各顶点的ve()值求所有弧的最早开始时间e;
4)根据各顶点的vl(值求所有弧的最迟开始时间l;
5)求AOE网中所有活动的差额d(),找出所有d()=0的活动构成关键路径;
·逆推法求解关键路径: