一、生成树的概念
在一个任意连通图G中,如果取它的全部顶点和一部分边构成一个子图G',即:V(G')=V(G)和E(G')⊆E(G)
若同时满足边集E(G')中的所有边既能够使全部顶点连通而又不形成任何回路,则称子图G'是原图G的一棵生成树。
下面简单说明一下,在既能够连通图G中的全部n个顶点又没有形成回路的子图G'(即生成树)中,必定包含n-1条边。要构造子图G',首先从图G中任取一个顶点加入G'中,此时G'中只有一个顶点,假定具有一个顶点的图是连通的,以后每向G'中加入一个顶点,都要加入以该顶点为一个端点(终点),以已连通的顶点之中的任一个顶点为开始顶点的一条边,这样既连通了该顶点又不会产生回路,进行n-1次后,就向G'中加入了n-1个顶点和n-1条边,使得G'中的n个顶点既连通又不产生回路。
在图G的一棵生成树G'中,若再增加一条边,就会出现一条回路。这是因为此边的两个端点已连通,再加入此边后,这两个端点间有两条路径,因此就形成了一条回路,子图G'也就不再是生成树了。同样,若从生成树G'中删去一条边,就使得G'变为非连通图。这是因为此边的两个端点是靠此边唯一连通的,删除此边后,必定使这两个端点分属于两个连通分量中,使G'变成了具有两个两通分量的非连通图。
同一个连通图可以有不同的生成树。例如对于图9-1(a),其余3个子图都是它的生成树。在每棵生成树中都包含8个顶点和7条边,即n个顶点和n-1条边,此时n等于原图中的顶点数8,它们的差别只是边的选取方法不同。
在这3棵生成树中,图9-1(b)中的边集是从图9-1(a)中的顶点V0出发,利用深度优先搜索遍历的方法而得到的边集,此图是原图的深度优先生成树;图9-1(c)中的边集是从图9-1(a)中的顶点V0出发,利用广度优先搜索遍历的方法而得到的边集,此图是原图的广度优先生成树;图9-1(d)是原图的任意一棵生成树。当然图9-1(a)的生成树远不止这3种,只要能连通所有顶点而又不产生回路的任何子图都是它的生成树。
对于一个连通网(即连通带权图,假定每条边上的权值均为正实数)来说,生成树不同,每棵树的权(即树中所有边上的权值总和)也可能不同。图9-2(a)就是一个连通网,图9-2(b)、(c)和(d)是它的3棵生成树,每棵树的权各不相同。它们分别为57、53和38.具有权值最小的生成树被称为图的最小生成树。通过后面将要介绍的构造最小生成树的方法可知,图9-2(d)是图9-2(a)的最小生成树。
求图的最小生成树的方法(算法)主要有两个:一个是普里姆算法;另一个是克鲁斯卡尔算法。下面分别进行讨论。
二、普里姆算法
普里姆算法的基本思路是:假设G=(V,E)是一个具有n个顶点的连通网,T=(U,TE)是G的最小生成树,其中,U是T的顶点集,TE是T的边集,U和TE的初值均为空集。算法开始时,首先从V中任取一个顶点(假定取V0),将它并入U中,此时U={ V0},然后只要U是V的真子集(即U⊂V),就从那些其中一个端点已在T中,另一个端点仍在T外的所有边中,找一条最短(即权值最小)边,假定为(i,j),其中Vi∈U,Vj∈(V-U),并把该边(i,j)和顶点j分别并入T的边集TE和顶点集U,如此进行下去,每次往生成树里并入一个顶点和一条边,直到n-1次后就把所有n个顶点都并入到生成树T的顶点集中,此时U=V,TE中含有n-1条边,T就是最后得到的最小生成树。
普里姆算法的关键之处是:每次如何从生成树T中到T外的所有边中, 找到一条最短边。例如,在第k次(1<=k<=n-1)前,生成树T中已有k个顶点和k-1条边,此时T中到T外的所有边数为k(n-k),当然它包括两顶点间没有直接边相连,其权值被看作常量MaxValue的边在内,从如此多的边中查找最短边,其时间复杂度为O(k(n-k)),显然是很费事的。是否有一种好的方法能够降低查找最短边的时间复杂度呢?回答是肯定的,它能够使查找最短边的时间复杂度降低到O(n-k)。此方法是:假定在进行第k次前已经保留着从T中到T外每一个顶点(共n-k个顶点)的各一条边,进行第k次时,首先从这n-k条最短边中找出一条最短的边,它就是从T中到T外的所有边中的最短边,假设为(i,j),此步需进行n-k次比较;然后把边(i,j)和顶点j分别并入T中的边集TE和顶点集U中,此时T外只有n-(k+1)个顶点,对于其中的每个顶点t,若(j,t)边上的权值小于已保留的从原T中到顶点t的最短边的权值,则用(j,t)修改之,使从T中到T外顶点t的最短边为(j,t),否则原有最短边保持不变,这样,就把第k次后从T中到T外每一顶点t的各一条最短边都保留下来了,为进行第k+1次运算做好了准备,此步需进行n-k-1次比较。所以,利用此方法求第k次的最短边共需比较2(n-k)-1次,即时间复杂度为O(n-k)。
例如,对于图9-2(a),它的邻接矩阵如图9-3所示,假定从V0出发利用普里姆算法构造最小生成树T,在其过程中,每次(第0次为初始状态)向T中并入一个顶点和一条边后,顶点集U、边集TE(每条边的后面为该边的权)以及从T中到T外每个顶点的各一条最短边所构成的集合(假定用LW表示)的状态如下:
第0次 U={ 0 }
TE={ }
LW={ (0,1)8,(0,2)∞,(0,3)5,(0,4)∞,(0,5)∞,(0,6)∞ }
第1次 U={ 0,3 }
TE={(0,3)5 }
LW={ (3,1)3,(0,2)∞,(0,4)∞,(3,5)7,(3,6)15 }
第2次 U={ 0 ,3,1}
TE={ (0,3)5 ,(3,1)3}
LW={ (1,2)12,(1,4)10,(3,5)7,(3,6)15 }
第3次 U={ 0,3,1,5 }
TE={ (0,3)5 ,(3,1)3,(3,5)7 }
LW={ (5,2)2,(5,4)9,(3,6)15 }
第4次 U={ 0,3,1,5,2 }
TE={ (0,3)5 ,(3,1)3,(3,5)7,(5,2)2 }
LW={ (2,4)6,(3,6)15 }
第5次 U={ 0,3,1,5,2,4 }
TE={ (0,3)5 ,(3,1)3,(3,5)7,(5,2)2 ,(2,4)6 }
LW={(3,6)15 }
第6次 U={ 0,3,1,5,2,4,6 }
TE={ (0,3)5 ,(3,1)3,(3,5)7,(5,2)2 ,(2,4)6,(3,6)15 }
LW={ }每次对应的图形如图9-4(b)至(h)所示,其中,粗实线表示新加入到TE集合中的边,细实线表示已加入到TE集合中的边,虚线表示LW集合中的边,但权值我MaxValue的边实际上是不存在的,所有没画出。
图9-4(h)就是最后得到的最小生成树,它同图9-2(d)是完全一样的,所以图9-4(h)是图9-2(a)(重画为 为图9-4(a))的最小生成树。
通过以上分析可知,在构造图的最小生成树的过程中,在进行第k次(1<=k<=n-1)前,边集TE中的边数为k-1条,从T中到T外每一顶点的最短边集LW中的边数为n-空调,TE和LW中的边数总和为n-1条。为了保存这n-1条边,设用具有n-1个元素的边集树组ed来存储,其中ed的前k-1个元素(即ed[0]~ed[k-2])保存生成树的边集TE中的边,后n-k个元素(即ed[k-1]~ed[n-2])保存LW中的边。在进行第k次时,首先从下标为k-1到n-2的元素(即LW中的边)中查找出权值最小的边,假定为ed[m];接着把边ed[k-1]与ed[m]对调,确保在第k次后ed的前k个元素保存着TE中的边,后n-k-1个元素保存着LW中的边;然后再修改LW中的有关边,使得从T中到T外每一顶点的各一条最短边被保存下来。这样经过n-1次运算后,CT中就保存着最小生成树中的全部n-1条边。
根据分析,假定采用邻接矩阵作为图的存储结构,则求出图的最小生成树的普里姆算法的具体描述为:
public static void Prim(AdjacencyGraph gr,EdgeElement [] ed)
{
//利用普里姆算法求出从顶点V0开始图gr的最小生成树,其边集存入ed中
if(gr.graphType()!=1)
{
System.out.println("gr不是一个连通网,不能求生成树,退出运行!");
System.exit(1);
}
int n=gr.vertices(); //取出图gr对象中的顶点个数的值赋给n
int [][] a=gr.getArray(); //取出gr对象中邻接矩阵的引用
for(int i=0;i
假设G=(V,E)是一个具有n个顶点的连通网,T=(U,TE)是G的最小生成树,U的初值等于V,即包含G中全部顶点,TE的初值为空集,即不包含任何边。克鲁斯卡尔算法的基本思路是:将图G中的边按权值从小到大的顺序依次选取,若选取的一条边使生成树T不形成回路 ,则把它并入生成树的边集TE中,保留作为T中的一条边,若选取的一条边使生成树T形成回路,则将其舍弃,如此进行下去,直到TE中包含n-1条边为止,此时的T即为图G的最小生成树。
现以图9-5(a)为例来说明此算法。设此图是用边集数组表示的,且数组中各边是按权值从小到大的顺序排列的,如图9-5(d)所示。若元素没有按序排列,则可通过调用排序算法,使之成为有序。算法要求按权值从小到大次序选取各边转换成按边集数组中下标次序选取各边。当选取前3条边时,均不产生回路,应保留作为生成树T中的边,如图9-5(b)所示;选取第4条边(2,3)时,将与已保留的边形成回路,应舍去;接着保留(1,5)边,舍去(3,5)边;选取到(0,1)边并保留后,保留的边数已够5条(即n-1条),此时必定将图中全部6个顶点连通起来,并且没有回路,如图9-5(c)所示,它就是图9-5(a)的最小生成树。
实现克鲁斯卡算法的关键之处是:如何判断欲加入T中的一条边是否与生成树中已保留的边形成回路。这可采用将各顶点划分为不同集合的方法来解决,每个集合中的顶点表示一个无回路的连通分量。算法开始时,由于生成树的顶点集等于图G的顶点集,边集为空,所以n个顶点分属于n个集合,每个集合中只有一个顶点,表明顶点之间互不连通。例如对于图9-5(a),其6个顶点集合为:
{0},{1},{2},{3},{4},{5}
当从边集数组中按次序选取一条边时,若它的两个端点分属于不同的集合,则表明此边连通了两个不同的连通分量,因每个连通分量无回路,所以连通后得到的连通分量仍不会产生回路,此边应保留作为生产树的一条边,同时把端点所在的两个集合合并成一个,即成为一个连通分量;当选取的一条边的两个端点同属于一个集合是,此边应放弃,因同一个集合中的顶点使连通无回路的,若再加入一条边则必产生回路。在上述例子中,当选取(0,4)4、(1,2)5、(1,3)8这3条边后,顶点的集合则变成如下3个:
{0,4},{1,2,3},{5}
下一条边(2,3)10的两端点同属于一个集合,故舍去,再下一条边(1,5)12的两端点属于不同的集合,应保留,同时把两个集合{1,2,3}和{5}合并成一个{1,2,3,5},以此类推,直到所有顶点同属于一个集合,即进行了n-1次集合的合并,保留了n-1条生成树的边为止。
为了用java语言描写出克鲁斯卡尔算法,求出图的最小生成树,设定eg是具有EdgeElement元素类型的边集数组,并假定每条边是按照权值从小到大的顺序存放的;再设定ed也是一个具有EdgeElement元素类型的边集数组,用该数组存储依次所求得的最小生成树中的每一条边;还需要使用一个参数n,表示图中的顶点数。另外,在算法内部需要定义一个具有Set元素类型的集合数组,假定用s表示,用它的每个元素表示对应的一个连通分量。
根据以上分析,给出克鲁斯卡尔算法的具体描述如下:
//克鲁斯卡尔算法
public static void Kruskal(EdgeElement [] eg,EdgeElement [] ed,int n)
{
//利用克鲁斯卡尔算法求边集数组eg所表示图的最下生成树,结果存入ed中
Set []s=new SequenceSet[n]; //定义集合数组s,每个元素是一个集合对象
for(int i=0;i