本文始發於我的公衆號:TechFlow,原創不易,求個關注web
今天是算法和數據結構專題的第19篇文章,咱們一塊兒來看看最小生成樹。算法
咱們先不講算法的原理,也不講一些七七八八的概念,由於對於初學者來講,看到這些術語和概念每每會很頭疼。頭疼也是正常的,由於無故忽然出現這麼多信息,都不知道它們是怎麼來的,也不知道這些信息有什麼用,天然就會以爲頭疼。這也是不少人學習算法熱情很高,可是最後又被勸退的緣由。緩存
咱們先不講什麼叫生成樹,怎麼生成樹,有向圖、無向圖這些,先簡單點,從最基本的內容開始,完整地將這個算法梳理一遍。數據結構
首先,咱們先來看看最簡單的數據結構——樹。編輯器
樹是一個很抽象的數據結構,由於它在天然界當中能找到對應的物體。咱們在初學的時候,每每都會根據天然界中真實的樹來理解這個概念。因此在咱們的認知當中,每每樹是長這樣的:學習
上面這張圖就是天然界中樹的抽象,咱們很容易理解。可是通常狀況下,咱們看到的樹結構每每不是這樣的,而是倒過來的。也就是樹根在上,樹葉在下。這樣設計的緣由很簡單,沒什麼特別的道理,只是由於咱們在遍歷樹的時候,每每從樹根開始,從樹根往葉子節點出發。因此咱們倒過來很容易理解一些,咱們把上面的樹倒過來就成了這樣:優化
上面的兩種畫法固然都是正確的,但既然樹能夠正着放,也能夠倒過來放,咱們天然也能夠將它伸展開來放。好比下面這張圖,其實也是一棵樹,只是咱們把它畫得不同而已。spa
咱們能夠想象一下,假若有一隻無形的大手抓住了樹根將它「拎起來」,那麼它天然而然就變成了上面的樣子。設計
而後你會發現,若是真的有這樣大手,它無論拎起哪一個節點,都會獲得一棵樹。也就是說,若是樹根的位置對咱們再也不重要的話,樹其實就等價於上面這樣的圖。code
那麼這樣的圖到底是什麼圖呢?它有什麼性質呢?全部的圖都能當作是樹嗎?
顯然這三種狀況都不是樹,第一種是由於圖中的邊有方向了。有了方向以後,圖中連通的狀況就被破壞了。在咱們認知當中樹應該是全連通的,就好像天然界中的一隻螞蟻,能夠走到樹上任何位置。不能全連通,天然就不是樹。狀況2也不對,由於有了環,樹是不該該有環的。天然界中的樹是沒有環的,不存在某根樹枝本身繞一圈,一樣,咱們邏輯中的樹也是沒有環的,不然咱們遞歸訪問永遠也找不到終點。第三種狀況也同樣,有些點孤立在外,不能連通,天然也不是樹。
那咱們總結一下,就能夠回答這個問題。樹是什麼?樹就是能夠全連通(無向圖),而且沒有環路的圖。
從剛纔的分析當中,咱們獲得了一個很重要的結論,樹的本質就是圖,只不過是知足了一些特殊性質的圖。這也是爲何樹的不少算法都會被收納進圖論這個大概念當中。
從全連通和沒有環路這兩個性質出發,咱們又能夠獲得一個很重要的結論,對於一棵擁有n個節點的樹而言,它的邊數是固定的,必定是n-1條邊。若是超過n-1條邊,那麼當中必定存在環路,若是小於n-1條邊,那麼必定存在不連通的部分。但注意,它只是一個必要條件,不是一個充分條件。也就是說並非n個點n-1條邊就必定是樹,這很容易構造出反例。
這個結論雖然很簡單,可是頗有用處,它能夠解決一個由圖轉化成樹的問題。
也就是說當下咱們擁有一個複雜圖,咱們想要根據這個圖生成可以連通全部節點的樹,這個時候應該怎麼辦?若是咱們沒有上面的性質,會有一點無從下手的感受。但有了這個性質以後,就明確多了。咱們一共有兩種辦法,第一種辦法是刪減邊,既然是一個複雜圖,說明邊的數量必定超過n-1。那麼咱們能夠試着刪去一些邊,最後留下一棵樹。第二種作法與之相反,是增長邊。也就是說咱們一開始把全部的邊所有撤掉,而後一條一條地往當中添加n-1條邊,讓它變成一棵樹。
咱們試着想一下,會發現刪減邊的作法明顯弱於添加邊的方法。緣由很簡單,由於咱們每一次在刪除邊的時候都面臨是否會破壞樹上連通關係的拷問。好比下圖:
若是咱們一旦刪去了AB這條邊,那麼必定會破壞整個結構的連通性。咱們要判斷連通關係,最好的辦法就是咱們先刪除這條邊,而後試着從A點出發,看看可否到達B點。若是能夠,那麼則認爲這條邊能夠刪除。若是圖很大的話,每一次刪除都須要遍歷整張圖,這會帶來巨大的開銷。而且每一次刪除都會改變圖的結構,很難緩存這些結果。
所以,刪除邊的方式並非不可行,只是複雜度很是高,正所以,目前比較流行的兩種最小生成樹的算法都是利用的第二種,也就是添加邊的方式實現的。
到這裏,咱們就知道了,所謂的最小生成樹算法,就是從圖當中挑選出n-1條邊將它轉化成一棵樹的算法。
咱們先不考慮邊上帶權重的狀況,咱們假設全部邊都是等價的,先來看看生成問題怎麼解決,再來進行優化求最小。
若是採用添加邊的方法,面臨的問題和上面相似,當咱們選擇一條邊的時候,咱們如何判斷這條邊是有必要添加的呢?這個問題須要用到樹的另一個性質。
因爲沒有環路,樹上任意兩點之間的路徑,有且只有一條。由於若是存在兩點之間的路徑有兩條,那麼必然能夠找到一個環路。它的證實很簡單,可是咱們很難憑本身想到這個結論。有了這個結論,就能夠回答上面的那個問題,什麼樣的邊是有必要添加的?也就是兩個點之間不存在通路的時候。若是兩個點之間已經存在通路,那麼當前這條邊就不能添加了,不然必然會出現環。若是沒有通路,那麼能夠添加。
因此咱們要作的就是設計一個算法,能夠維護樹上點的連通性。
可是這又帶來了一個新的問題,在樹結構當中,連通性是能夠傳遞的。兩個點之間連了一條邊,並不只僅是這兩個點連通,而是全部與這兩個點之間連通的點都連通了。好比下圖:
這張圖當中A和B連了一條邊,這不只僅是A和B連通,而是左半邊的集合和右半邊集合的連通。因此,雖然A只是和B連通了,可是和C也連通了。AC這條邊也同樣不能被加入了。也就是說A和B連通,實際上是A所在的集合和B所在的集合合併的過程。看到集合的合併,有沒有一點熟悉的感受?對嘛,上一篇文章當中咱們講的並查集算法就是用來解決集合合併和查詢問題的。那麼,顯然能夠用並查集來維護圖中這些點集的連通性。
若是對並查集算法有些遺忘的話,能夠點擊下方的傳送門回顧一下:
利用並查集算法,問題就很簡單了。一開始全部點之間都不連通,那麼全部點單獨是一個集合。若是當前邊連通的兩個點所屬於同一個集合,那麼說明它們之間已經有通路了,這條邊不能被添加。不然的話,說明它們不連通,那麼將這條邊連上,而且合併這兩個集合。
因而,咱們就解決了生成樹這個問題。
接下來,咱們爲圖中的每條邊加上權重,但願最後獲得的樹的全部權重之和最小。
好比,咱們有下面這張圖,咱們但願生成的樹上全部邊的權重和最小。
觀察一下這張圖上的邊,長短不一。根據貪心算法,咱們顯然但願用盡可能短的邊來連通樹。因此Kruskal算法的原理很是簡單粗暴,就是對這些邊進行長短排序,依次從短到長遍歷這些邊,而後經過並查集來維護邊是否可以被添加,直到全部邊都遍歷結束。
能夠確定,這樣生成出來的樹必定是正確的,雖然咱們對邊進行了排序,可是每條邊依然都有可能會被用上,排序並不會影響算法的可行性。但問題是,這樣貪心出來的結果必定是最優的嗎?
這裏,咱們仍是使用以前講過的等價判斷方法。咱們假設存在兩條長度同樣的邊,那麼咱們的決策是否會影響最後的結果呢?
兩個徹底相等的邊一共只有可能出現三種狀況,爲了簡化圖示,咱們把一個集合當作是一個點。第一種狀況是這兩條邊連通四個不一樣的集合:
那麼顯然這兩條邊之間並不會引發衝突,因此咱們能夠都保留。因此這不會引發反例。
第二種狀況是這兩條邊連通三個不一樣的集合:
這種狀況和上面同樣,咱們能夠都要,並不會影響連通狀況。因此也不會引發反例。
最後一種是這兩條邊連通的是兩個集合,也就是下面這樣。
在這種狀況下,這兩條件之間互相沖突,咱們只能選擇其中的一條。可是顯然,不論咱們怎麼選都是同樣的。由於都是鏈接了這兩個連通塊,而後帶來的價值也是同樣的,並不會影響最終的結果。
當咱們把全部狀況列舉出來以後,咱們就能夠明確,在這個問題當中貪心法是可行的,並不會引發反例,因此咱們能夠放心大膽地用。
明白了算法原理以後,咱們來看看這個算法的實際問題。其實這個算法在現實當中的使用蠻多的,好比自來水公司要用水管連通全部的小區。而水管是有成本的,那麼顯然自來水公司但願水管的總長度儘可能短。好比山裏的村莊通電,要用盡可能少的電纜將全部村莊連通,這些相似的問題其實均可以抽象成最小生成樹來解決。固然現實中的問題可能沒有這麼簡單,除了考慮成本和連通以外,還須要考慮地形、人文、社會等其餘不少因素。
最後,咱們試着用代碼來實現一下這個算法。
class DisjointSet:
def __init__(self, element_num=None):
self._father = {}
self._rank = {}
# 初始化時每一個元素單獨成爲一個集合
if element_num is not None:
for i in range(element_num):
self.add(i)
def add(self, x):
# 添加新集合
# 若是已經存在則跳過
if x in self._father:
return
self._father[x] = x
self._rank[x] = 0
def _query(self, x):
# 若是father[x] == x,說明x是樹根
if self._father[x] == x:
return x
self._father[x] = self._query(self._father[x])
return self._father[x]
def merge(self, x, y):
if x not in self._father:
self.add(x)
if y not in self._father:
self.add(y)
# 查找到兩個元素的樹根
x = self._query(x)
y = self._query(y)
# 若是相等,說明屬於同一個集合
if x == y:
return
# 不然將樹深小的合併到樹根大的上
if self._rank[x] < self._rank[y]:
self._father[x] = y
else:
self._father[y] = x
# 若是樹深相等,合併以後樹深+1
if self._rank[x] == self._rank[y]:
self._rank[x] += 1
# 判斷是否屬於同一個集合
def same(self, x, y):
return self._query(x) == self._query(y)
# 構造數據
edges = [[1, 2, 7], [2, 3, 8], [2, 4, 9], [1, 4, 5], [3, 5, 5], [2, 5, 7], [4, 5, 15], [4, 6, 6], [5, 6, 8], [6, 7, 11], [5, 7, 9]]
if __name__ == "__main__":
disjoinset = DisjointSet(8)
# 根據邊長對邊集排序
edges = sorted(edges, key=lambda x: x[2])
res = 0
for u, v, w in edges:
if disjoinset.same(u ,v):
continue
disjoinset.merge(u, v)
res += w
print(res)
其實主要都是利用並查集,咱們額外寫的代碼就只有幾行而已,是否是很是簡單呢?
相信你們也都感受到了Kruskal算法的原理很是簡單,若是你是順着文章脈絡這樣讀下來,相信必定會有一種順水推舟,一切都天然而然的感受。也正是所以,它很是符合直覺,也很是容易理解,一旦記住了就不容易忘記,即便忘記了咱們也很容易本身推導出來。這並非笑話,有一次我在比賽的時候臨時遇到了,當時許久不寫Kruskal算法,一時想不起來。憑着僅有的一點印象,硬是在草稿紙上推導了一遍算法。
在下一篇文章當中咱們繼續研究最小生成樹問題,一塊兒來看另一個相似但不相同的算法——Prim。
今天的文章就到這裏,原創不易,須要你的一個關注,掃碼關注,獲取更多精彩文章。