在學習了圖的基本結構和遍歷方式後,我們再繼續地深入學習一些圖的基本應用。在之前的數據結構中,我們並沒接觸太多的應用場景,但是圖的這兩類應用確是面試或考試中經常出現的問題,而且出現的頻率還非常高,不得不來好好說一說。
什麼是最小生成樹?
從前面的學習中,我們應該能夠發現,圖就是一種擴展的樹結構。對於樹來說,它只有一個上級結點,同級結點之間沒有關聯。而圖則打破了樹的這些規則。我們再反過來想想,能不能給定一個條件,那就是連接上所有的結點,但是每個結點之間只保留一條邊。這樣形成的一顆簡單的樹其實就是能夠串聯所有結點的一條路徑,而最小生成樹的概念,其實就是對於有權圖來說,權數最少的那條能夠串連起所有結點的邊的路徑,或者也可以說是最小連通樹、最小連通子圖、最小代價樹。
從上圖中就可以看出,對於一個有權圖來,可以有許多生成樹的方式,不過不同的路線方式的結果會不同,只有最後一個路徑形成的生成樹具有路徑最小的那顆樹,就是我們需要的最小生成樹。
爲什麼要強調是有權圖呢?因爲如果是無權圖,所有結點連接起來的方案其實就沒有什麼太大的意義了,因爲不管從哪個結點出發走哪條路徑可能權值都是一樣的。而帶權路徑則總會有一條最佳的路徑是可以將所有結點遍歷完成並且權數還是最小的。最典型的應用就是地圖上哪條線路成本最少呀,辦公樓佈線怎麼走線最經濟之類相關的題目,基本都會牽涉到最小生成樹的概念。
關於最小生成樹的最經典的算法,Prim 和 Kruskal 這兩個大神級別的算法是繞不過去的檻,下面我們就來粗淺地學習一下。
第一種算法 Prim
Prim 算法,中文名 普里姆 算法。起源就不多說了,反正是個人名,這篇文章和下篇文章中圖的應用的這些算法名稱都是人名相關的。他們發現並最初使用了這些算法,然後就將這些算法以他們的名字命名了。
Prim 算法的核心思想就是:從一個結點出發,查看這個結點的所有的邊中權值最小的那條邊,然後加上這條邊所連接的那個結點的所有邊,再一起看哪個邊的權值最小,然後一直重複這些步驟,反正就是所有結點到我們出發的這個結點中所有權值最小的邊都看一遍,並且讓它們能夠連接所有結點就完成了。
看圖是不是就清晰多了。我們一步一步地看。
- 首先我們從第 1 個結點出發,然後看第 1 個結點相關的邊哪個權值最小,很明顯,我們要選選擇 <1, 2> 這條邊,然後將結點 2 加入到選擇中
2)在結點 1 和結點 2 中選擇最權值最小的邊並連接到新的結點,在這裏我們選擇的是 <1, 3> 這條邊,於是結點 3 也加入到選擇中
4)在結點 1、2、3 的所有邊中,選擇權值最小的邊,可以看到 <2, 3> 這條邊的權值最小,但是 2 和 3 都已經連通了,所以選擇下一個最小的邊 <3, 4> ,結點 4 還沒有加入到已經連通的結點中,於是就走 <3, 4> 這條邊,結點 4 加入已連通結點中
5)同樣地,在結點 1、2、3、4 中選擇權值最小的邊,這時只有 <4, 6> 邊是最小的,並且結點 6 也沒有加入到已連通結點中,選擇這條路線,結點 6 加入連通結點中
6)最後,在結點 1、2、3、4、6 中查找權值最小的邊,得到 <6, 5> 這條邊,結點 5 也沒連通,於是選擇這條路徑,加入結點 5
7)所有結點都已經連通,權值累加結點爲 19 ,當前的這條路徑就是最小權值路徑,所形成的這一條路徑就是一顆最小生成樹了
從這個步驟和圖釋來說,大家可以自己嘗試寫寫這個 Prim 算法的代碼,其實並不複雜。我們需要一個集合來放置已經連通的結點信息,當查找路徑的時候找到的最小權值路徑連通的結點不在集合中,就加入到集合中。然後不斷累加所有的路徑權值,最後就得到了遍歷整張圖的最小生成樹路徑。
// 普里姆算法
function Prim($graphArr)
{
$n = count($graphArr);
// 記錄 1 號頂點到各個頂點的初始距離
$dis = [];
for ($i = 1; $i <= $n; $i++) {
$dis[$i] = $graphArr[1][$i];
}
// 將 1 號頂點加入生成樹
$book[1] = 1; // 標記一個頂點是否已經加入到生成樹
$count = 1; // 記錄生成樹中的頂點的個數
$sum = 0; // 存儲路徑之和
// 循環條件 生成樹中的頂點的個數 小於 總結點數
while ($count < $n) {
$min = INFINITY;
for ($i = 1; $i <= $n; $i++) {
// 如果當前頂點沒有加入到生成樹,並且記錄中的權重比當前權重小
if (!$book[$i] && $dis[$i] < $min) {
// 將 $min 定義爲當前權重的值
$min = $dis[$i];
$j = $i; // 用於準備將頂點加入到生成樹記錄中
}
}
$book[$j] = 1; // 確認將最小權重加入到生成樹記錄中
$count++; // 頂點個數增加
$sum += $dis[$j]; // 累加路徑和
// 調整當前頂點 $j 的所有邊,再以 $j 爲中間點,更新生成樹到每一個非樹頂點的距離
for ($k = 1; $k <= $n; $k++) {
// 如果當前頂點沒有加入到生成樹,並且記錄中的 $k 權重頂點大於 $j 頂點到 $k 頂點的權重
if (!$book[$k] && $dis[$k] > $graphArr[$j][$k]) {
// 將記錄中的 $k 頂點的權重值改爲 $j 頂點到 $k 頂點的值
$dis[$k] = $graphArr[$j][$k];
}
}
}
return $sum;
}
$graphArr = [];
BuildGraph($graphArr); // 之前文章中的生成鄰接矩陣的函數
echo Prim($graphArr); // 19
我們運行代碼並輸入測試數據。
php 5.4圖的應用:最小生成樹.php
請輸入結點數:6
請輸入邊數:9
請輸入邊,格式爲 出 入 權:2 4 11
請輸入邊,格式爲 出 入 權:3 5 13
請輸入邊,格式爲 出 入 權:4 6 3
請輸入邊,格式爲 出 入 權:5 6 4
請輸入邊,格式爲 出 入 權:2 3 6
請輸入邊,格式爲 出 入 權:4 5 7
請輸入邊,格式爲 出 入 權:1 2 1
請輸入邊,格式爲 出 入 權:3 4 9
請輸入邊,格式爲 出 入 權:1 3 2
19
可以看到輸出的結果和我們預期的一樣。代碼中已經有很詳細的註釋說明了,如果直接看代碼比較暈的話,大家可以拿調試工具進行斷點的單步調試來看一下具體的運行情況。在這裏我們先看一下那個 dis[] 中最後都保存了什麼東西。
Array
(
[1] => 9999999
[2] => 1
[3] => 2
[4] => 9
[5] => 4
[6] => 3
)
INFINITY 是我們定義的一個常量,在初始化 graphArr 這個鄰接矩陣時,將所有的邊都設置爲 INFINITY 了,主要就是方便我們後面進行最小值的比對。這個 INFINITY 我們設置的是 9999999 這樣一個非常大的數。dis[] 中其實包含的就是結點 1 所經過的每條邊所選擇的權值,把他們加起來就是我們的最終路徑長度。
第二種算法 Kruskal
Prim 算法好玩嗎?相信通過具體的算法你對最小生成樹的概念就更清晰了,不知道你會不會有個這樣的想法:直接遍歷所有的邊,給他們按權值排序,這樣我們再依次遍歷這個排序後的邊結構數組,然後將邊的結點加入到最終要生成的樹中,這樣不也能形成一個最小生成樹嘛!哇塞,你要是真的想到這個方案了那要給一個大大地讚了。這種方式就是我們最小生成樹的另一種明星算法:Kruskal 算法。它的中文名字可以叫做 克魯斯卡爾 算法。
看這個步驟是不是和 Prim 就完全不一樣了?不急,我們還是一步一步地來看。
1)在所有的邊中,選擇最小的那條邊,也就是 <1, 2> 這條邊,結點 1 和結點 2 連通
2)接着選擇第二小的邊,<1, 3> 邊符合條件,並且結點 3 沒有連通,加入結點 3
3)繼續選擇最小的邊,此時最小的邊是 <4, 6> ,這兩個結點都沒有連通,直接加入
5)接下來是 <6, 5> 這條邊最小,繼續連通並將結點 5 加入
6)好了,左右兩邊成型了,現在最小的邊是 <2, 3> 邊,不過結點 2 和結點 3 已經連通了,放棄!選擇 <4, 5> 邊,同樣,結點4 和結點 5 也已經連通了,放棄!選擇 <3, 4> 邊,OK,這兩條邊還沒有連通,直接連通,所有結點連通完畢,最小生成樹完成!
不錯吧,又學會一個新的套路,大家也可以試試按照上面的步驟和圖釋來自己先寫寫代碼。需要注意的我們要先給所有的邊排序,才能進行這個算法的操作。另外,每次判斷結點連通也是一件費事的工作,使用深度優先或者廣度優先遍歷是沒問題的,但效率太低,讓我們看看大神(算法書中)們是怎麼做的。
// 克魯斯卡爾算法
function Kruskal($graphArr)
{
global $map, $f;
$hasMap = [];
$i = 1;
// 轉換爲序列形式方便排序
// O(mn)或O(n^2),可以直接建圖的時候使用單向圖進行建立就不需要這一步了
foreach ($graphArr as $x => $v) {
foreach ($v as $y => $vv) {
if ($vv == INFINITY) {
continue;
}
if (!isset($hasMap[$x][$y]) && !isset($hasMap[$y][$x])) {
$map[$i] = [
'x' => $x,
'y' => $y,
'w' => $vv,
];
$hasMap[$x][$y] = 1;
$hasMap[$y][$x] = 1;
$i++;
}
}
}
// 使用快排按照權重排序
quicksort(1, count($map));
// 初始化並查集
for ($i = 1; $i <= count($graphArr); $i++) {
$f[$i] = $i;
}
$count = 0; // 已記錄結點數量
$sum = 0; // 存儲路徑之和
for ($i = 1; $i <= count($map); $i++) {
// 判斷一條邊的兩個頂點是否已經連通,即判斷是否已在同一個集合中
if (merge($map[$i]['x'], $map[$i]['y'])) { // 如果目前已連通,則選用這條邊
$count++;
$sum += $map[$i]['w'];
}
if ($count == count($map) - 1) { // 直到選了n-1條邊後退出
break;
}
}
return $sum;
}
Oh my God!代碼多了好多,還有好多莫名其妙的東西出現了。在上文中說過,我們要使用 Kruskal 算法就得先給邊排序。所以我們先將鄰接矩陣轉換成 map[x,y,w] 的形式,x 和 y 依然是代碼兩個結點,而 w 代表權重。這樣的一個可以看成是邊對象的數組就比較方便我們進行排序了。
接着我們使用快速排序按照權值進行排序,具體的快排算法我們在後面學習排序的時候再詳細說明,大家可以直接在文章底部複製測試代碼鏈接查看完整的代碼。
接下來就是使用並查集進行 Kruskal 算法的操作了。並查集就是代替深度和廣度優先遍歷來快速確定結點連通情況的一套算法。
$f = [];
// 並查集尋找祖先的函數
function getf($v)
{
global $f;
if ($f[$v] == $v) {
return $v;
} else {
// 路徑壓縮
$f[$v] = getf($f[$v]);
return $f[$v];
}
}
// 並查集合並兩子集合的函數
function merge($v, $u)
{
global $f;
$t1 = getf($v);
$t2 = getf($u);
// 判斷兩個點是否在同一個集合中
if ($t1 != $t2) {
$f[$t2] = $t1;
return true;
}
return false;
}
它本身還是通過遞歸的方式來將結點保存在一個數組中,通過判斷兩個點是否在同一個集合中,即兩個結點是否有共同的祖先來確定結點是否已經加入並且連通。
關於並查集的知識本人掌握的也並不是很深入,所以這裏就不班門弄斧了,大家可以自己查閱相關的資料或者深入研究各類算法書籍中的解釋。
最後運行代碼輸出的結果和 Prim 算法的結果是一致的,都是 19 。
總結
怎麼樣?最小生成樹是不是很好玩的東西,圖的結構其實是很複雜的,不過越是複雜的東西能夠玩出的花活也越多。但是反過來說,很多公司的面試過程中關於圖的算法能考到這裏的也都是大廠了,一般的小公司其實能簡單地說一說深度和廣度就已經不錯了。我們的學習還要繼續,下一篇我們將學習的是另一個圖的廣泛應用:最短距離。
今天的測試代碼均根據 《啊哈!算法》 改寫爲 PHP 形式,參考資料依然是其它各類教材。
測試代碼:
參考文檔:
《數據結構》第二版,嚴蔚敏
《數據結構》第二版,陳越
《數據結構高分筆記》2020版,天勤考研
《啊哈!算法》