美文网首页
《算法》-图[最小生成树]

《算法》-图[最小生成树]

作者: 算法手记 | 来源:发表于2020-09-12 02:39 被阅读0次

    最小生成树

    简单理解

    在这里插入图片描述

    在前面我们了解到了无向图和加权有向图,类似的我们给无向图的每一条边加上权重,就得到了加权无向图

    最小生成树:图的生成树是它的一棵含有所有顶点的无环连通子图。加权图的最小生成树(MST)是它的一棵权值之和最小的生成树
    [图片上传失败...(image-65234b-1599849589240)]
    最小生成树算法有很多应用,比如顶点是城市,边是城市之间的航线,那么最小生成树可以看作覆盖这些城市做需要的最短总航线。下面我们先来看基本的实现原理。

    贪心算法

    首先我们先来介绍一个概念切分:切分就是将图的所有顶点分为两个非空且不重叠的两个集合,横切边是一条连接两个属于不同集合顶点的边

    切分定理:在一副加权图中,给定任意切分,它的横切边中权重最小的必然位于最小生成树中

    贪心算法是所有最小生成树算法的基础,基本原理就是从一个点开始,以已经找到的最小生成树的顶点,和其他顶点作为一个切分,找出权重最小的横切边,加入最小生成树,不断进行下去,直到包含了所有顶点。

    public class Edge implements Comparable<Edge>{
        private final int v;
        private final int w;
        private final double weight;
    
        public Edge(int v, int w, double weight){
            this.v = v;
            this.w = w;
            this.weight = weight;//边的权重
        }
    
        public double weight(){
            return weight;
        }
        public int either(){
            return v;
        }
        public int other(int vertex){//返回另一个顶点
            if      (vertex == v) return w;
            else if (vertex == w) return v;
            else throw new RuntimeException("Inconsistent edge");
        }
    
        public int compareTo(Edge that){//边是可以按照权重大小比较的
            if    (this.weight<that.weight)   return -1;
            else if  (this.weight>that.weight) return +1;
            else return 0;
        }
    
    
        public String toString(){
            return String.format("%d-%d  %.2f", v,w,weight);
        }
    }
    

    然后才是加权无向图的实现

    public class EdgeWeightedGraph {
        private final int V;
        private int E;
        private Bag<Edge>[] adj;
    
        public EdgeWeightedGraph(int V){
            this.V = V;
            this.E = 0;
            adj = (Bag<Edge>[]) new Bag[V];
            for(int v = 0; v<V;v++){
                adj[v] = new Bag<Edge>();
            }
        }
    
        public EdgeWeightedGraph(In in){
            this(in.readInt());
            int E = in.readInt();
            for(int i=0;i<E;i++){
                int v = in.readInt();
                int w = in.readInt();
                Double weight = in.readDouble();
                Edge e = new Edge(v, w, weight);
                addEdge(e);
            }
        }
    
        public int V(){return V;}
        public int E(){return E;}
    
        public void addEdge(Edge e){
            int v = e.either();
            int w = e.other(v);
            adj[v].add(e);
            adj[w].add(e);
            E++;
        }
        public Iterable<Edge> adj(int v){
            return adj[v];
        }
    
        public Iterable<Edge> edges(){
            Bag<Edge> bag = new Bag<Edge>();
            for(int v=0;v<V;v++)
                for (Edge e:adj[v])
                    if(e.other(v)>v) bag.add(e);
            return bag;
        }
    
    }
    

    值得注意的一点是,因为是无向图,所以添加的时候要在两个端点的Bag里面都要添加,但是因为添加的是Edge对象的引用,所以所有的Edge对象只存了一份。

    Prim 算法

    这里介绍的Prim算法和下面将要介绍的Kruskal算法基本原理都是上面说的贪心算法,可以发现算法实现的关键就是找出横切边。这两个算法被发现的年代都很早,在《算法4》中,作者都使用的是最小优先队列,这个在博客《算法4》优先队列和堆排序中有过介绍,但是在Prim算法的即时版本中使用的是索引优先队列,具体的这种数据结构放在稍后介绍,主要功能是差不多的,在第一个位置能够得到最小的键值的索引,不过先来看Prim算法的具体实现原理。
    这里只介绍书上的即时版本。代码中维护几个重要的数据结构:

    edgeTo数组: 对于索引i,edgeTo[i]表示对于顶点i,将其链接到最小生成树上的边。
    distTo数组:distTo[w] = edgeTo[w].weight(),就是上面所说的对应的边的权重。
    marked数组:顶点v在树中则为true
    IndexMinPQ的索引优先队列:保存顶点值如w和它到树的所有边中权重的最小值,也是distTo[w]的值,这个值会变化,只要在树的生长过程中发现某个顶点v到w的边的权重比distTo[w]小,那么distTo[w]的值就会改成边v-w的权值,相应的edgeTo[w]也会改成边v-w。

    下面是从教材上截取的一幅图:


    在这里插入图片描述

    我们可以大致看下算法的运行过程,首先将0加入优先队列,之后将其弹出,插入和0相邻的顶点,图中左边显示了edgeTo[]和distTo[],红色的值代表当前阶段在优先队列中的边,也就是图示中细红色的边。再看第6步,原本是0-4这条边连接在生成树上的,但是当树生长到5节点的时候,发现5-4的权重更低,所以就更改了4连到树上的边。最后还有一点需要注意,树内的顶点是不能连接的,否则就会形成环,所以在生长的过程中不能连接marked[]为true的节点,下面是Prim算法的代码:

    public class PrimMST {
        private Edge[] edgeTo;
        private double[] distTo;
        private boolean[] marked;
        private IndexMinPQ<Double> pq;//保存当前树的横切边
    
        public PrimMST(EdgeWeightedGraph G){
            edgeTo = new Edge[G.V()];
            distTo = new double[G.V()];
            marked = new boolean[G.V()];
            for (int v = 0;v<G.V();v++)
                distTo[v] =Double.POSITIVE_INFINITY;
            pq = new IndexMinPQ<Double>(G.V());
    
            distTo[0] = 0.0;
            pq.insert(0, 0.0);
            while(!pq.isEmpty())
                visit(G, pq.delMin());//找到权重最小的横切边,和相应的顶点,然后再加入新的横切边
        }
    
        private void visit(EdgeWeightedGraph G, int v){
            marked[v] =true;//将顶点v加入生成树
            for (Edge e: G.adj(v)){//遍历v的所有边
                int w = e.other(v);
    
                if (marked[w]) continue;//另一个顶点如果在树中就不加入
                if (e.weight()<distTo[w]){//发现更小权重的边就对数据结构更新
                    edgeTo[w] =e;
                    distTo[w] =e.weight();
    
                    if(pq.contains(w))    pq.change(w, distTo[w]);
                    else                  pq.insert(w, distTo[w]);
                    //优先队列中有的话就更新,没有的话就插入。
                }
            }
        }
    
        public Iterable<Edge> edges(){
            Bag<Edge>  mstBag = new Bag<Edge>();
            for(int v=1;v<edgeTo.length;v++)
                mstBag.add(edgeTo[v]);
            return mstBag;
        }
    
        public double weight(){
            double weight = 0;
            for (int i=1;i<edgeTo.length;i++)
                weight+=distTo[i];
            return weight;
        }
    
    
    }
    
    

    索引优先队列在本篇中不是重点,下面只给出代码实现,不做具体分析:

    public class IndexMinPQ<Key extends Comparable<Key>> implements Iterable<Integer> {
        private int N;
        private int[] pq;
        private int[] qp;
        private Key[] keys;
        private int maxN ;
    
        public IndexMinPQ(int maxN){
    
            this.maxN =maxN;
            N=0;
            keys = (Key[]) new Comparable[maxN+1];
            pq  =new int[maxN+1];
            qp  =new int[maxN+1];
            for (int i=0;i<=maxN;i++) qp[i] = -1;
        }
    
        public boolean isEmpty(){
            return N==0;
        }
    
        public boolean contains(int k){
            return qp[k] !=-1;
        }
        public int size(){
            return N;
        }
        private boolean greater(int i, int j){
            return keys[pq[i]].compareTo(keys[pq[j]])>0;
        }
        private void exch (int i, int j){
            int swap = pq[i];
            pq[i] = pq[j];
            pq[j] =swap;
    
            qp[pq[i]] =i;
            qp[pq[j]] =j;
        }
    
        public void insert(int k, Key key){
            N++;
            qp[k] =N;
            pq[N] = k;
            keys[k] =key;
            swim(N);
    
        }
    
        public Key min(){
            return keys[pq[1]];
        }
    
        private void swim(int k){
            while(k>1 && greater(k/2, k)){  
                exch( k, k/2);
                k = k/2;
            }
        }
    
        private void sink(int k){
            while(2*k<N){
                int j=2*k;
                if (j<=N &&  greater(j, j+1)) j++;
                if (!greater(k, j)) break;
                exch( k, j);
                k=j;
            }
        }
    
        public void change(int i, Key key){
            keys[i] =key;
            swim(qp[i]);
            sink(qp[i]);
        }
    
        public int delMin(){
            int min =pq[1];
            exch(1, N--);
            sink(1);
            assert min == pq[N+1];
            qp[min] = -1;
            keys[min] = null;
            pq[N+1] = -1;return min;
        }
    
        public void delete(int i){
            int index = qp[i];
            exch(index, N--);
            swim(index);
            sink(index);
            keys[i] = null;
            qp[i] = -i;
        }
    
        public Iterator<Integer> iterator() { return new HeapIterator(); }
    
        private class HeapIterator implements Iterator<Integer> {
            // create a new pq
            private IndexMinPQ<Key> copy;
    
            // add all elements to copy of heap
            // takes linear time since already in heap order so no keys move
            public HeapIterator() {
                copy = new IndexMinPQ<Key>(pq.length - 1);
                for (int i = 1; i <= N; i++)
                    copy.insert(pq[i], keys[pq[i]]);
            }
    
            public boolean hasNext()  { return !copy.isEmpty();                     }
            public void remove()      { throw new UnsupportedOperationException();  }
    
            public Integer next() {
                if (!hasNext()) throw new NoSuchElementException();
                return copy.delMin();
            }
        }
    
    
    }
    

    Kruskal算法

    Prim算法是从一个顶点开始不断地生长,Kruskal算法却不是这样,他的基本想法是,对所有边的权重进行排序,然后从小到大,不断地将最小的边加入优先队列,这里又用到了优先队列。到最后所有的边连在一起形成一整棵树的时候,算法就可以停止。但是要注意的是,即使我们得到了一个权重最小的边,也不一定能加入到树中,新加入的边不能让已有的边形成环,因为生成树肯定是没有环的。《算法4》中又使用了一种数据结构UnionFound,可以用来快速查找节点之间是否连通,本质上还是树,查找的时候从叶结点不断上溯到根节点,以根节点为连通分量的区分标志。具体的结构不再详述,感兴趣的可查找其他资料。


    在这里插入图片描述

    看着上面的图就会发现过程很好理解,右边是按照权重排序好的边,黑色的是插入树的边,灰色的是插入时会形成环的,所以没有插入的边,这样的边直接略过,最后只要总的边数达到V-1就行了。下面是代码示例:

    public class KruskalMST {
        private Queue<Edge> mst;
    
        public KruskalMST(EdgeWeightedGraph G){
            mst = new Queue<Edge>();
            MinPQ<Edge> pq = new MinPQ<Edge>(G.E());
            for(Edge e:G.edges())pq.insert(e);
            WeightedQuickUnionUF uf =new WeightedQuickUnionUF(G.V());
    
            while(!pq.isEmpty() && mst.size()<G.V()-1){
                Edge edge= pq.delMin();
                int v= edge.either(), w=edge.other(v);
                if (uf.connected(v, w)) continue;
                uf.union(v, w);
                mst.enqueue(edge);
            }
        }
    
        public Iterable<Edge> edges(){
            return mst;
        }
    
        public double weight(){
            double weight =0 ;
            for (Edge e:mst)
                weight+=e.weight();
            return weight;
        }
    
        }
    
    

    可以看出代码很短,当然也是因为用了比较高级的数据结构,算法本身其实也不复杂,下面是UnionFound的代码,以供参考

    public class WeightedQuickUnionUF {
        private int[] id;
        private int[] sz;
        private int count;
    
        public WeightedQuickUnionUF(int N){
            count = N;
            id =new int[N];
            for(int i=0;i<N;i++) id[i] =i;
            sz = new int[N];
            for (int i=0;i<N;i++) sz[i] =i;
        }
        public int count(){return count;}
    
        public boolean connected(int p, int q){
            return find(p)==find(q);
        }
    
        public int find(int p){
            while(p!=id[p]) p=id[p];
            return p;
        }
        public void union(int p, int q){
            int i = find(p);
            int j = find(q);
            if (i==j) return ;
            if (sz[i]<sz[j])  {id[i] =j;sz[j]+=sz[i];}
            else              {id[j] = i; sz[i]+=sz[j];}
            count--;
        }
    
    }
    
    

    这里用的是一种加权的算法,可以有效地降低树的高度,增加查找的速度

    总结

    对于时间成本上面介绍的即使Prim算法的速度最坏情况下可以达到ElogV,因为它维护的是一个关于顶点的优先队列,所以一次最坏速度为 logV,算法进行过程中需要V次插入,V次删除最小值,以及最坏情况下E次改变权重,而每次改变权重都需要重新使堆有序,也就是又一次logV,所以总共的最坏速度为ElogV
    对于Kruskal算法就要慢一些对于最坏情况下速度为ElogE,主要是因为它维护了一个所有边的优先队列,那每次对优先队列操作成本为logV,而最坏情况下需要遍历所有边才能够得到我们需要的V-1条,其他的UnionFound的操作因为是对顶点进行的,所以乘的是logV,量级上比较小,所以忽略。
    综上,是最小生成树的两种常用算法。

    参考:
    最小生成树

    相关文章

      网友评论

          本文标题:《算法》-图[最小生成树]

          本文链接:https://www.haomeiwen.com/subject/ekviektx.html