數據結構:最小生成樹


最小代價生成樹

目前為止的學習,我們能夠看到現實中的很多問題都和圖結構息息相關,因為現實中的關系往往不是一對一或一對多的關系,而是多對多的關系。例如有這樣一個場景,假設我要在 n 個城市之間架設通信聯絡網,首先我們先明確一個前提:任意兩個城市之間都需要架設通信網絡?答案是否定的,因為這么做勞民傷財的,其實兩個城市之間的互聯可以通過多條鏈路連通,並不需要一定是直達的。
根據這一點,我們就能夠明白,連通 n 個城市需要 n - 1 條鏈路。接下來考慮下一個問題,不同城市間可能存在着多條路徑,路徑有長短,也就是說假設的 n - 1 條鏈路會因為城市間距離的不同而產生成本上的差異。因此我們需要考慮如何選擇出這樣 n - 1 條路徑,使得線路假設的成本最小。

在一個連通圖中的所有生成樹中,存在各邊代價之和最小的生成樹,則稱該生成樹為該聯通網的最小代價生成樹。在上面的例子中可以用聯通網表示,在一個圖結構中的頂點可以用於表示 n 個城市,邊表示頂點之間的路徑,用權表示城市之間的距離,這個使我們判斷成本的重要參考。

MST

MST 性質是一些構造最小生成樹算法使用的原理,因此先學習 MST 性質是下文的預備知識。

性質

假設 N = (V,E) 是一個連通網,U 是頂點集 V 的一個非空子集。若 (u,v) 是一條具有最小生成樹權值(代價)的邊,其中 u ∈ U,v ∈ V - U,則必存在一棵包含邊 (u,v) 的最小生成樹。
需要注解的是,在生成樹構造時,圖結構中的 n 個頂點分屬於 2 個集合:

  1. 已落在生成樹上的頂點集:U
  2. 尚未落在生成樹上的頂點集:V - U

那么我們就需要在所連通的 U 中頂點和 V - U 中頂點的邊中選取權值最小的邊。

證明

反證法:假設網 N 的任何一棵最小生成樹都不包含 (u,v)。設 T 是連通網上的一棵最小生成樹,當將邊 (u,v) 加入到 T 中時,由生成樹的定義,T 中必存在一條包含 (u,v) 的回路。另一方面,由於 T 是生成樹,則在 T 上必存在另一條邊 (u',v'),其中 u' ∈ U,v'∈ Y - U,且 u 和 u' 之間、v 和 v' 之間均有路徑相通。刪去邊 (u',v'),便可消除上述回路,同時得到另一棵生成樹 T'。因為 (u,v) 的權值不高於 (u',v'),則 T 的權值亦不高於 T,T’ 是包含 (u',v') 的一棵最小生成樹。由此和假設矛盾。 ——《數據結構(C語言版)》

Prim 算法(加點法)

該算法時間復雜度為 O(n2),與圖結構的邊數無關,適合稠密圖。由於直接講算法難以理解,所以我先模擬一下算法。

算法模擬

所謂加點法就是每次操作的都是點啦。假設有如下圖結構,從頂點 0 出發構造最小生成樹。

首先頂點 0 選擇它能夠延伸出去的最短路徑,那就是 0->1,因此我們把頂點 1 加入到點集中。

接着頂點 0,1 選擇它能夠延伸出去的最短路徑,那就是 0->3,因此我們把頂點 3 加入到點集中。

接着頂點 0,1,3 選擇它能夠延伸出去的最短路徑,那就是 1->2,因此我們把頂點 2 加入到點集中。

接着頂點 0,1,3,2 選擇它能夠延伸出去的最短路徑,那就是 2->5,因此我們把頂點 5 加入到點集中。(由於我們要保證選取的邊不能構成環,因此不能選擇已經在點集中的 0->2 路徑。)

最后頂點 0,1,3,2,5 選擇它能夠延伸出去的最短路徑,那就是 5->4,因此我們把頂點 4 加入到點集中。到此所有的點都被添加到生成樹中了,構造完畢。

算法流程

假設 N = (V,E) 是連通網,TE 是 N 上最小生成樹中邊的結合。

  1. U = {u0}(u0 ∈ V),TE = {};
  2. 在所有 u ∈ U,v ∈ V - U 的邊 (u,v)∈ E 中找到一條權值最小的邊 (u0,v0) 並入集合 TE,同時 v0 並入 U;
  3. 重復第二步,直至 U = V 為止。
  • 每次選擇最小邊的時候,可能存在多條同樣權值的邊可選,不要猶豫任選其一就行。

算法實現

結構設計

當我使用鄰接矩陣來存儲時,由於需要描述 U 到 V-U 具有最小權值的邊,因此需要兩個數組來輔助:

  1. lowcost 數組:存儲最小邊上的權值;
  2. adjvex 數組:存貯最小邊在 U 中的頂點。

算法步驟

首先將初始頂點 u 加入 U 中,對其每一個頂點 vj,將 lowcost 數組和 adjvex 數組初始化為到 u 的邊信息。
接着循環 n - 1 次,每次循環進行如下操作:

  1. 從各個邊中選出最小的邊 lowcost[k] 選出;
  2. 將 k 頂點加入 U 中;
  3. 更新剩余每組最小邊的信息,對於 V-U 中的邊,若存在新的邊權值比現有邊更小,則更新 lowcost 數組為新的權值。

代碼實現

#define INFINITY 65535
void MiniSpanTree_Prim(MGraph G)
{
    int min;
    int i,j,k;
    int adjvex[MAXV];    //保存相關頂點下標
    int lowcost[MAXV];    //保存相關頂點間邊的權值
    
    lowcost[0] = 0;    //v0 加入生成樹
    adjvex[0] = 0;    //初始化第一個頂點下標為 0
    for(i = 1; i < G.n; i++)    //初始化,循環除下標 0 外的所有頂點
    {
        lowcost[i] = G.edges[0][i];    //將 v0 頂點存在邊的權值存入 lowcost
        adjvex[i] = 0;    //初始化都為 v0 的下標
    }
    for(i = 1; i < G.n; i++)
    {
        min = INFINITY;    //初始化最小權值為 ∞
        j = 1;
        k = 0;
        while(j < G.n)    //循環全部頂點
        {
            if(lowcost[j] != 0 && lowcost[j] < min)
            {                //若權值不為 0 且小於 min
                min = lowcost[j];    //令當前權值為最小值
                k = j;    //當前最小值的下標拷貝給 k
            }
            j++;
        }
        cout << adjvex[k] << k;    //輸出當前頂點邊中權值最小邊
        lowcost[k] = 0;    //將頂點權值設為 0 表示添加入生成樹
        for(j = 1; j < G.n; j++)
        {
            if(lowcost[j] != 0 && G.edges[k][j] < lowcost[j])
            {         //若下標為 k 的頂點各邊權值小於當前頂點未被加入生成樹權值
                lowcost[j] = G.edges[k][j];    //將最小權值存入 lowcost
                adjvex[j] = k;    //將下標為 k 的頂點存入 adjvex
            }
        }
    }
}

Kruskal 算法(加邊法)

假若以“堆”結構來存放邊進行堆排序,對於包含 e 條邊的網,上述算法排序的時間復雜度為 O(e㏒2e)。只要采取合適的數據結構,算法的時間復雜度為 O(e㏒2e)。與普里姆算法相比,該算法更適合於求稀疏圖的最小生成樹。由於直接講算法難以理解,所以我先模擬一下算法。

算法模擬

所謂加邊法就是每次操作的都是點啦,假設有如下圖結構。

在所有邊挑選出一條最短的邊為 (0,1),添加邊。

對於還未被添加的邊,挑選出一條最短的邊為 (0,3),添加邊。

對於還未被添加的邊,挑選出一條最短的邊為 (1,2),添加邊。

對於還未被添加的邊,挑選出一條最短的邊為 (4,5),添加邊。

對於還未被添加的邊,挑選出一條最短的邊為 (0,2),但是添加了這條邊之后會形成回路,因此不能添加。

繼續挑選出一條最短的邊為 (2,5),添加邊,到此所有的點都被被連通,構造完畢。

算法流程

假設 N = (V,E) 是連通網,將 N 中的邊按權值從小到大的順序排列。

  1. 初始狀態只有 n 個頂點而沒有邊的非連通圖 T = (V,{}),圖中每個頂點都自成一個連通分量;
  2. 在 E 中選擇權值最小的邊,若該邊依附的頂點落在 T 中不同的連通分量上(不形成回路),則將此邊加入到 T 中,否則不添加而選擇下一條權值最小的邊;
  3. 重復步驟 2,直至 T 中的所有頂點都在同一連通分量上為止。

算法實現

結構設計

由於我們的視角放在“邊”上,因此我們選擇邊集數組來存儲圖結構,為我們提取邊的信息提供方便。同時為了判斷是否形成回路,我們可能需要結合並查集來判斷。

算法步驟

首先將邊集數組中的元素按權值從小到大排序。
接着依次檢查邊集數組的所有邊,做如下操作:

  1. 從邊集數組中選擇一條邊 (U1,U2);
  2. 在頂點數組中分別查找 v1 和 v2 所在的連通分量 vs1 和 vs2,進行判斷。若 vs1 和 vs2 不相等,表明所選的兩個頂點分別屬於不同的連通分量,則合並連通分量 vs1 和 vs2。若相等,表明所選的兩個頂點屬於同一個連通分量屬於同一個連通分量,舍棄這個邊,選擇下一條權值最小的邊。

代碼實現

void MiniSpanTree_Kruskal(MGraph G)
{
    int i,n,m;
    Edge edges[MAXE];    //定義邊集數組
    int parent[MAXV];    //判斷回路的並查集

    for(i = 0; i < G.n; i++)
    {
        n = Find(parent, edges[i].begin);    //“查”操作
        m = Find(parent, edges[i].end);
        if(m != n)    //若 n 和 m 不相等,說明回路不存在
        {
            parent[n] = m;    //將此邊結尾頂點放入下標為起點的 parent 中
            cout << edges[i].begin <<  edges[i].end << edges[i].weight << endl;
        }
    }
}

int Find(int *parent, int f)    //“查”操作
{
    while(parent[f] > 0)
        f = parent[f];
    return f;
}

實例:公路村村通

情景需求

測試樣例

輸入樣例

6 15
1 2 5
1 3 3
1 4 7
1 5 4
1 6 2
2 3 4
2 4 6
2 5 2
2 6 6
3 4 6
3 5 1
3 6 1
4 5 10
4 6 8
5 6 3

輸出樣例

12

情景分析

這個情景思路很明確,就是讓我們找最小生成樹,然后求一下最小生成樹中權值的和。需要注意的是輸入數據不足以保證暢通,這句話的意思是說給出的所有頂點可能會因為缺邊導致無法連通,因此我們就需要對這種情況進行判斷。

代碼實現

Prim 算法

與上文的代碼幾乎無異,需要添加一個判斷圖是否連通的功能而已。判斷方法是看看每一輪循環 k 值有沒有被修改,如果沒有被修改就證明圖的連接被切斷,也就是不連通,直接 return 就行了。

int MiniSpanTree_Prim(MGraph G)
{
    int min;
    int i, j, k;
    int adjvex[MAXV];    //保存相關頂點下標
    int lowcost[MAXV];    //保存相關頂點間邊的權值
    int cost = 0;

    lowcost[1] = 0;    //v1 加入生成樹
    adjvex[1] = 0;    //初始化第一個頂點下標為 1
    for (i = 2; i <= G->n; i++)    //初始化,循環除下標 1 外的所有頂點
    {
        lowcost[i] = G->edges[1][i];    //將 v1 頂點存在邊的權值存入 lowcost
        adjvex[i] = 1;    //初始化都為 v1 的下標
    }
    for (i = 2; i <= G->n; i++)
    {
        min = INFINITY;    //初始化最小權值為 ∞
        j = 1;
        k = 0;
        while (j <= G->n)    //循環全部頂點
        {
            if (lowcost[j] != 0 && lowcost[j] < min)
            {                //若權值不為 0 且小於 min
                min = lowcost[j];    //令當前權值為最小值
                k = j;    //當前最小值的下標拷貝給 k
            }
            j++;
        }
        if (k != 0)    //若 k 值被修改,證明成功添加了點
        {
            cost += min;    //計算費用
            lowcost[k] = 0;    //將頂點權值設為 0 表示添加入生成樹
        }
        else    //若 k 值沒被修改,證明圖不連通,斷了聯系
        {
            return -1;
        }
        for (j = 2; j <= G->n; j++)
        {
            if (lowcost[j] != 0 && G->edges[k][j] < lowcost[j])
            {         //若下標為 k 的頂點各邊權值小於當前頂點未被加入生成樹權值
                lowcost[j] = G->edges[k][j];    //將最小權值存入 lowcost
                adjvex[j] = k;    //將下標為 k 的頂點存入 adjvex
            }
        }
    }
    return cost;
}

實例:通信網絡設計

情景需求

測試樣例

輸入樣例

5 4
1 2 3
1 3 11
2 3 8
4 5 9

輸出樣例

NO!
1 part:1 2 3
2 part:4 5

情景分析

這個情景思路也很明確,就是讓我們找最小生成樹,然后求一下最小生成樹中權值的和。不過,我們可能會遇到像樣例那樣缺乏數據支撐的情況,也就是圖被分割成了好幾部分,這個情景對找到圖被分割的各個部分提出了要求。
如何實現這個功能?我們聯想到並查集,這是因為並查集這個結構能夠描述一堆數據元素所在的集合關系,我們可以用並查集來確定一下圖結構中被分為了幾個部分。若所有點都屬於同一個集合中,說明圖是連通的,可以找最小生成樹,若並查集中存在超過 1 個集合,說明圖被分割成了好幾部分,那就要分別輸出各部分。
兩種算法都可以實現功能,我選擇 Prim 算法,不過我只有確定並查集中僅存在一個集合我才啟動算法。

建圖算法

我需要構造一個並查集來確定有多少個集合,因此我可以在建立鄰接矩陣的時候順手建好並查集。對於並查集不是很熟悉的朋友可以看我的另一篇博客並查集(Union Find)

偽代碼

代碼實現

void CreateMGraph(MGraph& g, int n, int e, int parent[])
{
	int i, j;
	int point1, point2;
        int money;

	g = new GraphNode;
	for (i = 1; i <= n; i++)
	{
                parent[i] = i;
		for (j = 1; j <= n; j++)
		{
                      if (i == j)
                            g->edges[i][j] = 0;
                      else
	                    g->edges[i][j] = INFINITY;
		}
	}
	g->n = n;
	g->e = e;

	for (i = 0; i < e; i++)
	{
		cin >> point1 >> point2 >> money;
		g->edges[point1][point2] = g->edges[point2][point1] = money;
                Union(parent, point1, point2);
	}
}

主函數

在主函數需要先判斷並查集中是否僅存在一個集合,若僅存在一個集合才啟動 Prim 算法。

偽代碼

代碼

int main()
{
    MGraph g;
    int n, e;
    int count = 0;
    int parent[MAXV];
    int i, j;
    int fre = 1;

    cin >> n >> e;
    CreateMGraph(g, n, e, parent);
    for (int i = 1; i <= n; i++)
    {
        if (parent[i] == i)
        {
            count++;
        }
    }
    if (count == 1)
    {
        cout << "YES!\n" << "Total cost:" << MiniSpanTree_Prim(g) << endl;
    }
    else
    {
        cout << "NO!" << endl;
        for ( i = 1; i <= n; i++)
        {
            if (parent[i] == i)
            {
                cout << fre << " part:" << i;
                for ( j = i + 1; j <= n; j++)
                {
                    if (parent[j] == i)
                    {
                        cout << " " << j;
                    }
                }
                fre++;
                cout << endl;
            }
        }
    }
    return 0;
}

破圈法

避圈法和破圈法

一個有 n 個結點的連通圖的生成樹是原圖的極小連通子圖,且包含原圖中的所有 n 個結點,並且有保持圖連通的最少的邊,在一個連通圖中的所有生成樹中,存在如此各邊代價之和最小的生成樹,則稱該生成樹為該聯通網的最小代價生成樹
我們熟知的 Prim 算法和 Kruskal 算法可以用來找到最小生成樹,不過這兩種算法的思想其實是一致的:一開始我只有有限的信息,無論是所有頂點做加邊法,還是所有的邊做加點法,一開始的信息都是不完整的。其實無論是加點還是加邊,本質上是從最小的權的邊開始,添加的是在未選中的邊中的最小權值邊,原理都是使用了 MST 性質去做的。在做 Prim 算法和 Kruskal 算法時我們比較關心每添加了一次邊就檢查看看有沒有出線回路,因為生成樹是不能有回路。那也就是說我要避免回路的出現,在重復添加最小權值邊時就能保證將最小生成樹添加出來,這種手法就被稱為避圈法
現在我們來考慮另一種思路,避圈法是把最小生成樹建出來,現在我有全套的信息,我要怎么把最小生成樹找出來?由於最小生成樹是 n 個頂點 n - 1 條邊,因此我需要將邊的數量減少到 n - 1 條,一旦我滿足了這點就可以認為是找到了最小生成樹。所以現在的問題是怎么找需要刪除的邊?回顧一下什么叫生成樹,生成樹是沒有回路的,那也就是說我只需要把邊給刪除掉,使得該結構不存在回路即可。當然你可能要問了直接隨意刪除不就好了嗎?這樣剩余 n - 1 條邊絕對是不存在回路的。但是這么做可能會導致本來我的生成樹是可以保證連通的,但是就被拆成了多個部分,這個不是我們願意發生的事情。所以刪除邊的時候都需要驗證這條邊的有或無是否導致回路的存在。
還有一個問題是,如果我漫無目的地刪除邊,那么這樣得到的生成樹未必不是最小生成樹。這個問題解決還是比較容易的,我直接去找現有的權值最大邊即可,然后判斷回路存在后直接把最長邊。這樣每次操作的都是最長邊,剩余的邊就會是短邊,對應的生成樹就是最小生成樹。

模擬生成

我們還是拿這個圖結構來看,首先對於破圈法來說,我擁有全套信息。

接下來我去找權值最大的邊,應該是 3-5,判斷一下有這條邊將存在回路,刪除。

繼續尋找權值最大的邊,應該是 1-4,判斷一下有這條邊將存在回路,刪除。

繼續尋找權值最大的邊,應該是 2-5,判斷一下有這條邊對生成樹存在回路是沒有影響的。因為現在回路存在於 0,1,2 這 3 個點(用橙色標出) 2-5 的存在與否並不影響,因此保留。

繼續尋找權值最大的邊,應該是 0-2,判斷一下有這條邊將存在回路,刪除。

此時邊的數量已經縮小至 n - 1,說明構造完畢。

參考資料

《大話數據結構》—— 程傑 著,清華大學出版社
《數據結構(C語言版|第二版)》—— 嚴蔚敏 李冬梅 吳偉民 編著,人民郵電出版社
並查集(Union Find)


免責聲明!

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



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