求无向连通图的最小生成树算…

时间:2021-01-11 12:40:47

最小生成树是图论里很重要的部分。但是由于它属于图论所以NOIP基本不考,对于NOI又太基础,所以竞赛中出现的几率比较小,即使要考也不可能考裸的生成树算法= =

最小生成树就Prim和Kruskal两个算法,又没有多大的优化余地,所以学习起来还是很简单的。

    

 

 

一.Prim算法

     

 

1.算法思想

 

    对于图G=(V,E),用Prim算法求最小生成树T=(S,TE)的流程如下

   ① 初始化:设S、TE为空集,任选节点K加入S。

    ② 选取一条权值最小的边(X,Y),其中X∈S,且not (Y∈S) 即,选取一条权值最小的、连接着S中一点与S外一点的边。

       将Y加入S中,边(X,Y)加入TE中

    重复② 直到V=S即所有G中的点都在S中,此时的T为G的最小生成树。

 

    由此流程可见,Prim算法求最小生成树时任何时候的T都是一颗树。

        

 

 

2.实现

                        

 

       显然,Prim算法的主要运行时间花在过程②的选边中。看起来复杂度是O(VE)=O(V^3)不是么,效率也太低了吧……

                                     

 

       为了比较快速地选边,我们用两个数组lowcost、closest动态地维护每一个点到S的最短距离。在某一状态下,lowcost[i]表示所有与i相连且另一端点在S中的边中的权值最小值,closest[i]表示在S中且与i相连的点中与i之间距离最小的点。显然,lowcost[i]=w(i,closest[i])。需要注意的是两个数组记录的都是而不是路径。若i没有边直接连向S,则lowcost[i]=∞。另外,若i已在S中,则lowcost[i]=0。

  

       设出发点为x。初始时对于任意k∈V,closest[k]=x,lowcost[k]=w(k,x)【w(i,j)表示i、j间的距离。初始化时,若两点间没有边则w(i,j)赋为一个足够大的整数(如maxint),并且所有点到自身的距离赋为0,即w(i,i)=0】

    

       每一次找出lowcost中不为0的最小值lowcost[i],然后把i加入S(即lowcost[i]:=0),然后对于图中所有点k,若w(k,i)

  

 

以上操作重复|V|-1次结束。由于每次加入S的点i都在当时取到了符合流程②的边min{lowcost},而lowcost[i]=w(i,closest[i]),所以此时的最小生成树的各边就是(i,closest[i]),i∈V且not (i=x)【需要注意的是出发点x的closest[x]还是x,所以应忽略,实际取到x-1条边】。把i从1取到|V|,便得到最小生成树T的每条边。

         

 

 

Pascal程序:(摘自NOCOW,略有改动)

(设边权存于数组cost,图的顶点个数|V|为n)

 

求无向连通图的最小生成树算…

 

 

 

不难看出,以上算法包含一个二重循环,算法复杂度为O(V^2),与图的稠密度无关。

 

         

 

 

 

3.使用堆优化

       

    如果我们把要取的边作为一个最小优先级队列,用堆来选取边与调整,则算法可以得到优化。

   

对于上面代码绿色部分,转换成堆后就是一个建立一个有V个节点的小根堆的过程,复杂度为O(V);对于红色部分,就是取堆顶元素【O(1)】并删除、维护【O(logV)】;对于橙色部分,对于for循环我们可以用一个数组edge[i]记录与i节点相连的所有边,这样我们就把for循环的复杂度变成了与E相关。显然,图中的每条边仅在其两顶点进入最小生成树时被访问一次,也就是每条边被访问2次,因此橙色部分的执行次数总共是2E次而与外面的while无关。里面的lowcost修改时需要维护堆,复杂度为O(logV)。因此,算法的总复杂度为O(VlogV+ElogV)=O(ElogV)。具体程序见本文最下方。

   

对于|E|接近|V|的稀疏图,算法复杂度接近O(VlogV)显然比O(V^2)的算法优秀很多。然而对于E接近N^2的稠密图,算法复杂度接近O(V^2logV)反而不如平方算法。因此,使用堆优化的Prim算法适用于稀疏图,而不优化的Prim算法适用于稠密图。不过O(V^2logV)实际上不比O(V^2)慢多少,而现在的题目大多是稀疏图,所以这个算法在总体上是优于朴素Prim算法的。

   

 

顺便一提,如果用斐波那契堆来做的话,时间复杂度可以进一步优化为O(E+VlogV)。但事实上这样做的代码长度会到令人发指的地步,所以是一种只存在于论文的算法。应付一般的竞赛用堆优化的Prim就可以应付所有数据了。

            

 

 

 

二.Kruskal算法

        

 

1.算法思想


    设最小生成树为T=(V,TE),初始时设TE为空集。将G中所有边按权值排序,从小到大选取边。若当前选取的边加入TE后不会使TE出现环,则将边加入TE,否则舍弃之。当TE中有n-1条边时,算法结束。此时T=(V,TE)为G的最小生成树。

   

关于判断是否会产生环,有一个简单的方法:若i与j中已有一条路径i-k1-k2-…-kn-j,若加入边(i,j)就会形成环【i-k1-k2-…-kn-j-i】。因此,若一条边连接两个连通分量就不会形成环,反之则会形成环。初始时每一个点就是一个连通分量,当加入(i,j)时,判断i与j是否属于不同连通分量。如果是的话,在加入边后把i与j所在的连通分量合并。重复以上操作,就可以保证不会产生环。

          

 

 

2.实现

 

 

显然,Kruskal的难点在于判断、合并连通分量。Pascal语言中已有集合类型可以实现这一操作,不过竞赛中set是一个极其危险的类型【基本常识】。此处只用到查询、合并两个操作,是一个很明显的并查集模型,每一个连通分量就是并查集里的一个集合,通过并查集的算法即可实现算法。

   

 

3.时间复杂度分析

  

对E条边排序的复杂度为O(ElogE);E次并查集的查找、合并的复杂度也约为O(ElogE),因此算法的复杂度为O(ElogE)。又由于|E|的取值范围为【|V|-1】~【|V|(|V|-1)/2】=【|V|】~【|V|^2】,所以log|E|的范围是【log|V|】~【2log|V|】。因此O(logE)可以写作O(logV)。为了方便与Prim算法复杂度的比较,一般来说把Kruskal的复杂度表述为O(ElogV)

         

 

 

 

三、Prim、Kruskal、Prim+Heap算法效率实测

   

评测环境:WindowsXP,FreePascal2.40,Pentium(R) Dual-Core CPU T4300@2.10GHz,2G内存

求无向连通图的最小生成树算…
求无向连通图的最小生成树算…
求无向连通图的最小生成树算…
求无向连通图的最小生成树算…
求无向连通图的最小生成树算…



 

 

通过上图可以看出:

1.Prim在稠密图中比Kruskal优,在稀疏图中比Kruskal劣。

2.Prim+Heap在任何时候都有令人满意的的时间复杂度,但是代价是空间消耗极大。【以及代码很复杂>_<】

3.时间复杂度并不能反映出一个算法的实际优劣。

 

 

竞赛所给的题大多数是稀疏图,所以尽可能地使用Prim+Heap吧,在稀疏图中这是无敌的。如果一定要在朴素Prim和Kruskal里选一个的话那就用Kruskal吧。当然Prim的代码比较简单,对付水题用Prim也无所谓,只要不是极稀疏图两者相差不大。

          

 

 

最后附上Pascal写的Prim+Heap程序:

       

此处为了优化时间复杂度,把邻接表和邻接矩阵都用到了,否则对邻接矩阵的初始化都要n^2的时间,比整个ElogV的算法还慢【笑】。这也是堆优化的Prim容易超空间的原因= =【cost-n^2,edge-n^2,lowcost、closest、heap、arc-4×n====>2n^2+4n……】

当然,邻接矩阵实际上根本用不上,把邻接表的基类型改成record记录端点和权值就省掉了n^2的空间,但是……实际上是我太懒了【小声】

const maxn=12000;

var n,m,tot:longint;

    cost:array[1..maxn,1..maxn] of longint;

    edge:array[1..maxn,0..maxn] of longint;

    lowcost,closest,heap,arc:array[1..maxn] of longint;

    heapsize:longint;

procedure init;

var i,j,k,l:longint;

begin

  readln(n,m);

  for i:=1 to m do begin

    readln(j,k,l);

    cost[j,k]:=l;

    cost[k,j]:=l;

    inc(edge[j,0]);

    edge[j,edge[j,0]]:=k;

    inc(edge[k,0]);

    edge[k,edge[k,0]]:=j;

  end;

  for i:=1 to n do begin

    lowcost[i]:=maxlongint;

    closest[i]:=-1;

    heap[i]:=i;

    arc[i]:=i;

  end;

  lowcost[1]:=0;

  heapsize:=n;

  tot:=0;

end;

procedure shift(i:longint);

var l,r,t:longint;

begin

  l:=2*i;

  r:=2*i+1;

  if (l<=heapsize) and (lowcost[heap[l]]

  if (r<=heapsize) and (lowcost[heap[r]]

  if t<>i then begin

    l:=heap[i];

    heap[i]:=heap[t];

    heap[t]:=l;

    arc[heap[t]]:=t;

    arc[heap[i]]:=i;

    shift(t);

  end;

end;

procedure prim_heap;

var i,j,k,l,t:longint;

begin

  for i:=n div 2 downto 1 do

    shift(i);

  for i:=1 to n do begin

    j:=heap[1];

    if closest[j]<>-1 then begin

      inc(tot,cost[j,closest[j]]);

      writeln(j,'-',closest[j]);

    end;

    lowcost[heap[1]]:=0;

    heap[1]:=heap[heapsize];

    arc[heap[1]]:=1;

    dec(heapsize);

    shift(1);

    for k:=1 to edge[j,0] do

      if cost[edge[j,k],j]

        lowcost[edge[j,k]]:=cost[edge[j,k],j];

        l:=arc[edge[j,k]];

        while (l>1) and (lowcost[heap[l div 2]]>lowcost[heap[l]]) do

        begin

          t:=heap[l];

          heap[l]:=heap[l div 2];

          heap[l div 2]:=t;

          arc[heap[l]]:=l;

          arc[heap[l div 2]]:=l div 2;

          l:=l div 2;

        end;

        closest[edge[j,k]]:=j;

      end;

  end;

  writeln('Total=',tot);

end;

begin

  assign(input,'graph.in');

  assign(output,'tree.out');

  reset(input);

  rewrite(output);

  init;

  prim_heap;

  close(input);

  close(output);

end.