7.1 开场白
7.2 图的定义
图(Graph)是有顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E),其中,G表示一个图,V 是图G中顶点的集合,E是图G中边的集合。
注:在定义中,若V是顶点的集合,则强调了顶点集合V有穷非空。
在图中,任意两个顶点之间都可能有关系,顶点之间的逻辑关系用变来表示,边集可以是空的。
7.2.1 各种图定义
无向边:若顶点
vi
到
vj
之间的边没有方向,则称这条边是无向边(Edge),用无序偶对(
vi
,
vj
)表示。如果图中任意两个顶点之间的边都是无向边,则称该图是无向图。
有向边:若从顶点
vi
到
vj
的边有方向,则称这条边为有向边,也称弧(Arc)。用有序偶对<
vi
,
vj
>,
vi
称为弧尾(Tail),
vj
称为弧头(Head)。如果图中任意两个顶点之间的边都是有向边,则称该图为有向图。
在图中,若不存在顶点到其自身的边,其同一条边不重复出现,则称这样的图为简单图。
在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。含有 n 个结点的无向完全图有
n(n+1)2
条边。
在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。含有 n 个顶点的有向完全图有 n*(n-1)条边。
有很少条边或弧的图称为稀疏图,反之称为稠密图。
有些图的边或弧具有与它相关的数字,这种与图的边或弧相关的树叫做权(Weight)。这种带权的图通常称为网(Network)。
假设有两个图G=(V,{E})和G’=(V’,{E’}),如果V’⊆ V,且E’⊆ E,则称G’为G的子图(Subgraph).
7.2.2 图的顶点与边间的关系
对于无向图G=(V,{E}),如果边(v,v’)∈E,则成顶点v和v’互为邻接点(Adjacent),即v和v’相邻接。边(v,v’)依附于顶点v和v’,或者说(v,v’)与顶点v和v’相关联。顶点的度(degree)是和v相关联的边的数目,记为TD(V)。推论得到:边数其实就是各定点度数和的一半。
对于有向图G=(V,{E}),如果弧 < v,v’>∈E,则称顶点v邻接到顶点v’,顶点v’邻接自顶点v。弧< v,v’>和顶点v,v’相关联。以顶点v为头的弧的数目称为v的入度(InDegree),记为ID(v);以v为尾的弧的数目称为v的出度(OutDegree),记为OD(v);顶点v的度为TD(v)=ID(v)+OD(v)。
无向图G=(V,{E})中从顶点v到顶点v’的路径(Path)是一个顶点序列(v=
vi,0,vi,1,...,vi,m=v′
,其中,
(vi,j−1,vi,j)∈E
,1<= j <= m.路径的长度是路径上的边或弧的数目。第一个顶点到最后一个顶点相同的路径称为回路或环。序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环。
7.2.3 连通图相关术语
在无向图G中,如果从顶点v到顶点v’有路径,则称v和v’是连通的。如果对于图中任意两个顶点
vi、vj∈V
,
vi、vj
都是连通的,则称G是连通图。
无向图中的极大连通子图称为连通分量。注意连通分量的概念,它强调:
- 要是子图;
- 子图要是连通的;
- 连通子图含有极大顶点数;
- 具有极大顶点数的连通子图包含依附于这些顶点的所有边。
在有向图G中,如果对于每一对
vi、vj∈V、vi!=vj
,从
vi
到
vj
和从
vj
到
vi
都存在路径,则称G是强连通图。有向图中的极大强连通子图称作有向图。
所谓的一个连通图的生成树是一个极小的连通子图,它含有图中全部的 n 个顶点,但只有足以构成一棵树的 n-1条边。推导的:如果一个图有n个顶点和小于n-1条边,则是非连通图;如果它多于n-1边条,必定构成一个环。但是,有n-1条边不一定会生成树。
如果一个有向图恰有一个顶点的入度为0,其余顶点的入度均为1,则是一棵有向树。一个有向图的生成森林由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。
7.2.4 图的定义与术语总结
7.3 图的抽象数据类型
ADT 图(Graph)
Data
顶点的有穷非空集合和边的集合
Operation
CreateGraph(*G,V,VR): 按照顶点集V和弧集的定义构造图G。
DestroyGraph(*G):图G存在则销毁。
LocateVex(G,u):若图G中存在顶点u,则返回图中的位置。
GetVex(G,v):返回图G中顶点v的值。
PutVex(G,v,value):将图G中顶点v赋值给value。
FirstAdjVex(G,*v):返回顶点v的一个邻接顶点,若顶点在G中无邻接顶点返回空。
NextAdjVex(G,v,*w):返回顶点v相对于顶点w的下一个邻接顶点,若w是v的最后一个邻接点,则返回空。
InsertVex(*G,v):在图G中增添新顶点v。
DeleteVex(*G,v):删除图G中顶点v及其相关的弧。
InsertArc(*G,v,w):在图G中增添弧<v,w>,若G是无向图,还需要增添对称弧<w,v>。
DeleteArc(*G,v,w):在图中删除弧<v,w>,若G是无向图,还需要删除对称弧<w,v>。
DFSTraverse(G):对图G中进行深度优先遍历,在遍历过程中对每个顶点调用。
HFSTraverse(G):对图G中进行广度优先遍历,在遍历过程对每个顶点调用。
endADT
7.4 图的存储结构
五种不同的存储结构。
7.4.1 邻接矩阵
图的邻接矩阵(Adjacency Matrix)存储方式是用两个数组表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
typedef int EdgeType;
#define MAXVEX 100
#define INFINITY 65535
typedef struct
{
VertexType vexs[MAXVEX]; //顶点表
EdgeType arc[MAXVEX][MAXVEX]; //邻接矩阵,可看做表
int numVertexes, numEdges; //图中当前的顶点数和边数
}MGraph;
//建立无向网图的邻接矩阵表示
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][i] = INFINITY; //邻接矩阵初始化
for (k = 0; k < G->numEdges; ++k)
{
printf("输入边(vi,vj)上的下标i和下标j和权 w:\n");
scanf("%d,%d,%d,", &i, &j, &w);
G->arc[i][j] = w;
G->arc[j][i] = G->arc[i][j];
}
}
从图中可以看到,n个顶点e条边的无向网图的创建,时间复杂度为O( n+n2+e) ,其中对邻接矩阵G.arc的初始化花费了O( n2 )的时间。
7.4.2 邻接表
邻接矩阵对于边数相对顶点数较少的图,存在存储空间的极大浪费。
把数组与链表相结合的方法称为邻接表(Adjacency List)。
邻接表的处理办法如下:
- 图中顶点用一个一维数组存储,当然,顶点也可以用链表来存储,不过数组可以较容易第读取顶点信息,更加方便。另外,对于顶点数组中,每个数据元素还需要存储指向第一个邻接点的指针,以便于查找该顶点的边信息。
- 图中每个顶点vi的所有邻接点构成一个线性表,由于邻接点的个数不定,所有用单链表表示,无向图称为顶点vi的边表,有向图称为顶点vi作为弧尾的出边表。对于带权值的图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可。
一个有向图的逆邻接表:即对每个顶点vi都建立一个链接为vi为弧头的表。
typedef char VertexType;
typedef int EdgeType;
typedef struct EdgeNode //边表结点
{
int adjvex; //邻接点域,存储该顶点对应的下标
EdgeType weight; //用于存储权值
struct EdgeNode *next; //链域,指向下一个邻接点
}EdgeNode;
typedef struct VertexNode //顶点表结点
{
VertexType data; //顶点域,存储顶点信息
EdgeNode *firstedge; //边表头指针
}VertexNode, AdjList[MAXVEX];
typedef struct
{
AdjList adjList;
int numVertexes, numEdges; //图中当前顶点数和边数
}GraphAdjList;
//建立图的邻接表结构
void CreateALGraph(GraphAdjList *G)
{
int i, j, k;
EdgeNode *e;
printf("输入顶点数和边数:\n");
scanf("%d, %d",&G->numVertexes, &G->numEdges); //输入定点数和边数
for (i = 0; i < G->numVertexes; ++i) //读入顶点信息,建立顶点表
{
scanf(&G->AdjList[i].data); //输入顶点信息
G->adjList[i].firstedge = NULL; //将边表置为空表
}
for (k = 0; k < G->numEdges; ++k) //建立边表
{
printf("输入边(vi,vj)上的顶点序号:\n");
scanf("%d,%d,%d", &i,&j);
e = (EdgeNode *)malloc(sizeof(EdgeNode)); //向内存申请空间生成边表结点
e->adjvex = j; //邻接序号为j
e->next = G->adjList[i].firstedge; //将e指针指向当前顶点指向的结点
G->adjList[i].firstedge = e; //将当前顶点的指针指向e
e = (EdgeNode *)malloc(sizeof(EdgeNode)); //向内存申请空间生成边表结点
e->adjvex = i; //邻接序号为i
e->next = G->adjList[j].firstedge; //将e指针指向当前顶点指向的结点
G->adjList[j].firstedge = e; //将当前顶点的指针指向e
}
}
7.4.3 十字链表
邻接表是有缺陷的,想了解入度就必须要遍历整个表才知道。反之,逆邻接表解决了入度却解决不了出度。
把邻接表和逆邻接表结合起来,整合在一起就是:十字链表。
7.4.4 邻接多重表
对于无向图的邻接表,删除一条边时,需要删除两个结点,比较麻烦。仿照十字链表的方式,对边表结点的结构进行一些改造。
重新定义的边表结点结构如图
其中,ivex和jvex是与某条边依附的两个顶点在顶点表的下标。ilink指向依附顶点ivex的下一条边,jlink指向依附顶点jvex的下一边。这就是邻接多重表结构。
7.4.5 边集数组
边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素有一条边的起点下标(begin)、终点下标(end)和权(weight)组成。显然边集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫面整个边数组,效率并不高。因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作。
7.5 图的遍历
从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历(Traversing Graph)。
7.5.1 深度优先遍历(Depth_First_Search)
也称为深度优先搜索,简称为(DFS)。它从图中某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相通的顶点都被访问到。若图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点做起始点,重复上述过程,直至图中所有顶点都被访问到为止。
如果用的是邻接矩阵的方式,则代码如下:
//用邻接矩阵的方式
typedef int Boolean; //Boolean是布尔类型,其值是TRUE或FALSE
Boolean visited[MAX]; //访问标志的数组
//邻接矩阵的深度优先递归算法
void DFS(MGraph G, int i)
{
int j;
visited[i] = TRUE;
printf("%c", G.vexs[i]); //打印顶点,也可以其他操作
for (j = 0; j < G.numVertexes; ++j)
if (G.arc[i][j] == 1 && !visited[j])
DFS(G,j); //对未访问的邻接顶点递归调用
}
//邻接矩阵的深度遍历操作
void DFSTraverse(MGraph G)
{
int i;
for (i = 0; i < G.numVertexes; ++i)
visited[i] = FALSE; //初始所有顶点状态都是未访问过状态
for (i = 0; i < G.numVertexes; ++i)
if (!visited[i]) //对未访问的邻接顶点递归调用DFS,若是连通图,只会执行一次
DFS(G, i);
}
如果图结构是邻接表结构,函数代码几乎相同,只是在递归函数中因为将数组换成了链表而有不同,代码如下:
//邻接表的深度优先递归算法
void DFS(GraphAdjList GL, int i)
{
EdgeNode *p;
visited[i] = TRUE;
printf("%c", GL->adjList[i].data); //打印顶点,也可以其他操作
p = GL->adjList[i].firstedge;
while (p)
{
if (!visited[p->adjvex])
DFS(GL, p->adjvex); //对未访问的邻接顶点递归调用
p = p->next;
}
}
//邻接表的深度遍历操作
void DFSTraverse(GraphAdjList GL)
{
int i;
for (i = 0; i < GL->numVertexes; ++i)
visited[i] = FALSE; //初始所有顶点状态都是未访问过状态
for (i = 0; i< GL->numVertexes; ++i)
if (!visited[i]) //对未访问的邻接顶点递归调用DFS,若是连通图,只会执行一次
DFS(GL, i);
}
对比两个不同存储结构的深度优先遍历算法,对于n个顶点e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此都需要O( n2) 的时间,而邻接表做存储结构时,找邻接点所需的时间取决于顶点和边的数量,所以是O(n+e)。显然对于点多变少的稀疏图来说,邻接表结构使得算法在实践效率上大大提高。
7.5.2 广度优先遍历(Breadth_First_Search)
又称为广度优先搜索,简称BFS。类似与树的层序遍历。
//邻接矩阵的广度遍历算法
void BFSTraverse(MGraph G)
{
int i, j;
Queue Q;
for (i = 0; i < G.numVertexes; ++i)
visited[i] = FALSE;
InitQueue(&Q); //初始化一辅助用的队列
for (i = 0; i < G.numVertexes; ++i) //对每一个顶点做循环
{
if ( !visited[i]) //若是未访问过就处理
{
visited[i] = TRUE; //设置当前顶点访问过
printf("%c", G.vexs[i]); //打印顶点,也可以其他操作
EnQueue(&Q, i); //将次顶点如队列
while (!QueueEmpty(Q)) //若当前队列不为空
{
DeQueue(&Q, &i); //将队中元素出队列,赋值给i
for (j = 0; j < G.numVertexes; ++j)
{
//判断其他顶点若与当前顶点存在边且未访问过
if (G.arc[i][j] == 1 && !visited[j])
{
visited[j] = TRUE; //将找到的次顶点标记为已访问
printf("%c", G.vexs[j]); //打印顶点
EnQueue(&Q, j); //将找到的次顶点入队列
}
}
}
}
}
}
对于邻接表的广度优先遍历,代码与邻接矩阵差异不打,代码如下:
//邻接表的广度遍历算法
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", G.vexs[i]); //打印顶点,也可以其他操作
EnQueue(&Q, i); //将次顶点如队列
while (!QueueEmpty(Q)) //若当前队列不为空
{
DeQueue(&Q, &i); //将队中元素出队列,赋值给i
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; //指针指向下一个邻接点
}
}
}
}
}
对比图的深度优先遍历和广度优先遍历算法,它们在时间复杂度上是一样的,不同之处仅仅对顶点访问的顺序不同。可见两者在全图遍历上是没有优劣之分,只是视不同的情况选择不同的算法。
深度优先更适合目标比较明确,已找到目标为主要目的的情况,而广度优先更适合在不断扩大遍历范围时找到相对最优解的情况。
7.6 最小生成树
把构造连通图网的最小代价生成树称为最小生成树(Minimum Cost Spanning Tree)。找连通网的最小生成树,有两种经典算法,普里姆算法和克鲁斯卡尔算法。
7.6.1 普里姆算法(Prim)算法
邻接矩阵表示
//Prim 算法生成最小生成树
void MiniSpanTree_Prim(MGraph G)
{
int min, i, j, k;
int adjvex[MAXVEX]; //保存相关顶点下标
int lowcost[MAXVEX]; //保存相关顶点间边的权值
lowcost[0] = 0; //初始化第一个权值为0,即v0加入生成树。lowcost的值为0,在这里就是此下标的顶点已经加入生成树
adjvex[0] = 0; //初始化第一个顶点下标 0
for (i = 1; i < G.numVertexes; ++i)
{
lowcost[i] = G.arc[0][i]; //将v0顶点与之有边的权值存入数组
adjvex[i] = 0; //初始化都为v0的下标
}
for (i = 1; i < G.numVertexes; ++i)
{
min = INFINITY; //初始化最小权值为无穷
//通常设置为不可能的大数字如32768、65535等
j = 1; k = 0;
while (j < G.numVertexes) //循环全部顶点
{
if (lowcost[j] != 0 && lowcost[j] < min) //如果权值不为0,且权值小于min
{
min = lowcost[j]; //则让当前权值成为最小值
k = j; //将当前最小值的下标存入k
}
++j;
}
printf("(%d, %d)",adjvex[k], k); //打印当前顶点边中权值最小边
lowcost[k] = 0; //将当前顶点的权值设置为0,表示此顶点已经完成任务
for (j = 1; j < G.numVertexes; ++j) //循环所有顶点
{
if (lowcost[j] != 0 && G.arc[k][j] < lowcost[j]) //若下标为k顶点各边权值小于此前这些顶点未被加入生成数权值
{
lowcost[j] = G.arc[k][j]; //将较小权值存入lowcost
adjvex[j] = k; //将下标为k顶点存入adjvex
}
}
}
}
普里姆算法的实现定义:
假设N = (V,{E})是连通网那个,TE是 N 上最小生成树中边的集合。算法从U={u0}(u0∈V),TE = {}开始。重复执行下述操作:在所有u∈U, v∈V-U的边(u,v)∈E中找一条代价最小的边(u0,v0)并入集合TE,同时v0并入U,直至U=V为止。此时TE中必有n-1条边,则T=(V,{TE})为N的最小生成树。
由算法代码中的循环嵌套可得知此算法的时间复杂度为O(
n2
)。
7.6.2 克鲁斯卡尔算法(Kruskal)
普里姆算法是以某顶点为起点,逐步找各顶点上最小权值的边来构建最小生成树的。同样的思路,也可以直接就以边为目标去构建,只不过构建时要考虑是否会形成环路而已。此时,用到了图的存储结构中的边集数组结构。以下是edge边集数组结构的定义代码:
//边集数组Edge结构的定义
typedef struct
{
int begin;
int end ;
int weight;
}Edge;
把邻接矩阵转化为边集数组,并对它们按权值从小到达排序。
其中MAXEDGE为边数量的极大值,MAXVEX为顶点个数最大值,代码如下:
//Kruskal算法生成最小生成树
void MiniSpanTree_Kruskal(MGraph G) //生成最小生成树
{
int i, n, m;
Edge edges[MAXEDGE]; //定义边集数组
int parent[MAXVEX]; //定义一个数组用来判断边与边是否形成环路
//此处省略将邻接矩阵G转化为边集数组edges并按权由小到大排序的代码
for (i = 0; i < G.numVertexes; ++i)
parent[i] = 0;
for (i = 0; i < G.numEdges; ++i) //循环每一条边
{
n = Find(parent, edges[i].begin);
m = Find(parent, edges[i].end);
if (n != m) //假如n与m不等,说明此边没有与现有生成树形成环路
{
parent[n] = m; //将此边的结尾顶点放入下标为起点的parent中,表示此顶点已经在生成树集合中
printf("(%d, %d) %d", edges[i].begin, edges[i].end, edges[i].weight);
}
}
}
int Find(int *parent, int f) //查找连线顶点的尾部下标
{
while (parent[f] > 0)
f = parent[f];
return f;
}
把克鲁斯卡尔算法的实现定义归纳一下结束这一节的讲解。
假设N=(V,{E})是连通网,则令最小生成树的初始状态为只有n个顶点而无边的非连通图T={V,{}},图中每个顶点自成一个连通分量。在E中选择代价最小的边,若边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条边代价最下的边。依此类推,直至T中所有顶点都在同一连通分量上为止。
此算法的Find函数由边数e决定,时间复杂度为O(loge),而外面有一个for 循环e次。所以克鲁斯卡尔算法的时间复杂度为O(eloge)。
对比两个算法,克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。
7.7 最短路径
对于非网图,由于边上没有权值,所谓最短路径,就是值两顶点之间经过的边数最少的路径;而对于网图来说,最短路径是指两顶点之间经过边上的权值之和最少的路径,并且称路径上的第一个顶点是源点,最后一个顶点是终点。
7.7.1 迪杰斯特拉(Dijkstra)算法
这是一个按路径长度递增的次序产生最短路径的算法。它的思路大体是这样的:
它并不是一下子就求出了v0到v8的最短路径,而是一步步求出它们之间顶点的最短路径,过程中都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,最终得到你要的结果。
//Dijkstra算法
#define MAXVEX 9
#define INFINITY 65535
typedef int Patharc[MAXVEX]; //用于存储最短路径下标的数组
typedef int ShortPathTable[MAXVEX]; //用于存储到各点最短路径的权值和
//Dijkstra算法,求有向网G的顶点V0顶点到其余顶点v最短路径P[v]及带权长度D[v]
//P[v]的值为前驱顶点下标,D[v]表示v0到v的最短路径长度和
void ShortestPath_Dijkstra(MGraph G, int v0, Patharc *p, ShortPathTable *D)
{
int v, w, k, min;
int final[MAXVEX]; //final[w] = 1 表示求得顶点v0至vw的最短路径
for (v = 0; v < G.numVertexes; ++v) //初始化数据
{
final[v] = 0; //全部顶点初始化为未知最短路径状态
(*D)[v] = G.arc[v0][v]; //将与v0点有连线的顶点加上权值
(*p)[v] = 0; //初始化路径数组P为0
}
(*D)[v0] = 0; //v0至v0路径为0
final[v0] = 1; //v0至v0不需要求路径
//开始主循环,每次求得v0到某个v顶点的最短路径
for (v = 1; v < G.numVertexes; ++v)
{
min = INFINITY; //当前所知离v0顶点的最近距离
for (w = 0; w < G.numVertexes; ++w) //寻找离v0最近的顶点
{
if (!final[w] && (*D)[w] < min)
{
k = w;
min = (*D)[w]; //w顶点离v0顶点更近
}
}
final[k] = 1; //将目前找到的最近的顶点置为1
for (w = 0; w < G.numVertexes; ++w) //修正当前最短路径及距离
{
//如果经过v顶点的路径比现在这条路径的长度短的话
if (!final[w] && (min + G.arc[k][w] < (*D)[w]))
{ //说明找到了更短的路径,修改D[w]和p[w]
(*D)[w] = min + G.arc[k][w]; //修改当前路径长度
(*P)[w] = k;
}
}
}
}
通过迪杰斯特拉算法解决了从某个源点到其余各顶点的最短路径问题。算法时间复杂度为O( n2 ).若是只找到从源点到某一个特定终点的最短距离,时间复杂度依然是O( n2 )。若是求任一顶点到其余所有顶点的最短路径,此时整个算法时间复杂度为O( n3 )。
7.7.2 弗洛伊德(Floyd)算法
它求所有顶点到所有顶点的时间复杂度是O( n3 )。但是算法非常简洁优雅。代码如下,注意是求所有顶点到所有顶点的最短路径,因此,Pathmatirx和ShortPathTable都是二维数组
typedef int Pathmatirx[MAXVEX][MAXVEX];
typedef int ShortPathTable[MAXVEX][MAXVEX];
//floyd算法,求网图G中各顶点v到其余顶点w最短路径P[v][w]及带权长度D[v][w]
void ShortestPath_Floyd(MGraph G, Pathmatirx *p, ShortPathTable *D)
{
int v,w,k;
for (v = 0; v < G.numVertexes; ++v) //初始化D与P
{
for (w = 0; w < G.numVertexes; ++w)
{
(*D)[v][w] = G.matirx[v][w]; //D[v][w]值即为对应点间的权值
(*P)[v][w] = w; //初始化P
}
}
for (k = 0; k < G.numVertexes; ++k)
{
for (v = 0; v < G.numVertexes; ++v)
{
for (w = 0; w < G.numVertexes; ++w)
{
if ((*D)[v][w] > (*D)[v][k] + (*D)[k][w])
{//如果经过下标为k顶点路径比原两点间路径更短,将当前两点间权值设为更小的一个
(*D)[v][w] = (*D)[v][k] + (*D)[k][w];
(*P)[v][w] = (*P)[v][k]; //路径设置经过下标为k的顶点
}
}
}
}
}
//求最短路径的显示代码
for (v = 0; v < G.numVertexes; ++v)
{
for (w = v+1; w < G.numVertexes; ++w)
{
printf("v%d-v%d-v weight:%d", v, w, D[v][w]);
k = P[v][w]; //获得第一个路径顶点下标
printf("path:%d",v); //打印源点
while (k != w) //如果路径顶点下标不是终点
{
printf("->%d", k); //打印路径顶点
k = P[k][w]; //获得下一个路径顶点下标
}
printf(" -> %d\n", w); //打印终点
}
printf("\n");
}
7.8 拓扑排序
7.8.1 拓扑排序的介绍
在一个表示工程的有向图中,用顶点表示活动,弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,称为AOV网(Activity On Vertex Network)。
设G=(V,{E})是一个具有n个顶点的有向图,V中的顶点序列
v1,v2,.......,vn
满足若从
vi
到
vj
有一条路径,则在顶点序列中顶点
vi
必须在
vj
之前。则称这样的顶点序列为一个拓扑序列。
所谓拓扑排序,其实就是对一个有向图构造拓扑序列的过程。
7.8.2 拓扑排序算法
对AOV进行拓扑排序的基本思路是:从AOV网中选择一个入度为0的顶点输出,然后删去此顶点,并删除以此顶点为尾的弧,继续重复此步骤,直到输出全部顶点或者AOV网中不存在入度为0的顶点为止。
确定一下这个图需要使用的数据结构。为AOV网建立一个邻接表,考虑到算法过程中要查找入度为0的顶点,在原来顶点表结点结构中,增加一个入度域in,结构如图所示,其中in就是入度的数字。
因此对于图中的第一幅图AOV网,可以得到如第二幅图的邻接表数据结构。
在拓扑排序算法中,涉及的结构代码如下:
typedef struct EdgeNode //边表结点
{
int adjvex; //邻接点域,存储该顶点对应的下标
int weight; //用于存储权值,对于非网图可以不需要
struct EdgeNode *next; //链域,指向下一个邻接点
}EdgeNode;
typedef struct VertexNode //顶点表结点
{
int in; //顶点入度
int data; //顶点域,存储顶点信息
EdgeNode *firstedge; //边表头指针
}VertexNode, AdjList[MAXVEX];
typedef struct
{
AdjList adjList;
int numVertexes, numEdges; //图中当前顶点数和边数
}graphAdjList, *GraphAdjList;
在算法中,还需要辅助的数据——栈,用来存储处理过程中入度为0的顶点,目的是为了避免每个查找时都要去遍历顶点表找有没有入度为0的顶点。
//拓扑排序,若GL无回路,则输出拓扑排序序列并返回OK,若有回路返回ERROR
Status TopologicalSort(GraphAdjList GL)
{
EdgeNode *e;
int i, k, gettop;
int top = 0; //用于栈指针下标
int count = 0; //用于统计输出顶点的个数
int *stack; //建栈存储入度为0的顶点
stack = (int *) malloc(GL->numVertexes * sizeof(int));
for (i = 0; i < GL->numVertexes; ++i)
if (GL->adjList[i].in == 0)
stack[++top] = i; //将入度为0的顶点入栈
while (top != 0)
{
gettop = stack[top--]; //出栈
printf("%d->",GL->adjList[gettop].data); //打印此顶点
count++;
for (e = GL->adjList[gettop].firstedge; e; e = e->next)
{//对此顶点弧表遍历
k = e->adjvex;
if (!(--GL->adjList[k].in)) //将k号顶点邻接点的入度减1
stack[++top] = k; //若为0则入栈,以便于下次循环输出
}
}
if (count < GL->numVertexes) //如果count小于顶点数,说明不存在环
return ERROR;
else
return OK;
}
分析整个算法,对一个具有n个顶点e条弧的AOV网来说,整个算法的时间复杂度为O(n+e)。
7.9 关键路径
拓扑排序主要是为解决一个工程能否顺序进行的问题,但有时还需要解决工程完成需要的最短时间问题。
在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网,称之为AOE网(Activity On Edge Network)。把AOE中没有入边的顶点称为始点或源点,没有出边的顶点称为终点或汇点。正常情况下,AOE网只有一个始点或汇点。
尽管AOE网与AOV网都是用来工程建模的,但它们有很大的不同,主要体现在AOV网是顶点表示活动的网,它只描述活动之间的制约关系,而AOE网是用边表示活动的网,边上的权值表示活动持续的时间。AOE网是要建立在活动之间制约关系没有矛盾的基础上,再来分析完成整个工程至少需要多少时间,或者为缩短完成工程所需的时间,应当加快那些活动等问题。
路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫关键路径,在关键路径上的活动叫关键活动。
7.9.1 关键路径算法原理
只需要找到所有活动的最早开始时间和最晚开始时间,并且比较它们,如果相等就意味着此活动是关键活动,活动间的路径为关键路径。如果不等,则就不是。
为此,需要定义几个参数:
- 事件的最早发生时间etv(earliest time of vertex):即顶点 vk 的最早发生时间。
- 事件的最晚发生时间ltv(latest time of vertex):即顶点 vk 的最晚发生时间,也就是每个顶点对应的事件最晚需要开始的时间,超出此时间将会延迟整个工期。
- 活动的最早开工时间ete(earliest time of edge):即弧 ak 的最早发生时间。
- 活动的最晚开工时间lte(latest time of edge):即弧
ak
的最晚发生时间,也就是不推迟工期的最晚开工时间。
由1和2可以求得3和4,然后再根据ete【k】是否与lte【k】相等来判断 ak 是否是关键活动。
7.9.2 关键路径算法
将AOE网转换为邻接表结构,注意与拓扑排序时邻接表结构不同的地方在于,这里的弧链表增加了weight域,用来存储弧的权值。
求事件的最早发生时间etv的过程,就是从头至尾找拓扑序列的过程,因此:在求关键路径之前,需要先调用一次拓扑序列算法的代码来计算etv和拓扑序列列表。为此,首先在程序开始处声明几个全局变量
int *etv, *ltv; //事件最早发生时间和最迟发生时间数组
int *stack2; //用于存储拓扑序列的栈
int top2; //用于stack2的指针
其中stack2用来存储拓扑序列,以便后面求关键。
下面是改进过的求拓扑序列算法
//拓扑排序 ,用于关键路径计算
Status TopologicalSort(GraphAdjList GL)
{
EdgeNode *e;
int i, k, gettop;
int top = 0; //用于栈指针下标
int count = 0; //用于统计输出顶点的个数
int *stack; //建栈将入度为0的顶点入栈
stack = (int *) malloc(GL->numVertexes * sizeof(int));
for (i = 0; i < GL->numVertexes; ++i)
if ( 0 == GL->adjList[i].in)
stack[++top] = i;
top2 = 0; //初始化为0
etv = (int *)malloc(GL->numVertexes * sizeof(int)); //事件最早发生时间
for (i = 0; i < GL->numVertexes; ++i)
etv[i] = 0; //初始化为0
stack2 = (int *)malloc(GL->numVertexes * sizeof(int)); //初始化
while (top != 0)
{
gettop = stack[top--];
count++;
stack2[++top2] = gettop; //将弹出的顶点序号压入拓扑序列的栈
for (e = GL->adjList[gettop].firstedge; e; e = e->next)
{
k = e->adjvex;
if (!(--GL->adjList[k].in))
stack[++top] = k;
if ((etv[gettop]+e->weight)->etv[k]) //求各顶点事件最早发生时间值
etv[k] = etv[gettop] + e->weight;
}
}
if (count < GL->numVertexes)
return ERROR;
else
return OK;
}
其中P[K]表示所有到达顶点 vk 的弧的集合。下面来看求关键路径的算法代码:
//求关键路径,GL为有向网,输出GL的各项关键活动
void CriticalPath(GraphAdjList GL)
{
EdgeNode *e;
int i, gettop, k, j;
int ete, lte; //声明活动最早发生时间和最迟发生时间变量
TopologicalSort(GL); //求拓扑序列,计算数组etv和stack2的值
ltv = (int *)malloc(GL->numVertexes * sizeof(int)); //事件最晚发生时间
for (i = 0; i < GL->numVertexes; ++i)
ltv[i] = etv[GL->numVertexes-1]; //初始化ltv
while (top2 != 0) //计算ltv
{
gettop = stack2[top--]; //将拓扑序列出栈,后进先出
for (e = GL->adjList[gettop].firstedge; e; e = e->next)
{//求各顶点事件的最迟发生时间ltv值
k = e->adjvex;
if (ltv[k] - e->weight < ltv[gettop]) //求各顶点事件最晚发生时间ltv
ltv[gettop] = ltv[k] - e->weight;
}
}
for (j = 0; j < GL->numVertexes; ++j) //求ete,lte和关键活动
{
for (e = GL->adjList[j].firstedge; e; e = e->next)
{
k = e->adjvex;
ete = etv[j]; //活动最早发生时间
lte = ltv[k] - e->weight; //活动最迟发生时间
if (ete == lte) //两者相等即在关键路径上
printf("<v%d,v%d> length: %d, ", GL->adjList[j].data, GL->adjList[k].data, e->weight);
}
}
}
分析整个算法,最终求关键路径算法的时间复杂度依然是O(n+e)。