資料結構與演算法——克魯斯卡爾(Kruskal)演算法

天然呆dull發表於2021-10-05

應用場景-公交站問題

某城市新增 7 個站點(A, B, C, D, E, F, G) ,現在 需要修路把 7 個站點連通,各個站點的距離用邊線表示(權) ,比如 A – B 距離 12公里 問:如何修路保證 各個站點都能連通,並且 總的修建公路總里程最短?

如上圖所示:要求和前面的普利姆演算法中的修路問題是一樣的要求,只是換了一個背景。

克魯斯卡爾演算法介紹

克魯斯卡爾(Kruskal)演算法,是用來求加權連通圖的最小生成樹的演算法。

基本思想:按照權值從小到大的順序選擇 n-1 條邊,並保證這 n-1 條邊不構成迴路

具體做法:

  1. 首先構造一個只含 n 個頂點的森林
  2. 然後依權值從小到大從連通網中選擇邊加入到森林中,並使森林中不產生迴路,直至森林變成一棵樹為止

克魯斯卡爾演算法圖解

在含有 n 個頂點的連通圖中選擇 n-1 條邊,構成一棵極小連通子圖,並使該連通子圖中 n-1 條邊上權值之和達到最小,則稱其為連通網的最小生成樹。

例如,對於如上圖 G4 所示的連通網可以有多棵權值總和不相同的生成樹。

有多種不同的連通方式,但是哪一種權值才是最優的呢?下面是克魯斯卡爾演算法的圖解步驟:

以上圖 G4 為例,來對克魯斯卡爾進行演示(假設,用陣列 R 儲存最小生成樹結果)。

  • 第 1 步:將邊E,F [2]加入 R 中。

    E,F 的權值最小,因此將它加入到最小生成樹結果 R 中。

  • 第 2 步:將邊 C,D [3]加入 R 中。

    上一步操作之後,邊 C,D 的權值最小,因此將它加入到最小生成樹結果 R 中。

  • 第 3 步:將邊 D,E [4] 加入 R 中。

    同理,權值最小

  • 第 4 步:將邊 B,F [7] 加入 R 中。

    上一步操作之後,邊 C,E [5] 的權值最小,但 C,E 會和已有的邊構成迴路;因此,跳過邊C,E。同理,跳過邊 C,F [6]。將邊 B,F 加入到最小生成樹結果R中。

  • 第 5 步:將邊 E,G [8] 加入 R 中。 同理

  • 第 6 步:將邊 A,B [12] 加入 R 中。

    上一步操作之後,邊 F,G [9] 的權值最小,但 F,G 會和已有的邊構成迴路;因此,跳過邊 F,G 。同理,跳過邊 B,C [10]。將邊 A,B 加入到最小生成樹結果R中。 此時,最小生成樹構造完成!它包括的邊依次是:<E,F> <C,D> <D,E> <B,F> <E,G> <A,B> 總里程為 36。

動圖:

克魯斯卡爾演算法分析

根據前面介紹的克魯斯卡爾演算法的基本思想和做法,我們能夠了解到,克魯斯卡爾演算法重點需要解決的以下兩個問題:

  1. 對圖的所有邊按照權值大小進行排序。

    此問題採用排序演算法進行排序即可

  2. 將邊新增到最小生成樹中時,怎麼樣 判斷是否形成了迴路

    處理方式是:記錄頂點在 最小生成樹 中的終點,頂點的終點是 在最小生成樹中與它連通的最大頂點。然後每次需要將一條邊新增到最小生存樹時,判斷該邊的兩個頂點的終點是否重合,重合的話則會構成迴路。

如何判斷迴路?

在將 E,FC,D D,E 加入到最小生成樹 R 中之後,這幾條邊的頂點就都有了終點:

  • C 的終點是 F。
  • D 的終點是 F。
  • E 的終點是 F。
  • F 的終點是 F。

終點的說明:(備註:光看這個沒有接觸過該演算法的不明白,在程式碼後面有詳細的解釋)

  • 就是將所有頂點按照從小到大的順序排列好之後;某個頂點的終點就是 與它連通的最大頂點

    難道是說 CD、DE、EF 他們是一條線,從小到大,所以他們的終點都是 F?

  • 因此,接下來,雖然 C,E 是權值最小的邊。但是 C 和 E 的終點都是 F,即它們的終點相同,因此,將 C,E 加入最小生成樹的話,會形成迴路。這就是判斷迴路的方式。也就是說,我們加入的邊的兩個頂點 不能都指向同一個終點,否則將構成迴路。【後面有程式碼說明】

程式碼實現

無向圖構建

這裡使用上一章的普利姆演算法中實現的無向圖構建,簡單修改下

/**
 * 克魯斯而
 */
public class KruskalCase {
    // 不連通的預設值:0 則代表同一個點
    int INF = 100000;

    /**
     * 圖:首先需要有一個帶權的連通無向圖
     */
    class MGraph {
        int vertex;  // 頂點個數
        int[][] weights;  // 鄰接矩陣
        char[] datas; // 村莊資料
        int edgeNum; // 共有多少條邊

        /**
         * @param vertex  村莊數量, 會按照數量,按順序生成村莊,如 A、B、C...
         * @param weights 需要你自己定義好那些點是連通的,那些不是連通的
         */
        public MGraph(int vertex, int[][] weights) {
            this.vertex = vertex;
            this.weights = weights;

            this.datas = new char[vertex];
            for (int i = 0; i < vertex; i++) {
                // 大寫字母 A 從 65 開始
                datas[i] = (char) (65 + i);
            }
            // 計算有多少條邊
            for (int i = 0; i < weights.length; i++) {
                /*
                        A       B       C       D       E       F       G
                A       0       12      100000  100000  100000  16      14
                B       12      0       10      100000  100000  7       100000
                j = i + 1:比如:
                        i=0,j=1, 那麼就是 A,B 從而跳過了 A,A
                        i=1,j=2, 那麼就是 B,C 從而跳過了 B,A  B,B
                        那麼含義就出來了:跳過雙向邊的統計,也跳過自己對自己值得為 0 的統計
                 */
                for (int j = i + 1; j < weights.length; j++) {
                    if (weights[i][j] != INF) {
                        edgeNum++;
                    }
                }
            }
        }

        public void show() {
            System.out.printf("%-8s", " ");
            for (char vertex : datas) {
                // 控制字串輸出長度:少於 8 位的,右側用空格補位
                System.out.printf("%-8s", vertex + " ");
            }
            System.out.println();
            for (int i = 0; i < weights.length; i++) {
                System.out.printf("%-8s", datas[i] + " ");
                for (int j = 0; j < weights.length; j++) {
                    System.out.printf("%-8s", weights[i][j] + " ");
                }
                System.out.println();
            }
        }
    }

    @Test
    public void mGraphTest() {
        int[][] weights = new int[][]{
                //     A    B    C    D    E    F   G
                /*A*/ {0, 12, INF, INF, INF, 16, 14},
                /*B*/ {12, 0, 10, INF, INF, 7, INF},
                /*C*/ {INF, 10, 0, 3, 5, 6, INF},
                /*D*/ {INF, INF, 3, 0, 4, INF, INF},
                /*E*/ {INF, INF, 5, 4, 0, 2, 8},
                /*F*/ {16, 07, INF, INF, 2, 0, 9},
                /*G*/ {14, INF, INF, INF, 8, 9, INF}
        };
        MGraph mGraph = new MGraph(7, weights);
        mGraph.show();
        System.out.printf("共有 %d 條邊", mGraph.edgeNum);
    }
}

測試輸出

        A       B       C       D       E       F       G       
A       0       12      100000  100000  100000  16      14      
B       12      0       10      100000  100000  7       100000  
C       100000  10      0       3       5       6       100000  
D       100000  100000  3       0       4       100000  100000  
E       100000  100000  5       4       0       2       8       
F       16      7       100000  100000  2       0       9       
G       14      100000  100000  100000  8       9       100000  
共有 12 條邊

克魯斯卡爾演算法實現

 // 不連通的預設值:0 則代表同一個點
    int INF = 100000;

    /**
     * 圖:首先需要有一個帶權的連通無向圖
     */
    class MGraph {
        int vertex;  // 頂點個數
        int[][] weights;  // 鄰接矩陣
        char[] datas; // 村莊資料
        int edgeNum; // 共有多少條邊

        /**
         * @param vertex  村莊數量, 會按照數量,按順序生成村莊,如 A、B、C...
         * @param weights 需要你自己定義好那些點是連通的,那些不是連通的
         */
        public MGraph(int vertex, int[][] weights) {
            this.vertex = vertex;
            this.weights = weights;

            this.datas = new char[vertex];
            for (int i = 0; i < vertex; i++) {
                // 大寫字母 A 從 65 開始
                datas[i] = (char) (65 + i);
            }
            // 計算有多少條邊
            for (int i = 0; i < weights.length; i++) {
                /*
                        A       B       C       D       E       F       G
                A       0       12      100000  100000  100000  16      14
                B       12      0       10      100000  100000  7       100000
                j = i + 1:比如:
                        i=0,j=1, 那麼就是 A,B 從而跳過了 A,A
                        i=1,j=2, 那麼就是 B,C 從而跳過了 B,A  B,B
                        那麼含義就出來了:跳過雙向邊的統計,也跳過自己對自己值得為 0 的統計
                 */
                for (int j = i + 1; j < weights.length; j++) {
                    if (weights[i][j] != INF) {
                        edgeNum++;
                    }
                }
            }
        }

        public void show() {
            System.out.printf("%-8s", " ");
            for (char vertex : datas) {
                // 控制字串輸出長度:少於 8 位的,右側用空格補位
                System.out.printf("%-8s", vertex + " ");
            }
            System.out.println();
            for (int i = 0; i < weights.length; i++) {
                System.out.printf("%-8s", datas[i] + " ");
                for (int j = 0; j < weights.length; j++) {
                    System.out.printf("%-8s", weights[i][j] + " ");
                }
                System.out.println();
            }
        }
    }

	/**
     * 將無向圖中的邊 轉換成物件陣列
     *
     * @param graph
     * @return
     */
    public Edata[] convertEdatas(MGraph graph) {
        Edata[] datas = new Edata[graph.edgeNum];
        int[][] weights = graph.weights;
        char[] vertexs = graph.datas;
        int index = 0;
        for (int i = 0; i < weights.length; i++) {
            for (int j = i + 1; j < weights.length; j++) {
                if (weights[i][j] != INF) {
                    datas[index++] = new Edata(vertexs[i], vertexs[j], weights[i][j]);
                }
            }
        }
        return datas;
    }

    /**
     * 將邊按權值從小到大排序
     *
     * @param edata
     */
    public void sort(Edata[] edata) {
        Arrays.sort(edata, Comparator.comparingInt(o -> o.weight));
    }

	//演算法主體
    public Edata[] kruskal(MGraph mGraph, Edata[] edatas) {
        // 存放結果,陣列最大容量為所有邊的容量
        Edata[] rets = new Edata[mGraph.edgeNum];
        int retsIndex = 0;

        /*
          按照演算法思路:
            記錄頂點在 **最小生成樹** 中的終點,頂點的終點是 **在最小生成樹中與它連通的最大頂點**。
            然後每次需要將一條邊新增到最小生存樹時,判斷該邊的兩個頂點的終點是否重合,重合的話則會構成迴路。
         */
        // 用於存所有的終點:該陣列中的內容隨著被選擇的邊增加,終點也會不斷的增加
        int[] ends = new int[mGraph.edgeNum];//解釋:陣列下標含義為起點索引,ends[i] 為起點i的終點索引
        // 對所有邊進行遍歷
        for (Edata edata : edatas) {
            // 獲取這條邊的兩個頂點下標:
            //  第一次:E,F  ->  4,5
            int p1 = getPosition(mGraph.datas, edata.start);
            int p2 = getPosition(mGraph.datas, edata.end);

            // 獲取對應頂點的 終點
            /*
              第 1 次:E,F  ->  4,5
                ends = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
                m: 獲取 4 的終點:ends[4] 為 0,說明此點 還沒有一個終點,那麼就返回它自己 4
                n: 獲取 5 的終點:同上
                m != n , 選擇這一條邊。那麼此時 E,F  ->  4,5 已有邊的終點就是 5
                ends = [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
                終點表中讀法:         ↑ index=4,value=5 那麼表示 4 這個頂點的終點為 5
              第 2 次:C,D  ->  2,3
                ends = [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
                 m: 獲取 2 的終點,ends[2] = 0,說明此點 還沒有一個終點,則返回它自己 2
                 n: 獲取 3 的終點
                 m != n , 選擇這一條邊。那麼此時 C,D  ->  2,3 已有邊的終點就是 3
                 ends = [0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
              第 3 次:D,E  ->  3,4
                ends = [0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
                 m: 獲取 3 的終點,ends[3] = 0,說明此點 還沒有一個終點,則返回它自己 3
                 n: 獲取 4 的終點,!! 前面第一次,已經將 4 的終點 5 放進來了
                    那麼將獲取到的終點為 5,getEnd() 還會嘗試去獲取 5 的終點,發現為 0,則 4 的終點是 5
                 m != n -> 3 != 5 , 選擇這一條邊。那麼此時 D,E  ->  3,4 已有邊的終點就是 5
                 ends = [0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]
             */
            int m = getEnd(ends, p1);
            int n = getEnd(ends, p2);
            //判斷終點是否重合
            if (m != n) {
                ends[m] = n;
                rets[retsIndex++] = edata;
            }
        }
        return rets;
    }

    /**
     * 獲取該頂點的:終點
     * 這個演算法值得好好想想,下面有解析
     * @param ends
     * @param vertexIndex
     * @return
     */
    private int getEnd(int[] ends, int vertexIndex) {
        int temp = vertexIndex;
        while (ends[temp] != 0) {
            temp = ends[temp];
        }
        return temp;
    }

    /**
     * 獲取此頂點的下標
     *
     * @param vertexs
     * @param vertex
     * @return
     */
    private int getPosition(char[] vertexs, char vertex) {
        for (int i = 0; i < vertexs.length; i++) {
            if (vertexs[i] == vertex) {
                return i;
            }
        }
        return 0;
    }

    /**
     * 描述一條邊
     */
    class Edata {
        // 一條邊的開始和結束,比如 A,B
        char start;
        char end;
        int weight; // 這條邊的權值

        public Edata(char start, char end, int weight) {
            this.start = start;
            this.end = end;
            this.weight = weight;
        }

        @Override
        public String toString() {
            return start + "," + end + " [" + weight + "]";
        }
    }

    @Test
    public void kruskalTest() {
        int[][] weights = new int[][]{
                //     A    B    C    D    E    F   G
                /*A*/ {0, 12, INF, INF, INF, 16, 14},
                /*B*/ {12, 0, 10, INF, INF, 7, INF},
                /*C*/ {INF, 10, 0, 3, 5, 6, INF},
                /*D*/ {INF, INF, 3, 0, 4, INF, INF},
                /*E*/ {INF, INF, 5, 4, 0, 2, 8},
                /*F*/ {16, 07, INF, INF, 2, 0, 9},
                /*G*/ {14, INF, INF, INF, 8, 9, INF}
        };
        MGraph mGraph = new MGraph(7, weights);
        mGraph.show();
        System.out.printf("共有 %d 條邊 \n", mGraph.edgeNum);
        System.out.println("邊陣列為:");
        Edata[] edatas = convertEdatas(mGraph);
        printEdatas(edatas);
        System.out.println("排序後的邊陣列為:");
        sort(edatas);
        printEdatas(edatas);

        Edata[] kruskal = kruskal(mGraph, edatas);
        System.out.println("克魯斯卡爾演算法計算結果的邊為:");
        printEdatas(kruskal);
        int total = Arrays.stream(kruskal).filter(item -> item != null).mapToInt(item -> item.weight).sum();
        System.out.println("總里程數為:" + total);
    }

    private void printEdatas(Edata[] edatas) {
        for (Edata edata : edatas) {
            if (edata == null) {
                continue;
            }
            System.out.println(edata);
        }
    }

測試輸出

       A       B       C       D       E       F       G       
A       0       12      100000  100000  100000  16      14      
B       12      0       10      100000  100000  7       100000  
C       100000  10      0       3       5       6       100000  
D       100000  100000  3       0       4       100000  100000  
E       100000  100000  5       4       0       2       8       
F       16      7       100000  100000  2       0       9       
G       14      100000  100000  100000  8       9       100000  
共有 12 條邊 
邊陣列為:
A,B [12]
A,F [16]
A,G [14]
B,C [10]
B,F [7]
C,D [3]
C,E [5]
C,F [6]
D,E [4]
E,F [2]
E,G [8]
F,G [9]
排序後的邊陣列為:
E,F [2]
C,D [3]
D,E [4]
C,E [5]
C,F [6]
B,F [7]
E,G [8]
F,G [9]
B,C [10]
A,B [12]
A,G [14]
A,F [16]
克魯斯卡爾演算法計算結果的邊為:
E,F [2]
C,D [3]
D,E [4]
B,F [7]
E,G [8]
A,B [12]
總里程數為:36

獲取一個點的終點解釋

    /**
     * 獲取該頂點的:終點
     *
     * @param ends
     * @param vertexIndex
     * @return
     */
    private int getEnd(int[] ends, int vertexIndex) {
        int temp = vertexIndex;
        while (ends[temp] != 0) {
            temp = ends[temp];
        }
        return temp;
    }
    
    ....
    int p1 = getPosition(mGraph.datas, edata.start);
    int p2 = getPosition(mGraph.datas, edata.end);
    int m = getEnd(ends, p1);
    int n = getEnd(ends, p2);
    if (m != n) {
      ends[m] = n;
      rets[retsIndex++] = edata;
    }
第 1 次:E,F  ->  4,5
  ends = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
  m: 獲取 4 的終點:ends[4] 為 0,說明此點 還沒有一個終點,那麼就返回它自己 4
  n: 獲取 5 的終點:同上
  m != n , 選擇這一條邊。那麼此時 E,F  ->  4,5 已有邊的終點就是 5
  ends = [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
  終點表中讀法:         ↑ index=4,value=5 那麼表示 4 這個頂點的終點為 5
第 2 次:C,D  ->  2,3
  ends = [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
  m: 獲取 2 的終點,ends[2] = 0,說明此點 還沒有一個終點,則返回它自己 2
  n: 同理,獲取 3 的終點
  m != n , 選擇這一條邊。那麼此時 C,D  ->  2,3 已有邊的終點就是 3
  ends = [0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
第 3 次:D,E  ->  3,4
  ends = [0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
  m: 獲取 3 的終點,ends[3] = 0,說明此點 還沒有一個終點,則返回它自己 3
  n: 獲取 4 的終點,!! 前面第一次,已經將 4 的終點 5 放進來了
     那麼將獲取到的終點為 5,getEnd() 還會嘗試去獲取 5 的終點,發現為 0,返回 5,則 4 的終點是 5
  m != n -> 3 != 5 , 選擇這一條邊。那麼此時 D,E  ->  3,4 已有邊的終點就是 5
  ends = [0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]       

從以上的步驟執行來看,這個終點的判定是這樣的:

  1. E,F -> 4,5 由於 終點列表中沒有,那麼第一條邊的 起點 E 的終點就是 F

    記為:

      ends = [0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0]
      終點表中讀法:         ↑ index=4,value=5 那麼表示 4 這個頂點的終點為 5
    

  2. C,D -> 2,3

    [0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
    

    選擇這一條邊後,終點列表中成了上面這樣,如何理解? 看上圖:這兩條邊新增之後,他們並不能連通,所以此時的 ends 列表裡面的含義就是這樣的:

    • 2:3 , C 的終點是 D,因為這條邊暫時沒有和其他邊連線
    • 4:5,E 的終點是 F,因為這條邊暫時沒有和其他邊連線
  3. D,E -> 3,4

    此時:可以看到,加入我們要選擇這條邊,那麼 DE 就會和 EF 相連,獲取邊的終點時

    [0, 0, 3, 0, 5, 0, 0, 0, 0, 0, 0, 0]
    
    • 先獲取 D 的終點:ends[3]=0,返回它自己;

    • 獲取 E 的終點時:edns[4]=5,你會發現,其實 E 它已經和 F 連通了

    • 那麼此時:往終點列表裡面存放的則是 D 的終點是 F,而不是 E (這裡可以看到獲取終點的演算法中的那個迴圈的妙用了)

        ends = [0, 0, 3, 5, 5, 0, 0, 0, 0, 0, 0, 0]  
      
  4. 最終選擇完後的終點列表中的資料為

    [6, 5, 3, 5, 5, 6, 0, 0, 0, 0, 0, 0]
     A  B  C  D  E  F  G
     0  1  2  3  4  5  6
    
    • A 的終點是 G
    • B 的終點是 F → G
    • C 的終點是 D → F → G
    • D 的終點是 F → G
    • E 的終點是 F → G
    • F 的終點是 G

    選擇看明白了嗎?它利用這一個陣列,對於每次新增的邊 和 已經存在的邊,計算出,新增加的邊的起始點,對應的終點是什麼。 當整個最小生成樹都完成的時候,他們最終所對應的終點都是一樣的。

相關文章