最小生成樹的本質是什么?Prim算法道破天機


本文始發於個人公眾號:TechFlow,原創不易,求個關注


今天是算法和數據結構專題20篇文章,我們繼續最小生成樹算法,來把它說完。

在上一篇文章當中,我們主要學習了最小生成樹的Kruskal算法。今天我們來學習一下Prim算法,來從另一個角度來理解一下這個問題。

從邊到點

我們簡單回顧一下Kruskal算法的原理,雖然上篇文章當中用了很多篇幅,但是原理非常簡單。本質上就是我們對圖中所有的邊按照長度進行排序,之后我們按照順序依次把它作為樹的骨干,加入到樹上來。

在此過程當中,我們為了避免導致產生環,而破壞樹結構,所以使用了並查集算法來作為維護。只會考慮那些不在一個連通塊中的邊,否則就會構成環路。

很多人在學習了這個算法之后,會將它理解成貪心問題,或者是並查集的一個使用場景。這么理解倒也沒錯,但是在這個問題當中,還有更好的解釋。

這個解釋就是邊集的擴張

整個Kruskal運行的過程是我們不斷選擇邊加入樹中的過程,對於n個點的圖來說,我們需要n-1條邊。如果我們專注於這個被選出來邊的集合,那么算法開始的時候,它是空集,運行結束之后,它含有n-1條邊,達到飽和。

當然你也可以換個角度來看,如果我們的關注點在點上,那么最小生成樹構建的過程也同樣可以看成是點集的拓張。只不過點和邊不同,邊可以選擇,但是點不可以,點只能通過選擇邊來覆蓋。比如我們看下下圖:

在圖中,我們左邊是一棵已經構成的樹,當我們連通AE之后,我們就用邊覆蓋了E點。點是依托於邊的,不通過邊,是無法覆蓋點的。

我們從空集開始,除了第一條邊可以覆蓋兩個點之外,之后的每一條邊都連通一個已經覆蓋的點和一個沒有覆蓋的點。那么,同樣也是通過n-1條邊可以覆蓋n個點。這個就是Prim算法的核心思路,也就是點集的拓張。

整體思路

我們明白了Prim的核心思想是點集的拓張之后就容易了,由於我們每次選擇的邊兩邊一定是一個已經覆蓋的點和沒有覆蓋的點。所以我們生成的樹是一條邊一條邊逐漸長大的,而不是像Kruskal那樣東拼西湊起來的。

我們來看個例子:

我們已經連通了ABCD四個點,其中CE的長度是7,DF的長度是9,EF的長度是5。雖然EF的長度小於CE,但是由於我們必須要連通一個已經覆蓋的點和沒有覆蓋的點,雖然EF的距離更小,我們也不能選擇。只能選擇CE,所以在整個算法運行的過程中間,這棵樹是逐漸變大的。如果是Kruskal,我們肯定會先連通EF,再連通CE,整個算法運行的過程當中,各個部分都是隔開的,最后的樹其實是逐漸“拼湊”出來的。

和Kruskal維護集合相比,我們維護點有沒有覆蓋過則要容易得多。因為樹已經選擇的邊是不會修改的,所以我們只需要用一個數組標記一下每個位置的點有沒有覆蓋即可。簡單的bool類型就可以實現,非常方便。

所以我們的問題只剩下了一個,如何保證我們生成出來的樹的路徑和最小呢?

關於這個問題的回答Prim和Kruskal一樣,就是貪心。我們每次選擇最小的邊進行拓展,Kruskal是對所有邊進行排序,然后依次判斷能否選擇。那么Prim算法怎么用貪心呢?

其實也很簡單,我們也很容易想明白。Prim算法對邊有限制,只能選擇已經覆蓋的點和沒有覆蓋的點之間的連邊。我們給這些邊起個名字,叫做可增廣邊。那么,顯然我們要做的就是在可增廣邊當中選擇一條最短的進行增廣。

問題就只剩下了一個,我們怎么選擇和維護這個最短的可增廣邊呢,難道每次拓充之后,都進行排序嗎?

顯然不是,因為每次都排序帶來的開銷太大了,我們可以用一個數據結構來維護這些邊,讓它們按照邊的長度進行排序。這個數據結構我們應該很熟悉了,就是我們已經遇見過好幾次的——優先隊列

我們排序的鍵也已經很明顯了,就是邊的長度,邊是否合法的判斷也很簡單,我們只要判斷一下是否存在沒有覆蓋的點即可。於是整個流程就串起來了,我們可以先來把流程理一下,寫出它的流程:

選擇一個點u,當做已經覆蓋
把u所有相連的邊加入隊列
循環
    循環 從隊列頭部彈出邊
        如果邊合法
            彈出
            跳出循環
    獲取邊的兩個端點
    將未覆蓋的端點所有邊加入隊列
直到所有點都已經覆蓋

最后,我們看下Python的實現,首先是優先隊列的部分,這個邏輯我們可以利用現成的heapq來實現。

import heapq
 class PriorityQueue:   def __init__(self):  self._queue = []  self._index = 0   def push(self, item, priority):  # 傳入兩個參數,一個是存放元素的數組,另一個是要存儲的元素,這里是一個元組。  # heap內部默認從小到大排  heapq.heappush(self._queue, (priority, self._index, item))  self._index += 1   def pop(self):  return heapq.heappop(self._queue)[-1]   def empty(self):  return len(self._queue) == 0 

然后是Prim算法的實現,這里為了存儲方便,我們使用了鄰接表來存儲邊的信息。鄰接表其實是一個鏈表的數組,數組里的每一個元素都是一個鏈表的頭結點。這個鏈表存儲的是某一個節點的所有邊信息。

比如鄰接表中下標1的鏈表存儲的就是與1這個節點相連的所有邊的信息。這個數據結構在我們存儲樹和圖的時候經常用到,不過也並不復雜,我們也不用真的實現一個鏈表,因為可以通過數組來模擬。

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__":  # 記錄點是否覆蓋  visited = [False for _ in range(11)]  visited[1] = True  # 鄰接表,可以理解成二維數組  adj_table = [[] for _ in range(11)]  # u和v表示兩個端點,w表示線段長度  # 我們把v和w放入下標u中  # 把u和w放入下標v中  for (u, v, w) in edges:  adj_table[u].append([v, w])  adj_table[v].append([u, w])   que = PriorityQueue()   # 我們選擇1作為起始點  # 將與1相鄰的所有邊加入隊列  for edge in adj_table[1]:  que.push(edge, edge[1])   ret = 0  # 一共有7個點,我們需要加入6條邊  for i in range(7):  # 如果隊列為空,說明無法構成樹  while not que.empty():  u, w = que.pop()  # 如果連通的端點已經被覆蓋了,則跳過  if visited[u]:  continue  # 標記成已覆蓋  visited[u] = True  ret += w  # 把與它相連的所有邊加入隊列  for edge in adj_table[u]:  que.push(edge, edge[1])  break   print(ret) 

結尾

到這里,關於Prim算法的介紹就結束了。其實本質上來說Prim和Kruskal是最小生成樹算法的一體兩面,兩者的本質都是一樣的,就是增廣。只不過不同的是,兩者一個是點的增廣一個是邊的增廣而已。但是由於點的增廣也依托於邊,所以Prim當中既用到點來判斷是否覆蓋,又用到邊的信息來增廣點。

如果單純從算法邏輯入手,沒有能夠理解它的本質,不僅很容易把這兩個算法搞混淆,也容易在寫代碼的時候搞暈,不知道到底要維護什么,要拓展什么。

增廣的思想在圖論相關的算法當中經常用到(比如網絡流),並不只是在最小生成樹當中出現,因此理解這一概念對於我們后續的學習非常重要。希望大家都能領會其中的精髓。

今天的文章就到這里,原創不易,掃碼關注我,獲取更多精彩文章。


免責聲明!

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



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