1. 程式人生 > >[從今天開始修煉資料結構]圖的最小生成樹 —— 最清楚易懂的Prim演算法和kruskal演算法講解和實現

[從今天開始修煉資料結構]圖的最小生成樹 —— 最清楚易懂的Prim演算法和kruskal演算法講解和實現

接上文,研究了一下演算法之後,發現大話資料結構的程式碼風格更適合與前文中鄰接矩陣的定義相關聯,所以硬著頭皮把大話中的最小生成樹用自己的話整理了一下,希望大家能夠看懂。

  一、最小生成樹

    1,問題

      最小生成樹要解決的是帶權圖 即 網 結構的問題,就是n個頂點,用n-1條邊把一個連通圖連線起來,並且使得權值的和最小。可以廣泛應用在修路建橋、管線運輸、快遞等各中網路方面。我們把構造連通圖的最小代價生成樹成為最小生成樹。

      最小生成樹有兩個演算法 普里姆演算法和克魯斯卡爾演算法

    2,普里姆演算法

      (1)普里姆演算法的思路是,從一個入口進入,找到距離入口最近的下一個結點;現在你有了兩個結點,找到分別與這兩個結點連通的點中,權值最小的;現在你有了三個結點,分別找到與這三個結點聯通的點中,權值最小的;……

      從思路可以看出,這就是一個切分問題。將一副加權圖中的點進行區分,它的橫切邊中權值最小的必然屬於子圖的最小生成樹。(橫切邊,指連線兩個部分的頂點的邊)也就是將已經找到的點,和沒找到的點進行區分。

      解決思路就是貪心演算法,使用切分定理找到最小生成樹的一條邊,並且不斷重複直到找到最小生成樹的所有邊。

      (2)實現思路

       下面我們就來一步一步地實現普里姆演算法。為了方便討論,我們先規定一些事情。a,只考慮連通圖,不考慮有不連通的子圖的情況。b,只考慮每條邊的權值都不同的情況,若有權值相同,會導致生成樹不唯一

        c,Vi的角標對應了其在vertex[]中儲存的角標。 d,這裡我們從V0為入口進入。e,這裡我們使用的是上一篇文章實現的鄰接矩陣儲存的圖結構。

      首先,我們拿到一張網。

      

 

     我們從V0進入,找到與V0相連的邊,鄰接點和權值。我們需要容器來儲存,因為是鄰接矩陣儲存的,所以我們這裡用一維陣列來儲存這些元素,我們仿照書中的程式碼風格,定義一個adjvex[numVertex]和一個lowcost[numVertex]。這兩個陣列的含義和具體用法我們後面再說,現在你只需要知道它們是用來橫切邊,鄰接點和權值的。adjvex[]的值被初始為0,因為我們從V0開始進入。lowcost[]的值被初始化為INFINITY,因為我們要通過這個陣列找到最小權值,所以要初始化為一個不可能的大值,以方便後續比較,但這裡的INFINITE 不需要手動設定,因為edges中已經將沒有邊的位置設定為了I,所以只需要在拷貝權值時同事將I也拷貝過來。

    現在我們的切分只區分了V0和其他點,橫切邊權值有10,11,分別對應鄰接點V1,V5,我們將V1,V5的對應權值按照其在vertex[]中儲存的角標1,5來存入lowcost,將與V1,V5對應的鄰接點V0的角標0存入adjvex[]中(其實所有頂點的鄰接點對應角標都被初始化為0)。所以我們現在得到

    adjvex[]  = { 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 };
    lowcost[] = { 0 ,10 , I , I , I , 11 , I , I , I };

  這裡大家要理解這兩個陣列的含義,我再仔細解釋一下。lowcost中存有非I元素的位置的下標,表示的是,橫切邊所連線的,沒有被連入生成樹的一端的頂點在vertex[]中儲存的下標,在這裡也等於Vi的下標,也可以理解為“剛剛被發現的結點的下標”。

  然後adjvex[]中儲存了非0元素的下標,與lowcost的中的下標是一樣的意義,只不過這裡因為被劃分的點是0所以陣列中沒有非0元素。

  lowcost中存有的非I元素,表示的是,這個位置對應的頂點與現有最小生成樹的橫切邊中權值最小的一條邊的權值。

  adjvex中存有的非0元素,是一個頂點在vertex[]中的下標,表示的是該角標index對應的vertex[index]與adjvex[index]這兩個頂點之間的權值最小,該權值是lowcost[index]。

  這樣大家應該能夠對大話資料結構中這一部分有完整的理解了。   

  我們現在從lowcost[]中找到最小的權值,然後將該權值對應的頂點加入最小生成樹。加入的方式是將該權值置為0,並且將該新結點的鄰接點在adjvex中對應的角標置為該新結點的index。

  對應現在的情況,就是把V1加入生成樹,把兩個陣列調整如下:

    adjvex[]  = { 0 , 0 , 1 , 0 , 0 , 0 , 1 , 0 , 1 };
    lowcost[] = { I , 0 , 18, I , I ,11 , 16, I , 12};

 

 

接下來我們有四條待選邊,lowcost中找到最小的非零權值是11,對應index為5,去vertex[5]找到V5,所以此時V5加入生成樹,將lowcost[5]置為0,V5的鄰接點加入adjvex和lowcost,如下

    adjvex[]  = { 0 , 0 , 1 , 0 , 5 , 0 , 1 , 0 , 1 };
    lowcost[] = { I , 0 , 18, I , 26, 0 , 16, I , 12};

 

 反覆重複上面的動作v-1次,此時就加入了v-1條邊,得到了最小生成樹。

程式碼實現如下:

    public void MiniSpanTree_Prim(){

        int[] adjvex = new int[numVertex];
        int[] lowcost = new int[numVertex];

        adjvex[0] = 0;
        lowcost[0] = 0;
        /*
        for (int i = 1; i < numVertex; i++){
            lowcost[i] = INFINITY;
        }

         */

        for (int i = 1; i < numVertex; i++)
        {
            lowcost[i] = edges[0][i];
            adjvex[0] = 0;
        }

        for (int i = 1; i < numVertex; i++){
            int min = INFINITY;
            int k = 0;
            for (int j = 1; j < numVertex; j++){
                if (lowcost[j] != 0 && lowcost[j] < min){
                  min = lowcost[j];
                  k = j;
                }
            }
            System.out.printf("(%d,%d,w = %d)加入生成樹",adjvex[k],k,min);
            System.out.println();

            lowcost[k] = 0;
            for (int j = 1; j < numVertex; j++){
                if (lowcost[j] != 0 && lowcost[j] > edges[k][j]){
                    lowcost[j] = edges[k][j];
                    adjvex[j] = k;
                }
            }
        }
    }

  2,克魯斯卡爾(Kruskal)演算法

  如果說普里姆演算法是面向頂點進行運算,那麼克魯斯卡爾演算法就是面向邊來進行運算的。

  (1)思路:克魯斯卡爾演算法的思路是,在離散的頂點集中,不斷尋找權值最小的邊,如果加入該邊不會在點集中生成環,則加入這條邊,直到獲得最小生成樹。

      所以我們的問題就是兩個,第一個:將邊按照權值排序  第二個:判斷加入一條邊之後會不會生成環

    將邊按照權值排序很容易,這裡我們用邊集陣列

    那麼如何判斷環呢? 克魯斯卡爾判斷環的依據是,在一棵樹結構中,如果對其中兩個結點新增一條原本不存在的邊,那麼一定會產生一個環。而一棵樹中,根節點是唯一確定的。我們將新增邊抽象為建立一棵樹,並用陣列parent[numVertex]儲存這棵樹的結構,下標index與結點在vertex[]中的位置vertex[index]相同,parent[index]是這個結點在這棵樹中的父親結點。每次新增一條邊,就是在擴大森林中某一棵樹,當森林全部連成一棵樹,則得到了最小生成樹。

  (2)具體步驟:我們這裡使用與普里姆相同的例子

 

其邊集陣列排序後為

 

 我們遍歷邊集陣列,首先拿到edges[0],分別判斷4和7是否擁有相同的最大頂點,方法是進入parent[]中查詢它們所在的樹的根結點是否相同。因為是第一次查詢,所以結果都是0,即它們不是同一棵樹,可以連線。連線時,將parent[4]置7或者將parent[7]置4都可以,這裡我們選擇前者。

 

 

 下面拿到edges[1],查詢parent[2],parent[8]得均為0,則不是同一棵樹,可以連線。我們將parent[2]置8

 

 下面是edges[2],查詢0,1,可以連線。

 

 接下來edges[3],查詢0,5,此時V0的父是V1,V1對應的parent[1]中儲存的0表示V1是這棵樹的父,parent[5]=0,即V0和V5不是同一棵樹,可以連線。將parent[1]置為5

 

 

接下來edges[4], 查詢1,8,不在同一棵樹,此時1所在樹的根是5,將1和8連線,此時樹合併應該將根節點5的parent[5]置為8.現在上圖的兩個棵樹合併了

 

 接下來是edges[5],查詢3,7,不在同一子樹,連線。

 

 

 接下來是edges[6],查詢1,6,不在同一子樹,連線。

 

 

 接下來是edges[7]查詢5,6,發現它們的根節點都是8,在同一棵子樹,所以不連線。

下面我就不再重複了,總之這樣迴圈檢測,可以得到最終的最小生成子樹。(注!最小生成子樹和我們上面用來判斷是否連通的樹是不同的!parent陣列也並不是唯一的!因為在構造判斷樹的時候,不管把誰做父,誰做子,都可以構建樹,並不影響判斷環的結果)

 

 

   (3)程式碼實現

   

    /*
        定義邊結構的內部類。
     */
    private class Edge implements Comparable<Edge> {
        private int begin;
        private int end;
        private int weight;

        private Edge(int begin, int end, int weight){
            this.begin = begin;
            this.end = end;
            this.weight = weight;
        }

        @Override
        public int compareTo(Edge e) {
            return this.weight - e.weight;
        }

        public int getBegin() {
            return begin;
        }

        public void setBegin(int begin) {
            this.begin = begin;
        }

        public int getEnd() {
            return end;
        }

        public void setEnd(int end) {
            this.end = end;
        }

        public int getWeight() {
            return weight;
        }

        public void setWeight(int weight) {
            this.weight = weight;
        }
    }
    /**
     * 得到排序好的邊集陣列,用ArrayList儲存
     * @return
     */
    public ArrayList<Edge> getOrderedEdges() {
        ArrayList<Edge> edgeList = new ArrayList<>();
        for (int row = 0; row < numVertex; row++){
            for (int col = row; col < numVertex; col++){
                if(edges[row][col] != 0 && edges[row][col] != INFINITY){
                    edgeList.add(new Edge(row, col, edges[row][col]));
                }
            }
        }
        Collections.sort(edgeList);
        return edgeList;
    }

    /**
     * 克魯斯卡爾演算法
     */
    public void MiniSpanTree_Kruskal(){
        ArrayList<Edge> edgeList = getOrderedEdges();
        int[] parent = new int[numVertex];
        for (int i = 0; i < numVertex; i++){
            parent[i] = 0;
        }

        for (int i = 0; i < edgeList.size(); i++){
            int m = findRoot(edgeList.get(i).getBegin(), parent);
            int n = findRoot(edgeList.get(i).getEnd(), parent);
            if (m != n){
                link(edgeList.get(i), parent, m, n);
            }
        }


    }

    /*
        連線兩點,並且設定parent陣列
     */
    private void link(Edge edge, int[] parent, int m, int n) {
        System.out.printf("(%d,%d),weight = %d 加入最小生成樹", edge.getBegin(), edge.getEnd(), edge.getWeight());
        System.out.println();

        parent[m] = n;
    }

    /*
    找到本子樹的根節點
     */
    private int findRoot(int root, int[] parent) {
        while (parent[root] > 0){
            root = parent[root];
        }
        return root;
    }

總結:克魯斯卡爾的FindRoot函式的時間複雜度由邊數e決定,時間複雜度為O(loge),而外面有一個for迴圈e次,所以克魯斯卡爾的時間複雜度是O(eloge)

  對立兩個演算法,克魯斯卡爾主要針對邊展開,邊少時時間效率很高,對於稀疏圖有很大優勢,;而普里姆演算法對於稠密圖會更好一