《大话数据结构》--图的最小生成树算法

546 阅读6分钟

最小生成树

生成树:所谓的一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但只有足以构成一棵树的n-1条边。

**给定一个带权的无向连通图,如何选取一棵生成树,使树上所有边上权的总和为最小,这叫最小生成树 **

  • N个顶点,一定有N-1条边
  • 包含全部顶点
  • N-1条边都在图中

普里姆(Prim)算法

假设N= (P,{E}) 是连通网,TE是N上最小生成树中边的集合。算法从U={uo}(uo∈V)(V中某一顶点 u0 ),TE={}开始,重复执行以下操作。在所有u∈U,v∈V-∪的边(u,v) ∈E中(一个顶点在U中,另一个顶点在V-U中).找一条代价最小的边(uo,Vo) 并入集合TE,同时vo并入U,直至U=V为止。此时TE中必有n-1条边,则T= (V,{TE}) 为N的最小生成树。

普里姆算法在找最小生成树时,将顶点分为两类,一类是在查找的过程中已经被纳入树中的顶点(A类)。另一类是未被纳入的顶点(B类)。(下面代码以lowcost的值分为A、B类。等于0是A类,不等于0是B类)
对于给定的连通网,起始状态全部顶点都归为B 类。在找最小生成树时,选定任意一个顶点作为起始点,并将之从 B 类移至 A 类;然后找出 B 类中到 A 类中的顶点之间权值最小的顶点,将之从 B 类移至 A 类,如此重复,直到 B 类中没有顶点为止。所走过的顶点和边就是该连通图的最小生成树。

由算法代码中的循环嵌套可得知此算法的时间复杂度为0(n2)

实现

1 static void MiniSpanTree_Prim(MGraph m) {    
2    // 保存相关顶点的下标,对应lowcost权值的起始点
3    int[] adjvex = new int[9];    
4    // 保存相关顶点间边的权值,为0时代表顶点被纳入最小生成树   
5    int[] lowcost = new int[9];    
6    int min, j, k;    
7    // 初始化第一个权值为0,即V0加入生成树。lowcost的值为0,代表此下标的顶点加入到生成树。    
8    lowcost[0] = 0;    
9    // 初始化第一个顶点下标为0    
10    adjvex[0] = 0;    
11    // 循环除下标为0外的全部顶点    
12    for (int i = 1; i < m.numVertexes; i++) {        // 将V0顶点有边的权值存入数组        
13        lowcost[i] = m.arc[0][i];        // 初始化下标为0        
14        adjvex[i] = 0;    
15    }    
16    for (int i = 1; i < m.numVertexes; i++) {        
17        min = INFINITY;        
18        j = 1;        
19        k = 0;        
20        while(j < m.numVertexes) {            
21            // 如果权值不为0且权值小于min            
22            if(lowcost[j]!= 0 && lowcost[j] < min) {               
23                min = lowcost[j];                
24                k = j;            
25            }            
26            j++;        
27        }        
28        // 打印当前顶点边中权值最小边        
29        System.out.print(adjvex[k] + " " + k + ",");        
30        // 把当前顶点的权值设置为0,表示此顶点已完成任务        
31        lowcost[k] = 0;        
32        for (j = 1; j < m.numVertexes; j++) {            
33            // 若下标为k顶点各边权值小于此前这些顶点未被加入生成树权值            
34            if(lowcost[j]!= 0 && m.arc[k][j] < lowcost[j]) {                
35                // 将较小权值存入lowcost                
36                lowcost[j] = m.arc[k][j];                
37                // 将下标为k的顶点存入adjvex                
38                adjvex[j] = k;            
39            }        
40        }    
41    }
42}
  • 8-10:我们分别给这两个数组的第一个下标位赋值为0,arjvex[0]=0 其实意思就是我们现在从顶点Vo 开始(事实上,最小生成树从哪个顶点开始计算都无所谓,我们假定从Vo开始),lowcost[0]=0就表示Vo已经被纳入到最小生成树中,之后凡是lowcost数组中的值被设置为0就是表示此下标的顶点被纳入最小生成树。
  • 12-14:for循环,我们读取邻接矩阵的第一行数据。将数值赋值给lowcost 数组,所以此时lowcost 数组值为 {0,10,65535,65535,65535,11,65535, 65535, 65535}, 而arjvex则全部为0。 此时,我们已经完成了整个初始化的工作,准备开始生成。
  • 16-41:整个循环过程就是构建最小生成树的过程。注意由于我们已经把V0纳入到最小生成树了,循环从一开始。整个循环主要分为3部分(1).找到当前lowcost数组中最小值,并用k保留(2)打印结果(3)查找对比k行的权值并放入lowcost数组
  • 17-27:找到当前lowcost数组中最小值,并用k保留此最小值的顶点下标。经过循环后,min=10, k=1.注意22行if判断的lowcost[j]!=0表示已经是生成树的顶点不参与最小权值的查找。
  • 29-31:因k=1, adjvex[1]=0,打印结果为(0, 1),表示v0至v1边为最小生成树的第一条边。把当前顶点(k)的权值设置为0,表示此顶点已被纳入到最小生成树
  • 32-40:j循环由1至8,因k=1,查找邻接矩阵的第V1行的各个权值,与lowcost的对应值比较,若更小则修改lowcost 值,并将k值存入adjvex数组中。因第v1行有18、16、12均比65535小,所以最终lowcost数组的值为: {0,0,18,65535,65535,11,16,65535,12}。 adjvex 数组的值为:{0,0,1,0,0,0,1,0,1}。这里if判断的lowcost[j]!=0也说明V0和V1已经是生成树的顶点不参与最小权值的比对了。
  • 再次执行16-41行,得到第二条边(0, 5)。

构造最小生成树的过程

克鲁斯卡尔(Kruskal)算法

克鲁斯卡尔算法的基本思想是以边为主导地位

假设N= (V,{E}) 是连通网,则令最小生成树的初始状态为只有n个顶点而无边的非连通图T={V,{}},图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。依次类推,直至T中所有顶点都在同一连通分量上为止。

此算法的Find函数由边数e决定,时间复杂度为0(loge),而外面有一个for循环e次。所以克鲁斯卡尔算法的时间复杂度为O(eloge)。

具体思路

  1. 将边按权值从小到大的顺序添加到新图中,保证添加的过程中不会形成环 
  2. 重复上一步直到连接所有顶点,此时就生成了最小生成树

判断是否会产生回路的方法

在初始状态下给每个顶点赋予不同的标记,对于遍历过程的每条边,其都有两个顶点,判断这两个顶点的标记是否一致,如果一致,说明它们本身就处在一棵树中,如果继续连接就会产生回路;如果不一致,说明它们之间还没有任何关系,可以连接。(代码中使用int[] parent来标记是否会生成环路)

实现

1static void MiniSpanTree_kruskal(MGraph m) {    
2    // 定义数组判断边与边是否形成环路。    
3    int[] parent = new int[m.numVertexes];    
4    // 返回边集数组,权值从小到大排序    
5    List<Edge> edgeList= getEdgeList();    
6    // 初始化数组值为0    
7    for (int i = 0; i < m.numVertexes; i++) {        
8        parent[i] = 0;    
9    }    
10    for (int i = 0; i < m.numVertexes; i++) {        
11        int x =findParent(parent, edgeList.get(i).begin);        
12        int y = findParent(parent, edgeList.get(i).end);        
13        // 假如x与y不等,说明此边没有与现有生成树形成环路        
14        if(x != y) {            
15            // 将此边的结尾顶点放入到下标为起点的parent中。表示此边已经在生成树集合中            
16            parent[x] = y;           
17            System.out.println("(" + edgeList.get(i).begin + "," + edgeList.get(i).end + ")," + edgeList.get(i).weight);        
18        }    
19    }
20}
// 查找连线顶点的尾部下标
21static int findParent(int[] parent, int f) {    
22    while(parent[f] > 0) {        
23        f = parent[f];    
24    }    
25    return f;
26}

分析代码的执行步骤

1-9:是对循环做的准备工作
10-19:对输入的边集数组进行循环遍历

  • i=0:调用函数findParent,传入的参数是数组parent和当前权值最小边(V4,V7) 的begin: 4。因为parent中全都是0所以传出值使得n=4。传入(V4,V7) 的end: 7。传出值使得m=7。n与m不相等,因此parent[4]=7。 此时parent数组值为{0,0, 0, 0, 7, 0, 0, 0, 0},此时我们已经将边(V4,V7)纳入到最小生成树中。

  • i=1、2、3、4、5时,如图

  • 当i=6时,此时parent数组为{1,5,8,7,7,8,0,0,6}。由i=6的粗线连线可以得到,我们其实是有两个连通的边集合A与B中纳入到最小生成树中。

  • 当parent[0]=1, 表示v0和V1已经在生成树的边集合A中。此时将parent[0]=1的1改为下标,由parent[1]=5,表示V1和V5在边集合A中,parent[5]=8 表示Vs与V8在边集合A中,parent[8]=6表示v8与V6在边集合A中,parent[6]=0表示集合A暂时到头,我们查看parent中没有查看的值,parent[2]=8 表示V2与V8在一个集合中,因此v2也在边集合A中。再由parent[3]=7、parent[4]=7和parent[7]=0可知此时边集合A(V0、 V1、V2、V5、V8、V6)。集合B中(V3、V4、V7)。

  • 当i=7时,第10行,调用Find函数,会传入参数edges[7].begin=5,得到n=6。传入参数edges[7].end=6得到m=6。此时n=m。这就告诉我们,因为边(V5,V6)使得边集合A形成了环路。因此不能将它纳入到最小生成树中。当i=8时,与上面相同,由于边(V1,V2) 使得边集合A形成了环路。

  • 当i=9时,边(v6,V7),第11行得到n=6,第12行得到m=7,因此parent[6]=7。 此时parent 数组值为{1, 5, 8, 7, 7,8,7, 0,6}。此后边的循环均造成环路。

最终最小生成树

普里姆算法和克鲁斯卡尔算法的区别

克魯斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势;普里姆算法对于稠密图,即边数非常多的情况会更好一些。