東哥帶你刷圖論第五期:Kruskal 最小生成樹演算法

labuladong發表於2021-11-23

讀完本文,你不僅學會了演算法套路,還可以順便去 LeetCode 上拿下如下題目:

  1. 以圖判樹(中等)
  2. 最低成本聯通所有城市(中等)
  3. 連線所有點的最小費用(中等)

-----------

圖論中知名度比較高的演算法應該就是 Dijkstra 最短路徑演算法環檢測和拓撲排序二分圖判定演算法 以及今天要講的最小生成樹(Minimum Spanning Tree)演算法了。

最小生成樹演算法主要有 Prim 演算法(普里姆演算法)和 Kruskal 演算法(克魯斯卡爾演算法)兩種,這兩種演算法雖然都運用了貪心思想,但從實現上來說差異還是蠻大的,本文先來講 Kruskal 演算法,Prim 演算法另起一篇文章寫。

Kruskal 演算法其實很容易理解和記憶,其關鍵是要熟悉並查集演算法,如果不熟悉,建議先看下前文 Union-Find 並查集演算法

接下來,我們從最小生成樹的定義說起。

什麼是最小生成樹

先說「樹」和「圖」的根本區別:樹不會包含環,圖可以包含環

如果一幅圖沒有環,完全可以拉伸成一棵樹的模樣。說的專業一點,樹就是「無環連通圖」。

那麼什麼是圖的「生成樹」呢,其實按字面意思也好理解,就是在圖中找一棵包含圖中的所有節點的樹。專業點說,生成樹是含有圖中所有頂點的「無環連通子圖」。

容易想到,一幅圖可以有很多不同的生成樹,比如下面這幅圖,紅色的邊就組成了兩棵不同的生成樹:

對於加權圖,每條邊都有權重,所以每棵生成樹都有一個權重和。比如上圖,右側生成樹的權重和顯然比左側生成樹的權重和要小。

那麼最小生成樹很好理解了,所有可能的生成樹中,權重和最小的那棵生成樹就叫「最小生成樹」

PS:一般來說,我們都是在無向加權圖中計算最小生成樹的,所以使用最小生成樹演算法的現實場景中,圖的邊權重一般代表成本、距離這樣的標量。

在講 Kruskal 演算法之前,需要回顧一下 Union-Find 並查集演算法。

Union-Find 並查集演算法

剛才說了,圖的生成樹是含有其所有頂點的「無環連通子圖」,最小生成樹是權重和最小的生成樹。

那麼說到連通性,相信老讀者應該可以想到 Union-Find 並查集演算法,用來高效處理圖中聯通分量的問題。

前文 Union-Find 並查集演算法詳解 詳細介紹了 Union-Find 演算法的實現原理,主要運用 size 陣列和路徑壓縮技巧提高連通分量的判斷效率。

如果不瞭解 Union-Find 演算法的讀者可以去看前文,為了節約篇幅,本文直接給出 Union-Find 演算法的實現:

class UF {
    // 連通分量個數
    private int count;
    // 儲存一棵樹
    private int[] parent;
    // 記錄樹的「重量」
    private int[] size;

    // n 為圖中節點的個數
    public UF(int n) {
        this.count = n;
        parent = new int[n];
        size = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            size[i] = 1;
        }
    }
    
    // 將節點 p 和節點 q 連通
    public void union(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        if (rootP == rootQ)
            return;
        
        // 小樹接到大樹下面,較平衡
        if (size[rootP] > size[rootQ]) {
            parent[rootQ] = rootP;
            size[rootP] += size[rootQ];
        } else {
            parent[rootP] = rootQ;
            size[rootQ] += size[rootP];
        }
        // 兩個連通分量合併成一個連通分量
        count--;
    }

    // 判斷節點 p 和節點 q 是否連通
    public boolean connected(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        return rootP == rootQ;
    }

    // 返回節點 x 的連通分量根節點
    private int find(int x) {
        while (parent[x] != x) {
            // 進行路徑壓縮
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }

    // 返回圖中的連通分量個數
    public int count() {
        return count;
    }
}

前文 Union-Find 並查集演算法運用 介紹過 Union-Find 演算法的一些演算法場景,而它在 Kruskal 演算法中的主要作用是保證最小生成樹的合法性。

因為在構造最小生成樹的過程中,你首先得保證生成的那玩意是棵樹(不包含環)對吧,那麼 Union-Find 演算法就是幫你幹這個事兒的。

怎麼做到的呢?先來看看力扣第 261 題「以圖判樹」,我描述下題目:

給你輸入編號從 0n - 1n 個結點,和一個無向邊列表 edges(每條邊用節點二元組表示),請你判斷輸入的這些邊組成的結構是否是一棵樹。

函式簽名如下:

boolean validTree(int n, int[][] edges);

比如輸入如下:

n = 5
edges = [[0,1], [0,2], [0,3], [1,4]]

這些邊構成的是一棵樹,演算法應該返回 true:

但如果輸入:

n = 5
edges = [[0,1],[1,2],[2,3],[1,3],[1,4]]

形成的就不是樹結構了,因為包含環:

對於這道題,我們可以思考一下,什麼情況下加入一條邊會使得樹變成圖(出現環)

顯然,像下面這樣新增邊會出現環:

而這樣新增邊則不會出現環:

總結一下規律就是:

對於新增的這條邊,如果該邊的兩個節點本來就在同一連通分量裡,那麼新增這條邊會產生環;反之,如果該邊的兩個節點不在同一連通分量裡,則新增這條邊不會產生環

而判斷兩個節點是否連通(是否在同一個連通分量中)就是 Union-Find 演算法的拿手絕活,所以這道題的解法程式碼如下:

// 判斷輸入的若干條邊是否能構造出一棵樹結構
boolean validTree(int n, int[][] edges) {
    // 初始化 0...n-1 共 n 個節點
    UF uf = new UF(n);
    // 遍歷所有邊,將組成邊的兩個節點進行連線
    for (int[] edge : edges) {
        int u = edge[0];
        int v = edge[1];
        // 若兩個節點已經在同一連通分量中,會產生環
        if (uf.connected(u, v)) {
            return false;
        }
        // 這條邊不會產生環,可以是樹的一部分
        uf.union(u, v);
    }
    // 要保證最後只形成了一棵樹,即只有一個連通分量
    return uf.count() == 1;
}

class UF {
    // 見上文程式碼實現
}

如果你能夠看懂這道題的解法思路,那麼掌握 Kruskal 演算法就很簡單了。

Kruskal 演算法

所謂最小生成樹,就是圖中若干邊的集合(我們後文稱這個集合為 mst,最小生成樹的英文縮寫),你要保證這些邊:

1、包含圖中的所有節點。

2、形成的結構是樹結構(即不存在環)。

3、權重和最小。

有之前題目的鋪墊,前兩條其實可以很容易地利用 Union-Find 演算法做到,關鍵在於第 3 點,如何保證得到的這棵生成樹是權重和最小的。

這裡就用到了貪心思路:

將所有邊按照權重從小到大排序,從權重最小的邊開始遍歷,如果這條邊和 mst 中的其它邊不會形成環,則這條邊是最小生成樹的一部分,將它加入 mst 集合;否則,這條邊不是最小生成樹的一部分,不要把它加入 mst 集合

這樣,最後 mst 集合中的邊就形成了最小生成樹,下面我們看兩道例題來運用一下 Kruskal 演算法。

第一題是力扣第 1135 題「最低成本聯通所有城市」,這是一道標準的最小生成樹問題:

每座城市相當於圖中的節點,連通城市的成本相當於邊的權重,連通所有城市的最小成本即是最小生成樹的權重之和。

int minimumCost(int n, int[][] connections) {
    // 城市編號為 1...n,所以初始化大小為 n + 1
    UF uf = new UF(n + 1);
    // 對所有邊按照權重從小到大排序
    Arrays.sort(connections, (a, b) -> (a[2] - b[2]));
    // 記錄最小生成樹的權重之和
    int mst = 0;
    for (int[] edge : connections) {
        int u = edge[0];
        int v = edge[1];
        int weight = edge[2];
        // 若這條邊會產生環,則不能加入 mst
        if (uf.connected(u, v)) {
            continue;
        }
        // 若這條邊不會產生環,則屬於最小生成樹
        mst += weight;
        uf.union(u, v);
    }
    // 保證所有節點都被連通
    // 按理說 uf.count() == 1 說明所有節點被連通
    // 但因為節點 0 沒有被使用,所以 0 會額外佔用一個連通分量
    return uf.count() == 2 ? mst : -1;
}

class UF {
    // 見上文程式碼實現
}

這道題就解決了,整體思路和上一道題非常類似,你可以認為樹的判定演算法加上按權重排序的邏輯就變成了 Kruskal 演算法。

再來看看力扣第 1584 題「連線所有點的最小費用」:

比如題目給的例子:

points = [[0,0],[2,2],[3,10],[5,2],[7,0]]

演算法應該返回 20,按如下方式連通各點:

很顯然這也是一個標準的最小生成樹問題:每個點就是無向加權圖中的節點,邊的權重就是曼哈頓距離,連線所有點的最小費用就是最小生成樹的權重和。

所以解法思路就是先生成所有的邊以及權重,然後對這些邊執行 Kruskal 演算法即可:

int minCostConnectPoints(int[][] points) {
    int n = points.length;
    // 生成所有邊及權重
    List<int[]> edges = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            int xi = points[i][0], yi = points[i][1];
            int xj = points[j][0], yj = points[j][1];
            // 用座標點在 points 中的索引表示座標點
            edges.add(new int[] {
                i, j, Math.abs(xi - xj) + Math.abs(yi - yj)
            });
        }
    }
    // 將邊按照權重從小到大排序
    Collections.sort(edges, (a, b) -> {
        return a[2] - b[2];
    });
    // 執行 Kruskal 演算法
    int mst = 0;
    UF uf = new UF(n);
    for (int[] edge : edges) {
        int u = edge[0];
        int v = edge[1];
        int weight = edge[2];
        // 若這條邊會產生環,則不能加入 mst
        if (uf.connected(u, v)) {
            continue;
        }
        // 若這條邊不會產生環,則屬於最小生成樹
        mst += weight;
        uf.union(u, v);
    }
    return mst;
}

這道題做了一個小的變通:每個座標點是一個二元組,那麼按理說應該用五元組表示一條帶權重的邊,但這樣的話不便執行 Union-Find 演算法;所以我們用 points 陣列中的索引代表每個座標點,這樣就可以直接複用之前的 Kruskal 演算法邏輯了。

通過以上三道演算法題,相信你已經掌握了 Kruskal 演算法,主要的難點是利用 Union-Find 並查集演算法向最小生成樹中新增邊,配合排序的貪心思路,從而得到一棵權重之和最小的生成樹。

最後說下 Kruskal 演算法的複雜度分析:

假設一幅圖的節點個數為 V,邊的條數為 E,首先需要 O(E) 的空間裝所有邊,而且 Union-Find 演算法也需要 O(V) 的空間,所以 Kruskal 演算法總的空間複雜度就是 O(V + E)

時間複雜度主要耗費在排序,需要 O(ElogE) 的時間,Union-Find 演算法所有操作的複雜度都是 O(1),套一個 for 迴圈也不過是 O(E),所以總的時間複雜度為 O(ElogE)

本文就到這裡,關於這種貪心思路的簡單證明以及 Prim 最小生成樹演算法,我們留到後續的文章再聊。

_____________

檢視更多優質演算法文章 點選我的頭像,手把手帶你刷力扣,致力於把演算法講清楚!我的 演算法教程 已經獲得 90k star,歡迎點贊!

相關文章