目录
数据结构-图(第八章)的整理笔记,若有错误,欢迎指正。
- 图的应用
图的应用主要包括:最小生成(代价)树、最短路径、拓扑排序和关键路径。
最小生成树
- 一个连通图的生成树包含图的所有顶点,并且只含尽可能少的边。对于生成树来说,若砍去它的一条边,则会使生成树变成非连通图;若给它增加一条边,则会形成图中的一条回路。
- 对于一个带权连通无向图G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。
- 设R为G的所有生成树的集合,若T为R中边的权值之和最小的那棵生成树,则T称为G的最小生成树(Minimum- Spanning-Tree,MST)。
- 不难看出,最小生成树具有如下性质:
- 最小生成树不是唯一的,即最小生成树的树形不唯一,R中可能有多个最小生成树。当图G中的各边权值互不相等时,G的最小生成树是唯一的;若无向连通图G的边数比顶点数少1,即G本身是一棵树时,则G的最小生成树就是它本身。
!注意: 只有连通图才有生成树,非连通图只有生成森林。 - 最小生成树的边的权值之和总是唯一的,虽然最小生成树不唯一,但其对应的边的权值之和总是唯一的,而且是最小的。
- 最小生成树的边数为顶点数减1。
- 构造最小生成树有多种算法,但大多数算法都利用了最小生成树的下列性质:假设G=(V,E)是一个带权连通无向图,U是顶点集的一个非空子集。若(u,v)是一条具有最小权值的边,其中u∈U,v∈V-U,则必存在一棵包含边(u,v)的最小生成树。
- 基于该性质的最小生成树算法主要有Prim算法和Kruskal算法,它们都基于贪心算法的策略。
普里姆(Prim)算法
- 从某⼀个顶点开始构建生成树,每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。
算法实现
- 由于普里姆(Prim)算法中需要频繁地取一条条边的权,所以图采用邻接矩阵更合适。
- Pim算法中的候选边是指集合U和V-U之间的所有边(称为U和V-U两个顶点集合的割集),如果把这些边都保存起来是非常消耗空间的,实际上考虑候选边的目的是求U和V-U之间的最小边(指权最小的边)。为此只考虑V-U集合的顶点(因为两个顶点集之间的边是无向边),建立两个数组closest和lowcost,用于记录V-U中顶点i到U中顶点的最小边。
- 对于V-U中的一个顶点j,它的最小边对应U的某个顶点,用 closest[j]保存U中的这个顶点,顶点j的最小边对应U中的顶点k,有closest[j]=k,并且用lowcost[j]存储该最小边的权。也就是说,这样的最小边为(closest[j],j)边,对应的权为lowcost[j]。
- 这里的约定是若某个顶点i有lowcost[i]=0,表示i∈U;若0<lowcost[i]<∞(或者lowcost[i]≠0),表示i∈V-U。
- 初始时,U中只有一个顶点v。对于所有顶点i,这时(v,i)边就是顶点i到U的最小边,置lowcost[i]=g.edges[v][i](没有边时为∞,v到v为0),closest[i]=v。由于lowcost[v]已经被置为0,表示它添加到U集合中了。
- 在候选边中求ー条最小边的过程是扫描V-U中的所有顶点j,通过比较lowcost值求出最小lowcost值对应的顶点k,那么(closest[k],k)就是最小边,输出这条最小边,并将顶点k添加到U中,即置 lowcost[k]=0。
- 接着做调整,也就是修改侯选边,也仅仅考虑V-U集合的顶点。对于j∈V-U(即lowcost[j]!=0),在上一步(顶点k还没有添加到U中时),lowcost[j]保存的是顶点j到U中顶点closest[j]的最小边,而现在U发生了改変(改变是仅仅在U中增加了顶点k),所以需要将原来的lowcost[j]与g.edge[k][i]进行比较,如果g.edge[k][i]小,选择(k,j)作为新的最小边,即置lowcost[j]=g.edge[k][i],closest[j]=k,否则顶点j的候选边不改变。
void Prim(MGraph g, int v)
{
int mincost = 0;
int lowcost[MaxVertenNum];
int MIN;
int closest[MaxVertenNum], i, j, k;
for (i = 0; i < g.vexnum; i++) //给lowcost[]和closest[]置初值
{
lowcost[i] = g.Edge[v][i];
closest[i] = v;
}
for (i = 1; i < g.vexnum; i++) //找出(n-1)个顶点
{
MIN = INF;
for (j = 0; j < g.vexnum; j++) //在(V-U)中找出离U最近的顶点k
if (lowcost[j] != 0 && lowcost[j] < MIN)
{
MIN = lowcost[j]; //k记录最近顶点的编号
k = j;
}
printf("边(%c, %c)权为:%d\n", g.Vex[closest[k]], g.Vex[k],MIN); //输出最小生成树的一条边
mincost = mincost + MIN;
lowcost[k] = 0; //标记k已经加入U
for (j = 0; j < g.vexnum; j++) //对(V-U)中的顶点j进行调整
if (lowcost[j] != 0 && g.Edge[k][j] < lowcost[j])
{
lowcost[j] = g.Edge[k][j];
closest[j] = k; //修改数组lowcost和closest
}
}
printf("最小代价为:%d\n", mincost);
}
运行结果
程序分析
- Prim()算法中有两重for循环,所以时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2),其中|V|为图的顶点个数。可以看出,Prim()算法的执行时间与图中的边数e无关,所以它特别适合用稠密图求最小生成树。
克鲁斯卡尔(Kruskal)算法
- 每次选择⼀条权值最小的边,使这条边的两头连通(原本已经连通的就不选),直到所有结点都连通。
- 结果同Prim算法。
算法实现
- 和普里姆(Prim)算法一样,在该算法中需要频繁地取一条条边的权,所以图采用邻接矩阵更合适。
- 设置一个辅助数组vset[0…n-1],vset[i]用于记录一个顶点所在的连通分量编号。初值时每个顶点构成一个连通分量,所以有 vset[i]=i,vset[j]=j(所有顶点的连通分量编号等于该顶点编号)。当选中(i,j)边时,如果顶点j的连通分量编号相同,表示加入后会出现回路,不能加入;否则表示加入后不会出现回路,可以加入,然后将这两个顶点所在连通分量中所有顶点的连通分量编号改为相同(改为vset[i]或者vset[j]均可)。
- 另外,用一个数组E[ ]存放图G中的所有边,要求它们是按权值从小到大的顺序排列的,为此先从图G的邻接矩阵中获取所有边集E,再采用直接插入排序法对边集E按权值递增排序。
void Kruskal(MGraph g) //Kruskal算法
{
int i, j, ul, vl, sn1, sn2, k;
int vset[MaxVertenNum];
Edge E[MaxVertenNum]; //存放图中的所有边
k = 0; //e数组的下标从0开始计数
for (i = 0; i < g.vexnum; i++) //由g产生边集E,不重复选取同一条边
for (j = 0; j <= i; j++)
if (g.Edge[i][j] != 0 && g.Edge[i][j] != INF)
{
E[k].u = i;
E[k].v = j;
E[k].w = g.Edge[i][j];
k++;
}
Insertsort(E, g.Edge); //采用直接插入排序对E数组按权值递增排序
for (i = 0; i < g.vexnum; i++) vset[i] = i; //初始化辅助数组
k = 1; //k表示当前构造生成树的第几条边,初值为1
j = 0; //E中边的下标, 初值为0
while (k < g.vexnum) //生成的边数小于顶点数时循环
{
ul = E[j].u;
vl = E[j].v; //取一条边的两个顶点
sn1 = vset[ul];
sn2 = vset[vl]; //分别得到两个顶点所属的集合编号
if (sn1 != sn2) //两顶点属于不同的集合, 该边是最小生成树的一条边
{
printf("(%d, %d):%d\n", ul, vl, E[j].w); //输出最小生成树的一条边
k++; //生成边数增1
for (i = 0; i < g.vexnum; i++) //两个集合统一编号
if (vset[i] == sn2) //集合编号为sn2的改为sn1
vset[i] = sn1;
}
j++; //扫描下一条边
}
}
运行结果
程序分析
- 时间复杂度: O ( ∣ E ∣ l o g 2 ∣ E ∣ ) O(|E|log_2^{|E|}) O(∣E∣log2∣E∣) ,可以看出,Kruskal算法的执行时间仅与图中的边数有关,与顶点数无关,所以它特别适合用边稀疏图求最小生成树。
最短路径
- 广度优先搜索查找最短路径只是对无权图而言的。当图是带权图时,把从一个顶点 v 0 v_0 v0到图中其余任意一个顶点 v i v_i vi的一条路径(可能不止一条)所经过边上的权值之和,定义为该路径的带权路径长度,把带权路径长度最短的那条路径称为最短路径。
- 求解最短路径的算法通常都依赖于一种性质,即两点之间的最短路径也包含了路径上其他顶点间的最短路径。带权有向图G的最短路径问题一般可分为两类:一是单源最短路径,即求图中某一顶点到其他各顶点的最短路径;二是求每对顶点间的最短路径。
最 短 路 径 问 题 { 单 源 最 短 路 径 { 广 度 优 先 搜 索 ( B F S ) 算 法 ( 无 权 图 ) 狄 克 斯 特 拉 ( D i j k s t r a ) 算 法 ( 带 权 图 、 无 权 图 ) 各 顶 点 间 的 最 短 路 径 — 弗 洛 伊 德 ( F l o y d ) 算 法 ( 带 权 图 、 无 权 图 ) 最短路径问题\begin{cases} 单源最短路径 \begin{cases} 广度优先搜索(BFS)算法(无权图)\\ 狄克斯特拉(Dijkstra)算法(带权图、无权图) \end{cases}\\ 各顶点间的最短路径 —弗洛伊德(Floyd)算法(带权图、无权图) \end{cases} 最短路径问题⎩⎪⎨⎪⎧单源最短路径{广度优先搜索(BFS)算法(无权图)狄克斯特拉(Dijkstra)算法(带权图、无权图)各顶点间的最短路径—弗洛伊德(Floyd)算法(带权图、无权图) - 最短路径一定是简单路径。
广度优先搜索(BFS)算法
- 无权图可以视为一种特殊的带权图,只是每条边的权值都为1。
算法实现
void BFS_Min_Distance(MGraph g,int v) //广度优先遍历求不带权无向图最短路径
{
bool visited[MaxVertenNum];
int d[MaxVertenNum], path[MaxVertenNum];
for (int i = 0; i < g.vexnum; i++) //初始化数组
{
visited[i] = false; //初始时全部为false
d[i] = INF; //初始时全部为∞
path[i] = -1; //初始时全部为-1
}
SqQueue* qu;
InitQueue(qu); //初始化队列
visited[v] = 1; //置访问标记
d[v] = 0; //顶点v到顶点v的最短路径为0
enQueue(qu, v);
while (!EmptyQueue(qu))
{
deQueue(qu, v);
for (int w = FirstNeighbor(g, v); w >= 0; w = NextNeighbor(g, v, w))
if (!visited[w]) //尚未访问的邻接顶点
{
d[w] = d[v] + 1; //前驱顶点的路径值+1
path[w] = v; //记录前驱顶点序号
visited[w] = true; //置访问标记
enQueue(qu, w);
}
}
Destroy(qu);
}
运行结果
- 不带权无向图(邻接矩阵法)
- 不带权无向图(邻接表法)
- 不带权有向图(邻接矩阵法)
- 不带权有向图(邻接表法)
程序分析
- 在BFS算法的基础之上进行改进,增加了两个数组d[ ]和path[ ],d[ ]用来记录最短路径值,path[ ]用来记录当前顶点的前驱顶点序号。path[ ]为-1时,表示是从该顶点开始进行的遍历。
- 当要求起始遍历顶点到其余每个顶点的最短路径时,访问数组d[ ]即可,按照d[ ]中的值,能够在path[ ]中找到一条最短路径。
- BFS算法求单源最短路径只适用于无权图,或所有边的权值都相同的图。
狄克斯特拉(Dijkstra)算法
- Dijkstra算法设置一个集合S记录已求得的最短路径的顶点,初始时把源点v放入S,集合S每并入一个新顶点,都要修改源点到集合V-S中顶点当前的最短路径长度值。
- 在构造的过程中还设置了两个辅助数组:
- dist[ ]:记录从源点v到其他各顶点当前的最短路径长度,它的初态为:若从v到有弧,则dist[i]为弧上的权值;否则置dist[i]为∞。
- path[ ]:path[i]表示从源点到顶点i之间的最短路径的前驱结点。在算法结束时,可根据其值追溯得到源点到顶点的最短路径。
- Dijkstra算法的步骤如下(不考虑对path[ ]的操作):
- 初始化:集合S初始为{0},dist[]的初始值dist[i]=arcs[0][i],i=1,2,…,n-1。
- 从顶点集合V-S中选出 v j v_j vj,满足dist[j]= Min{dist[i] | v i v_i vi∈V-S}, v j v_j vj就是当前求得的一条从 v 0 v_0 v0出发的最短路径的终点,令S=S∪{j}。
- 修改从 v 0 v_0 v0出发到集合V-S上任一顶点 v k v_k vk可达的最短路径长度:若dist[j]+arcs[j][k]<dist[k],则更新dist[k]=dist[j]+arcs[j][k]。
- 重复2)~3)操作共n-1次,直到所有的顶点都包含在S中。
算法实现
void Dispath(MGraph g, int dist[], int path[], int S[], int v)
//输出从顶点v出发的所有最短路径
{
int i, j, k;
int apath[MaxVertenNum], d;
for (i = 0; i < g.vexnum; i++)
if (S[i] == 1 && i != v)
{
printf("从顶点%c到顶点%c的最短路径长度为:%d\t路径为:", g.Vex[v], g.Vex[i], dist[i]);
d = 0;
apath[d] = i;
k = path[i];
if (k == -1) printf("无路径\n"); //没有路径的情况
else //存在路径时输出该路径
{
while (k != v)
{
d++;
apath[d] = k;
k = path[k];
}
d++;
apath[d] = v; //添加路径上的起点
printf("%c", g.Vex[apath[d]]); //先输出起点
for (j = d - 1; j >= 0; j--) printf(",%c", g.Vex[apath[j]]); //再输出其他顶点
printf("\n");
}
}
}
void Dijksra(MGraph g, int v)
{
int dist[MaxVertenNum], path[MaxVertenNum];
int S[MaxVertenNum]; //S[i]=1表示顶点i在S中,S[i]=0表示顶点i在U中
int MINdis, i, j, u;
for (i = 0; i < g.vexnum; i++)
{
dist[i] = g.Edge[v][i]; //距离初始化
S[i] = 0;
if (g.Edge[v][i] < INF) //路径初始化
path[i] = v; //顶点v到顶点i有边时,置顶点i的前一个顶点为v
else path[i] = -1; //顶点v到顶点i没边时,置顶点i的前一个顶点为-1
}
S[v] = 1; //源点编号v放入S中
path[v] = 0;
for (i = 0; i < g.vexnum - 1; i++) //循环直到所有顶点的最短路径都求出
{
MINdis = INF; //MINdis置最大长度初值
for(j=0;j<g.vexnum;j++)
if (S[j] == 0 && dist[j] < MINdis)
{
u = j;
MINdis = dist[j];
}
S[u] = 1; //顶点u加入S中
for (j = 0; j < g.vexnum; j++) //修改不在S中(即U中)的顶点的最短路径
if(S[j]==0)
if (g.Edge[u][j] < INF && dist[u] + g.Edge[u][j] < dist[j])
{
dist[j] = dist[u] + g.Edge[u][j];
path[j] = u;
}
}
Dispath(g, dist, path, S, v); //输出最短路径
}
运行结果
程序分析
- 显然,狄克斯特拉(Dijkstra)算法也是基于贪心策略的。使用邻接矩阵表示时,时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。使用带权的邻接表表示时,虽然修改dist[ ]的时间可以减少,但由于在dist[ ]中选择最小分量的时间不变,时间复杂度仍为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。
- 狄克斯特拉(Dijkstra)算法和普里姆(Prim)算法的相似之处:前者的dist[ ]数组记录的是从当前顶点到达某一个指定顶点的最短路径的值;后者的lowCost[ ]数组记录的是这些顶点加入到目前组建的生成树的最小代价。二者算法的时间复杂度相同。
- !注意:Dijkstra算法不适用于有负权值的带权图。
弗洛伊德(Floyd)算法
- 求所有顶点之间的最短路径问题描述如下:已知一个各边权值均大于0的带权有向图,对任意两个顶点 v i ≠ v j v_i≠v_j vi=vj,要求求出 v i v_i vi与 v j v_j vj之间的最短路径和最短路径长度。
- Floyd算法的基本思想是:递推产生一个n阶方阵序列 A ( − 1 ) , A 0 , … , A k , … , A n − 1 A^{(-1)},A^{0},…,A^{k},…,A^{n-1} A(−1),A0,…,Ak,…,An−1,其中 A ( k ) [ i ] [ j ] A^{(k)}[i][j] A(k)[i][j]表示从顶点 v i v_i vi到顶点 v j v_j vj的路径长度,k表示绕行第k个顶点的运算步骤。
- 初始时,对于任意两个顶点 v i v_i vi和 v j v_j vj,若它们之间存在边,则以此边上的权值作为它们之间的最短路径长度;若它们之间不存在有向边,则以∞作为它们之间的最短路径长度。以后逐步尝试在原路径中加入顶点k(k=0,1,…,n-1)作为中间顶点。若增加中间顶点后,得到的路径比原来的路径长度减少了,则以此新路径代替原路径。
- 算法描述如下:
定义一个n阶方阵序列 A ( − 1 ) , A 0 , … , A n − 1 A^{(-1)},A^{0},…,A^{n-1} A(−1),A0,…,An−1,其中, A ( − 1 ) [ i ] [ j ] = a r c s [ i ] [ j ] A^{(-1)}[i][j]=arcs[i][j] A(−1)[i][j]=arcs[i][j], A ( k ) [ i ] [ j ] = M i n A ( k − 1 ) [ i ] [ j ] , A ( k − 1 ) [ i ] [ k ] + A ( k − 1 ) [ k ] [ j ] , k = 0 , 1 , . . . , n − 1 A^{(k)}[i][j]=Min{A^{(k-1)}[i][j]},A^{(k-1)}[i][k]+A^{(k-1)}[k][j],k=0,1,...,n-1 A(k)[i][j]=MinA(k−1)[i][j],A(k−1)[i][k]+A(k−1)[k][j],k=0,1,...,n−1
式中, A ( 0 ) [ i ] [ j ] A^{(0)}[i][j] A(0)[i][j]是从顶点 v i v_i vi到 v j v_j vj、中间顶点是 v 0 v_0 v0的最短路径的长度, A ( k ) [ i ] [ j ] A^{(k)}[i][j] A(k)[i][j]是从顶点 v i v_i vi到 v j v_j vj、中间顶点的序号不大于k的最短路径的长度。 - Floyd算法是一个迭代的过程,每迭代一次,在从 v i v_i vi到 v j v_j vj到的最短路径上就多考虑了一个顶点;经过n次迭代后,所得到的 A ( n − 1 ) [ i ] [ j ] A^{(n-1)}[i][j] A(n−1)[i][j]就是 v i v_i vi到 v j v_j vj的最短路径长度,即方阵 A ( n − 1 ) A^{(n-1)} A(n−1)中就保存了任意一对项点之间的最短路径长度。
算法实现
void Dispath(MGraph g, int A[][MaxVertenNum], int path[][MaxVertenNum])
{
int i, j, k, s;
int apath[MaxVertenNum], d; //存放一条最短路径中间顶点(反向)及其顶点个数
for (i = 0; i < g.vexnum; i++)
for (j = 0; j < g.vexnum; j++)
{
if (A[i][j] != INF && i != j) //若顶点i和j之间存在路径
{
printf("从%c到%c的路径为:", g.Vex[i], g.Vex[j]);
k = path[i][j];
d = 0;
apath[d] = j; //路径上添加终点
while (k != -1 && k != i) //路径上添加中间点
{
d++;
apath[d] = k;
k = path[i][k];
}
d++;
apath[d] = i; //路径上添加起点
printf("%c", g.Vex[apath[d]]); //输出起点
for (s = d - 1; s >= 0; s--) printf("_%c", g.Vex[apath[s]]); //输出路径上的中间顶点
printf("\t路径长度为:%d\n", A[i][j]);
}
}
}
void Floyd(MGraph g)
{
int A[MaxVertenNum][MaxVertenNum], path[MaxVertenNum][MaxVertenNum];
int i, j, k;
for(i=0;i<g.vexnum;i++)
for (j = 0; j < g.vexnum; j++)
{
A[i][j] = g.Edge[i][j]; //A[]数组中的值等于邻接矩阵表中的值
if (i != j && g.Edge[i][j] < INF) path[i][j] = i;
else path[i][j] = -1;
//path[i][j] = -1; //初始时,path[]数组值全为-1
}
for (k = 0; k < g.vexnum; k++) //遍历整个矩阵,i为行号,j为列号
for (i = 0; i < g.vexnum; i++)
for (j = 0; j < g.vexnum; j++)
if (A[i][j] > A[i][k] + A[k][j])
{
A[i][j] = A[i][k] + A[k][j]; //更新最短路径长度
path[i][j] = path[k][j]; //中转点
}
Dispath(g, A, path);
}
运行结果
程序分析
- Floyd算法的时间复杂度为 O ( ∣ V ∣ 3 ) O(|V|^3) O(∣V∣3)。不过由于其代码很紧湊,且并不包含其他复杂的数据结构,因此隐含的常数系数是很小的,即使对于中等规模的输入来说,它仍然是相当有效的。
- Floyd算法允许图中有带负权值的边,但不允许有包含带负权值的边组成的回路。 Floyd算法同样适用于带权无向图,因为带权无向图可视为权值相同往返二重边的有向图。
- 也可以用单源最短路径算法来解决每对顶点之间的最短路径问题。轮流将每个顶点作为源点,并且在所有边权值均非负时,运行一次Dijkstra算法,其时间复杂度为 O ( ∣ V ∣ 2 ⋅ ∣ V ∣ ) = O ( ∣ V ∣ 3 ) O(|V|^2\cdot|V|)=O(|V|^3) O(∣V∣2⋅∣V∣)=O(∣V∣3)。
总结
广度优先搜索(BFS)算法 | 狄克斯特拉(Dijkstra)算法 | 弗洛伊德(Floyd)算法 | |
---|---|---|---|
⽆权图 | √ | √ | √ |
带权图 | × | √ | √ |
带负权值的图 | × | × | √ |
带负权回路的图 | × | × | × |
时间复杂度 | O(|V| 2 ^2 2)或O(|V|+|E|) | O(|V| 2 ^2 2) | O(|V| 3 ^3 3) |
通常用于 | 求无权图的单源最短路径 | 求带权图的单源最短路径 | 求带权图中各顶点间的最短路径 |
- 也可用Dijkstra算法求所有顶点间的最短路径,重复|V|次即可,总的时间复杂度也是 O ( ∣ V ∣ 3 ) O(|V|^3) O(∣V∣3)。