最小生成樹,Prim和Kruskal的原理與實現


文章首先於微信公眾號:小K算法,關注第一時間獲取更新信息

1 新農村建設

大清都亡了,我們村還沒有通網。為了響應國家的新農村建設的號召,村里也開始了網絡工程的建設。
窮鄉僻壤,人煙稀少,如何布局網線,成了當下村委會首個急需攻克的難題。
如下圖,農戶之間的距離隨機,建設網線的成本與距離成正比,怎樣才能用最少的成本將整個村的農戶網絡連通呢?

2 思考

如果農戶A到農戶B,農戶B到農戶C的網線已經建好了,那農戶A和農戶C也間接的連通了,不用再建設。

每一根線都可以連通2個農戶,所以有N個農戶,就只需要N-1條網線就可以了。

3 問題建模

將上述問題轉化為無向圖來表示。

用鄰接矩陣存儲農戶之間的距離。

這樣問題就轉化成:找N-1條邊將上述圖組成一個連通圖,要求N-1條邊的權值和最小。

這就是經典的最小生成樹問題。有兩種算法專門解決這類問題,PrimKruskal

4 Prim

4.1 反向思考

對於一個N個點,N-1條邊的連通圖:
如果剪掉1條線,整個圖會變成2個連通子圖;如果剪掉2條線,就會變成3個連通子圖。

如果剪掉了B到D之間的網線,這時變成2個連通子圖。

  • 連通子圖1:A,B
  • 連通子圖2:C,D,E,F

為了將整個圖連通,就需要找出兩個子圖之間的最小距離邊,連通這條邊就行了。
其實就是找出子圖1中的所有點到子圖2中的所有點的最小邊。
這里有3條邊,A-C,B-D,B-E,其中A-C距離最小,連通這條邊就是最好的方案。
推論:

  • 最優的方案是確定的,即最小權值和唯一
  • 在最優方案中,剪掉任意一條邊所分隔出的兩個連通子圖,之間的最小距離都應該是剪掉的這條,沒有比這條邊更小的,否則可以換掉這條邊構成新的最優方案

如上圖就不是最優方案,因為兩個子圖之間還有更小的邊

4.2 Prim算法框架

對於加權連通圖G=(V,E),V為頂點集,E為邊集。

  • 以V中任意一點x為起點,將x加入一個新的頂點集S={x},初始新的邊集T={}為空
  • 重復如下步驟直到S=V:
    1)選擇E中最小的邊<u,v>,其中u屬於S,而v不屬於S但屬於V
    2)將v加入S,將邊<u,v>加入T
  • 最終S,T即為所求最小生成樹

算法解釋:把S和非S想象成兩個子圖,每一步其實就是在找出這兩個子圖之間的最小邊。

過程模擬如下圖:

  • 以A為起點,將A加入S={A};
  • 第一條最小邊為A-B,將B加入S={A,B}
  • 第二條最小邊為B-D,將D加入S={A,B,D}
  • 第三條最小邊為D-C,將C加入S={A,B,D,C}

繼續重復以上過程直到S=V,T即為所求邊集。

4.3 Prim代碼實現

變量定義

const int MAXN = 100;
int n, m, temp, ans = 0, map[MAXN][MAXN], length[MAXN];
char s, t;
bool flag[MAXN];

初始化

void init() {
    cin >> n >> m;
    memset(map, -1, MAXN * MAXN);
    for (int i = 0; i < n; ++i) {
        map[i][i] = 0;
        flag[i] = false;
        length[i] = 0x7fffffff;
    }
    for (int i = 0; i < m; ++i) {
        cin >> s >> t >> temp;
        map[s - 'A'][t - 'A'] = temp;
        map[t - 'A'][s - 'A'] = temp;
    }
}

核心算法

int main() {
    init();
    // 將0作為起點加入集合S
    flag[0] = true;
    for (int i = 0; i < n; ++i) {
        if (map[0][i] >= 0) {
            length[i] = map[0][i];
        }
    }
    // 選擇N-1條邊
    for (int i = 0; i < n - 1; ++i) {
        int min = 0x7fffffff;
        int k = 0;
        // 枚舉非S所有點,選擇最小的邊
        for (int j = 1; j < n; ++j) {
            if (!flag[j] && length[j] < min) {
                min = length[j];
                k = j;
            }
        }
        ans += min;
        cout << "k=" << k << " ,min=" << min << endl;
        // 將新的點k加入集合S,並通過k更新非S中點的距離
        flag[k] = true;
        for (int j = 1; j < n; ++j) {
            if (!flag[j] && map[k][j] >= 0 && map[k][j] < length[j]) {
                length[j] = map[k][j];
            }
        }
    }
    cout << "ans=" << ans;
    return 0;
}

5 Kruskal

5.1 思考

最優解是要選取N-1條邊,邊的數量是固定的,但邊的權值不一樣,所以可以讓這N-1條邊盡可能的小。那就可以用貪心的思想,從最小的邊開始選擇。

如上圖,從最小的邊開始選擇,第1條是A-B,第2條是B-D,第3條是A-D。
但這里就出現了沖突,因為A與D已經連通,再多一條邊會形成環,沒有意義。
所以再多加一個判斷,如果一條邊所關聯的兩個點已經連通就不能選擇,否則可以選擇。

當選擇第4條邊D-E時,判斷D和E沒有連通,將這兩個子圖連通。把兩個子圖看成不同的集合,這一步就是合並成同一個集合。

如果初始每個點都屬於一個獨立的集合,每選擇一條邊,就將所在的集合合並成同一個,在下一次選擇邊的時候,就只需判斷關聯的兩個點是否為同一集合。這就可以用並查集快速處理。
詳細可查看並查集專題

5.2 Kruskal算法框架

對於加權連通圖G=(V,E),V為頂點集,E為邊集。

  • 初始一個非連通圖T=(V,{}),即含所有點,邊集為空
  • 重復以下步驟,直到成功選擇N-1條邊
    1)在E中取出最小邊<u,v>,如果u,v沒有連通,就將該邊加入T,同時將u,v連通;否則舍棄判斷下一條最小邊。
  • 最終T即為所求最小生成樹

過程模擬如下圖:

  • 判斷第1條邊B-D,將B,D合並為一個集合;判斷第2條邊A-B,將A,B,D合並為一個集合
  • 判斷第3條邊A-D,A,D已經屬於同一個集合,放棄選擇
  • 判斷第4條邊E-F,將E,F合並為一個集合

繼續重復以上過程直到選出N-1條邊。

5.3 Kruskal代碼實現

變量定義

struct Edge {
    int start;
    int end;
    int value;
};
const int MAXN = 100, MAXM = 100;

int n, m, answer = 0, edgeNum = 0, father[MAXN];
Edge edge[MAXM];

初始化

void init() {
    char s, e;
    int temp;
    // 並查集根結點,初始為-1,合並之后為-num,num表示集合中的個數
    memset(father, -1, MAXN);
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        cin >> s >> e >> temp;
        edge[i].start = s - 'A';
        edge[i].end = e - 'A';
        edge[i].value = temp;
    }
}
bool compare(const Edge &a, const Edge &b) {
    return a.value < b.value;
}

查找根

int findFather(int s) {
    int root = s, temp;
    // 查找s的最頂層根
    while (father[root] >= 0) {
        root = father[root];
    }
    // 路徑壓縮,提高后續查找效率
    while (s != root) {
        temp = father[s];
        father[s] = root;
        s = temp;
    }
    return root;
}

合並集合

void unionSet(int s, int e) {

    int rootS = findFather(s);
    int rootE = findFather(e);

    int weight = father[rootS] + father[rootE];
    // 將結點數少的集合作為結點數多的集合的兒子節點
    if (father[rootS] > father[rootE]) {
        father[rootS] = rootE;
        father[rootE] = weight;
    } else {
        father[rootE] = rootS;
        father[rootS] = weight;
    }
}

核心算法

int main() {
    init();
    sort(edge, edge + m, compare);
    for (int i = 0; i < m; i++) {
        if (findFather(edge[i].start) != findFather(edge[i].end)) {
            unionSet(edge[i].start, edge[i].end);
            answer += edge[i].value;
            edgeNum++;
            if (edgeNum == n - 1) {
                break;
            }
        }
    }
    cout << answer << endl;
    return 0;
}

6 總結

prim基於頂點操作,適用於點少邊多的場景,多用鄰接矩陣存儲。
kruskal基於邊操作,適用於邊少點多的場景,多用鄰接表存儲。


掃描下方二維碼關注公眾號,第一時間獲取更新信息!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM