第七章 图
图(Graph)是一种较线性表和树更为复杂的数据结构。
图的定义和术语
图是由一个顶点集 V 和一个弧集 VR构成的数据结构。Graph = (V , VR )其中,VR={<v,w>| v,w∈V 且 P(v,w)},<v,w>表示从 v 到 w 的一条弧,并称 v 为
弧尾,w 为
弧头。谓词 P(v,w) 定义了弧 <v,w>的意义或信息。由于“弧”是有方向的,因此称
由顶点集和弧集构成的图为有向图。
若<v, w>∈VR 必有<w, v>∈VR, 则称 (v,w) 为顶点v 和顶点 w 之间存在一条边。
由顶点集和边集构成的图称作无向图。
假设图中有 n 个顶点,e 条边,则:
- 含有 e=n(n-1)/2 条边的无向图称作完全图;
- 含有 e=n(n-1) 条弧的有向图称作有向完全图;
- 若边或弧的个数 e<nlogn,则称作稀疏图,否则称作稠密图。
假若顶点v 和顶点w 之间存在一条边,则称顶点v 和w 互为邻接点,边(v,w) 和顶点v 和w 相关联。和顶点v 关联的边的数目定义为顶点的
度。
在无向图G中,如果从顶点v到顶点v'有路径,则称v和v'是连通的。如果对于图中任意两个顶点vi、vj∈V,vi和vj都是连通的,则称G是
连通图(Connected Graph)。无向图中的极大连通子图称为
连通分量(Connected Component)。
在有向图G中,如果对于每一对vi, vj∈V, vi≠vj,从vi到vj和从vj到vi都存在路径,则称G是
强连通图。有向图中的极大强连通子图称作有向图的
强连通分量。
一个连通图的
生成树是一个极小连通子图,它含有图中全部顶点,但只有足以构成一棵树的n-1条边。
图的存储结构
数组(邻接矩阵)表示法
用两个数组分别存储数据元素(顶点)的信息和数据元素之间的关系(边或弧)的信息。其形式描述如下:
// - - - - - - 图的数组(邻接矩阵)存储表示 - - - - -
#define MAX_VERTEX_NUM 20 //最大顶点个数
typedef enum {DG, DN, UDG, UDN} GraphKind; // {有向图,有向网,无向图,无向网}
// 弧的定义
typedef struct ArcCell
{
VRType adj; // VRType是顶点关系类型。
// 对无权图,用1或0表示相邻否;
// 对带权图,则为权值类型。
InfoType *info; // 该弧相关信息的指针
} ArcCell, AdjMatrix[MAX_VERTEX_NUM] [MAX_VERTEX_NUM];
// 图的定义
typedef struct
{
VertexType vexs[MAX_VERTEX_NUM]; // 顶点向量
AdjMatrix arcs; // 邻接矩阵
int vexnum, arcnum; // 顶点数,弧数
GraphKind kind; // 图的种类标志
} MGraph;
typedef enum {DG, DN, UDG, UDN} GraphKind; // {有向图,有向网,无向图,无向网}
// 弧的定义
typedef struct ArcCell
{
VRType adj; // VRType是顶点关系类型。
// 对无权图,用1或0表示相邻否;
// 对带权图,则为权值类型。
InfoType *info; // 该弧相关信息的指针
} ArcCell, AdjMatrix[MAX_VERTEX_NUM] [MAX_VERTEX_NUM];
// 图的定义
typedef struct
{
VertexType vexs[MAX_VERTEX_NUM]; // 顶点向量
AdjMatrix arcs; // 邻接矩阵
int vexnum, arcnum; // 顶点数,弧数
GraphKind kind; // 图的种类标志
} MGraph;
邻接表
邻接表(Adjacency List)是图的一种链式存储结构。在邻接表中对图中每个顶点建立一个单链表,每个链表上附设一个表头结点。
表结点:
其中
邻接点域(adjvex)指示与顶点vi邻接的点在图中的位置,
链域(nextarc)指示下一条边或弧的结点,
数据域(info)存储和边或弧相关的信息,如权值等。
头结点:
数据域(data)存储顶点vi的名或其他有关信息,
链域(firstarc)指向链表中第一个结点。
//- - - - 图的邻接表存储表示 - - - -
#define MAX_VERTEX_NUM 20
// 表结点
typedef struct ArcNode
{
int adjvex; // 该弧所指向的顶点的位置
struct ArcNode *nextarc; // 指向下一条弧的指针
InfoType *info; // 该弧相关信息的指针
}
// 头结点
typedef struct VNode
{
VertexType data; // 顶点信息
ArcNode *firstarc; // 指向第一条依附该顶点的弧的指针
}VNode, AdjList[MAX_VERTEX_NUM];
// 图的结构定义
typedef struct
{
AdjList vertices;
int vexnum, arcnum; // 图的当前顶点数和弧数
int kind; // 图的种类标志
}ALGraph;
#define MAX_VERTEX_NUM 20
// 表结点
typedef struct ArcNode
{
int adjvex; // 该弧所指向的顶点的位置
struct ArcNode *nextarc; // 指向下一条弧的指针
InfoType *info; // 该弧相关信息的指针
}
// 头结点
typedef struct VNode
{
VertexType data; // 顶点信息
ArcNode *firstarc; // 指向第一条依附该顶点的弧的指针
}VNode, AdjList[MAX_VERTEX_NUM];
// 图的结构定义
typedef struct
{
AdjList vertices;
int vexnum, arcnum; // 图的当前顶点数和弧数
int kind; // 图的种类标志
}ALGraph;
最小生成树
假设要在 n 个城市之间建立通讯联络网,则连通n个城市只需要修建n-1条线路,如何在最节省经费的前提下建立这个通讯网?
该问题等价于:构造网的一棵
最小生成树(Minimum Cost Spanning Tree),即在e条带权的边中选取n-1条边(不构成回路),使“权值之和”为最小。
构造最小生成树可以有多种算法。其中多数算法利用了最小生成树的下列一种简称为
MST的性质:假设N=(V, {E})是一个连通网,U是顶点集V的一个非空子集。若(u,v)是一条具有最小权值(代价)的边,其中u∈U, v∈V-U,则必存在一棵包含边(u,v)的最小生成树。
可以用
反证法证明之。假设网N的任何一棵最小生成树都不包含(u,v)。设T是连通网上的一棵最小生成树,当将边(u,v)加入到T中时,由生成树的定义,T中必存在一条包含(u,v)的回路。另一方面,由于T是生成树,则在T上必存在另一条边(u',v'),其中u'∈U, v'∈V-U,且u和u'之间,v和v'之间均有路径相通。删去边(u',v'),便可消除上述回路,同时得到另一棵生成树T‘。因为(u,v)的代价不高于(u',v'),则T'的代价亦不高于T,T'是包含(u,v)的一棵最小生成树。由此和假设矛盾。
普里姆(Prim)算法和克鲁斯卡尔(Kruskal)算法是两个利用MST性质构造最小生成树的算法。
普里姆算法
假设N=(V,{E})是连通网,TE是N上最小生成树中边的集合。算法从U={u
0} (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的最小生成树。
为实现这个算法需附设一个
辅助数组closedge,以记录从U到V-U具有最小代价的边。对每个顶点vi∈V-U,在辅助数组中存在一个相应分量closedge[i-1],它包括两个域,其中
lowcost存储该边上的权。显然 closedge[i-1].lowcost = Min{ cost(u,vi)|u∈U}。
vex域存储该边依附的在U中的顶点。
void MiniSpanTree_PRIM(MGraph G, VertexType u)
{
// 用普里姆算法从第u个顶点出发构造网G的最小生成树T,输出T的各条边。
// 记录从顶点集U到V-U的代价最小的边的辅助数组定义:
// struct
// {
// VertexType adjvex;
// VRType lowcost;
// }closedge[MAX_VERTEX_NUM];
k=LocateVex(G,u);
for(j=0;j<G.vexnum;++j) // 辅助数组初始化
{
if(j!=k)
closedge[j]={u, G.arcs[k][j].adj};
}
closedge[k].lowcost=0; // 初始,U={u}
for(i=1;i<G.vexnum;++i) // 选择其余G.vexnum-1个顶点
{
k=minimum(closedge);
// 此时closedge[k].lowcost=MIN{ closedge[vi].lowcost | closedge[vi].lowcost>0, vi∈V-U}
printf(closedge[k].adjvex, G.vexs[k]); //输出生成树的边
closedge[k].lowcost=0; //第k顶点并入U集
for(j=0;j<G.vexnum;++j)
{
if(G.arcs[k][j].adj<closedge[j].lowcost) // 新顶点并入U后重新选择最小边
closedge[j]={G.vex[k], G.arcs[k][j].adj};
}
}
} // MiniSpanTree
分析算法,假设网中有n个顶点,则第一个进行初始化的循环语句的频度为n,第二个循环语句的频度为n-1。其中有两个内循环:其一是在closedge[v].lowcost中求最小值,其频度为n-1;其二是重新选择具有最小代价的边,其频度为n。
普利姆算法的时间复杂度为O(n^2),与网中的边数无关,因此适用于求边稠密的网的最小生成树。
克鲁斯卡尔算法
假设连通网N={V,[E]},则令最小生成树的初始状态为只有n个顶点而无边的非连通图T=(V,{}),图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。依次类推,直至T中所有顶点都在同一连通分量为止。
上述算法至多对e条边各扫描一次,以堆来存放网中的边,则每次选择最小代价的边仅需O(loge)的时间(第一次需O(e))。又生成树T的每个连通分量可以看成是一个等价类,则构造T加入新的边的过程类似于求等价类的过程,由此可以利用并查集来描述T,使构造T的过程仅需O(eloge)的时间,
由此克鲁斯卡尔算法时间复杂度为O(eloge)(e为网中边的数目),因此它相对于普里姆算法而言,适合于求边稀疏的网的最小生成树。
附:建堆的时间复杂度
建堆的时间复杂度为O(n)证明如下:
构建堆从叶节点的父节点开始,以树高递减的方向逐层往上,一直到根。
假设堆中共有N个元素,则树高H=log2(N),从最底层开始,为从各节点建堆的复杂度求和:
S = 1 * 2^(H-1) + 2 * 2^(H-2) + ... + (H-1) * 2^1 + H * 2^0
= 2^H * ( 1*2^(-1) + 2*2^(-2) + 3*2^(-3) + ... + H*2(-H))
= 2^(H+1) - 2 - H
将H=log2(N)代入,S = 2N - 2 - log2(N)
数列an=n*2^(-n)求和
Sn=1/2+2/2^2+3/2^3+…+n/2^n
2Sn=1+2/2+3/2^2+4/2^3+…+n/2^(n-1)
2Sn-Sn=1+1/2+1/2^2+1/2^3+…+1/2^(n-1)-n/2^n
Sn=1*(1-1/2^n)/(1-1/2)
=(2^(n+1)-n-2)/2^n
Sn=1/2+2/2^2+3/2^3+…+n/2^n
2Sn=1+2/2+3/2^2+4/2^3+…+n/2^(n-1)
2Sn-Sn=1+1/2+1/2^2+1/2^3+…+1/2^(n-1)-n/2^n
Sn=1*(1-1/2^n)/(1-1/2)
=(2^(n+1)-n-2)/2^n
拓扑排序
检查有向图中是否存在回路的方法之一,是对有向图进行拓扑排序。
什么是拓扑排序(Topological Sort)?简单地说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。
拓扑排序的步骤:
(1)在有向图中选一个没有前驱的顶点且输出之。
(2)从图中删除该顶点和所有以它为尾的弧。
重复上述两步,直至全部顶点均已输出,或者当前图中不存在无前驱的顶点为止。后一种情况则说明有向图中存在环。
最短路径
从某个源点到其余各顶点的最短路径
给定带权有向图G和源点v,求从v到G中其余各顶点的最短路径。
如何求得这些路径?
迪杰斯特拉(Dijkstra)提出了一个按路径长度递增的次序产生最短路径的算法。
首先,引进一个辅助向量D,它的每个分量D[i]表示当前所找到的从始点v到每个终点vi的最短路径的长度。它的初态为:若从v到vi有弧,则D[i]为弧上的权值;否则置D[i]为∞。显然,长度为 D[j] = Min{ D[i] | vi∈V} 的路径就是从v出发的长度最短的一条最短路径。此路径为(v, vj)。
那么,下一条长度次短的最短路径是哪一条呢?假设该次短路径的终点是vk,则可想而知,这条路径或者是(v, vk),或者是(v, vj, vk)。它的长度或者是从v到vk的弧上的权值,或者是D[j]和从vj到vk的弧上的权值之和。
一般情况下,假设S为已求得最短路径的终点的集合,则可证明:下一条最短路径(设其终点为x)或者是弧(v, x),或者是中间只经过S中的顶点而最后到达顶点x的路径。这可用反证法来证明。假设此路径上有一个顶点不在S中,则说明存在一条终点不在S而长度比此路径短的路径。但是,这是不可能的。因为我们是按长度递增的次序来产生各最短路径的,故长度比此路径短的所有路径均已产生,它们的终点必定在S中,即假设不成立。
因此,在一般情况下,下一条长度次短的最短路径的长度必是 D[j] = Min{ D[i] | vi∈V-S} 其中,D[i]或者是弧(v, vi)上的权值,或者是D[k] (vk∈S)和弧(vk, vi)上的权值之和。
// - - - - - - - - - - - - - C语言描述的迪杰斯特拉算法 - - - - - - - - - - - - - - -
void ShortestPath_DIJ(MGraph G, int v0, PathMatrix &P, ShortPathTable &D)
{
// 用Dijkstra算法求有向网G的v0顶点到其余顶点v的最短路径P[v]及其带权长度D[v]。
// 若P[v][w]为TRUE,则w是从v0到v当前求得最短路径上的顶点。
// final[v]为TRUE当且仅当v∈S,即已经求得从v0到v的最短路径。
for(v=0; v<G.vexnum; ++v)
{
final[v]=FALSE;
D[v]=G.arcs[v0][v];
for(w=0; w<G.vexnum; ++w)
P[v][w]= FALSE; // 设空路径
if( D[v]< INFINITY)
{
P[v][v0]=TRUE;
P[v][v]=TRUE;
}
}
D[v0]=0;
final[v0]=TRUE; // 初始化,v0顶点属于S集
// 开始主循环,每次求得v0到某个v顶点的最短路径,并加v到S集
for(i=1; i<G.vexnum; ++i) // 其余G.vexnum-1个顶点
{
min = INFINITY; // 当前所知离v0顶点的最近距离
for(w=0; w<G.vexnum; ++w)
{
if( !final[w]) // w顶点在V-S中
{
if(D[w]<min) // w顶点离v0顶点更近
{
v=w;
min=D[w];
}
}
}
final[v] = TRUE; // 离v0顶点最近的v加入S集
for(w=0; w<G.vexnum; ++w) // 更新当前最短路径及距离
{
if(!final[w] && (min + G.arcs[v][w]<D[w])) // 修改D[w]和P[w], w∈V-S
{
D[w] = min + G.arcs[v][w];
P[w] = P[v];
P[w][w] = TRUE; // P[w] =P[v] + P[w]
} // if
} // for
} // for
} // ShortestPath_DIJ
分析这个算法的运行时间。第一个FOR循环的时间复杂度是O(n),第二个FOR循环共进行n-1次,每次执行的时间是O(n)。所以总的
时间复杂度是O(n^2)。
每一对顶点之间的最短路径
解决这个问题的一个办法是:每次以一个顶点为源点,重复执行迪杰斯特拉算法n次。这样,便可求得每一对顶点之间的最短路径。总的执行时间为O(n^3)。
这里要介绍由
弗洛伊德(Floyd)提出的另一个算法。这个
算法的时间复杂度也是O(n^3),但形式上简单些。
弗洛伊德算法仍从图的带权邻接矩阵cost出发,其基本思想是:
假设求从顶点vi到vj的最短路径。如果从vi到vj有弧,则从vi到vj存在一条长度为 arcs[i][j]的路径,该路径不一定是最短路径,尚需进行n次试探。首先考虑路径(vi, v0, vj)是否存在(即判别弧(vi, v0)和(v0, vj)是否存在)。如果存在,则比较(vi, vj)和(vi, v0, vj)的路径长度取长度较短者为从vi到vj的中间顶点的序号不大于0的最短路径,那么(vi, ..., v1, ..., vj)就有可能是从vi到vj的中间顶点的序号不大于1的最短路径。将它和已经得到的从vi到vj中间定点序号不大于0的最短路径相比较,从中选出中间顶点的序号不大于1的最短路径之后,再增加一个顶点v2,继续进行试探。以此类推。在一般情况下,若(vi, ... , vk)和(vk, ..., vj)分别是从vi到vk和从vk到vj的中间顶点的序号不大于k-1的最短路径,则将(vi, ..., vk, ..., vj)和已经得到的从vi到vj且中间顶点序号不大于k-1的最短路径相比较,其长度较短者便是从vi到vj的中间顶点的序号不大于k的最短路径。这样,在经过n次比较后,最后求得的必是从vi到vj的最短路径。按此方法,可以同时求得各对顶点间的最短路径。
先定义一个n阶方阵序列 D^(-1), D^(0), D^(1), ..., D^(k), ... , D^(n-1),其中D^(-1)[i][j] = G.arcs[i][j] D^(k)[i][j] = Min {D^(k-1)[i][j], D^(k-1)[i][k] + D^(k-1)[k][j]} 0<=k<=n-1
从上述计算公式可见,D^(1)[i][j]是从vi到vj的中间顶点的序号不大于1的最短路径的长度;D^(k)[i][j]是从vi到vj中间顶点的序号不大于k的最短路径的长度;D^(n-1)[i][j]就是从vi到vj的最短路径的长度。
// - - - - - - - - - - - - - - Floyd算法 - - - - - - - - - - - - - - - -
void ShortestPath_FLOYD( MGraph G, PathMatrix &P[], DistancMatrix &D)
{
// 用Floyd算法求有向网G中各对顶点v和w之间的最短路径P[v][w]及其
// 带权长度D[v][w]。若P[v][w][u]为TRUE,则u是从v到w当前求得最短路径上的顶点。
for(v=0; v<G.vexnum; ++v) // 各对节点之间初始已知路径及距离
{
for(w=0; w<G.vexnum; ++w)
{
D[v][w] = G.arcs[v][w];
for(u=0; u<G.vexnum; ++u)
P[v][w][u] = FALSE;
if(D[v][w]<INFINITY) // 从v到w有直接路径
{
P[v][w][v] = TRUE;
P[v][w][w] = TRUE;
}
}
}
for(u=0; u<G.vexnum; ++u)
{
for(v=0; v<G.vexnum; ++v)
{
for(w=0; w<G.vexnum; ++w)
{
if( D[v][u] + D[u][w] < D[v][w]) // 从v经u到w的一条路径更短
{
D[v][w] = D[v][u] + D[u][w];
for( i=0; i<G.vexnum; ++i)
P[v][w][i] = P[v][u][i] || P[u][w][i];
}
}
}
}
}// ShortestPath_FLOYD