10行實現最短路演算法——Dijkstra

TechFlow2019發表於2020-09-10

今天是演算法資料結構專題的第34篇文章,我們來繼續聊聊最短路演算法。

在上一篇文章當中我們講解了bellman-ford演算法和spfa演算法,其中spfa演算法是我個人比較常用的演算法,比賽當中幾乎沒有用過其他的最短路演算法。但是spfa也是有缺點的,我們之前說過它的複雜度是,這裡的E是邊的數量。但有的時候邊的數量很多,E最多能夠達到,這會導致超時,所以我們會更換其他的演算法。這裡說的其他的演算法就是Dijkstra。

演算法思想

在上一篇文章當中我們曾經說過Bellman-ford演算法本質上其實是動態規劃演算法,我們的狀態就是每個點的最短距離,策略就是可行的邊,由於一共最多要鬆弛V-1次,所以整體的演算法複雜度很高。當我們用佇列維護可以鬆弛的點之後,就將複雜度降到了,也就是spfa演算法。

Dijkstra演算法和Bellman-ford演算法雖然都是最短路演算法,但是核心的邏輯並不相同。Dijkstra演算法的底層邏輯是貪心,也可以理解成貪心演算法在圖論當中的使用。

其實Dijstra演算法和Bellman-ford演算法類似,也是一個鬆弛的過程。即一開始的時候除了源點s之外,其他的點的距離都設定成無窮大,我們需要遍歷這張圖對這些距離進行鬆弛。所謂的鬆弛也就是要將這些距離變小。假設我們已經求到了兩個點u和v的距離,我們用dis[u]表示u到s的距離,dis[v]表示v的距離。

假設我們有dis[u] < dis[v],也就是說u離s更近,那麼我們接下來要用一個新的點去搜尋鬆弛的可能,u和v哪一個更有可能獲得更好的結果呢?當然是u,所以我們選擇u去進行新的鬆弛,這也就是貪心演算法的體現。如果這一層理解了,演算法的整個原理也就差不多了。

我們來整理一下思路來看下完整的演算法流程:

  1. 我們用一個陣列dis記錄源點s到其他點的最短距離,起始時dis[s] = 0,其他值設為無窮大
  2. 我們從未訪問過的點當中選擇距離最小的點u,將它標記為已訪問
  3. 遍歷u所有可以連通的點v,如果dis[v] < dis[u] + l[u] [v],那麼更新dis[v]
  4. 重複上述2,3兩個步驟,直到所有可以訪問的點都已經訪問過

怎麼樣,其實核心步驟只有兩步,應該很好理解吧?我找到了一張不錯的動圖,大家可以根據上面的流程對照一下動圖加深一下理解。

我們根據原理不難寫出程式碼:

INF = sys.maxsize
edges = [[]] # 鄰接表儲存邊
dis = [] # 記錄s到其他點的距離
visited = {} # 記錄訪問過的點

while True:
    mini = INF
    u = 0
    flag = False
    # 遍歷所有未訪問過點當中距離最小的
    for i in range(V):
        if i not in visited and dis[i] < mini:
            mini, u = dis[i], i
            flag = True
            
    # 如果沒有未訪問的點,則退出
    if not flag:
        break
        
 visited[u] = True
    
    for v, l in edges[u]:
        dis[v] = min(dis[v], dis[u] + l)

雖然我們已經知道演算法沒有反例了,但是還是可以思考一下。主要的點在於我們每次都選擇未訪問的點進行鬆弛,有沒有可能我們鬆弛了一個已經訪問的點,由於它已經被鬆弛過了,導致後面沒法拿來鬆弛其他的點呢?

其實是不可能的,因為我們每次選擇的都是距離最小的未訪問過的點。假設當前的點是u,我們找到了一個已經訪問過的點v,是不可能存在dis[u] + l < dis[v]的,因為dis[v]必然要小於dis[u],v才有可能先於u訪問。但是這有一個前提,就是每條邊的長度不能是負數。

演算法優化

和Bellman-ford演算法一樣,Dijkstra演算法最大的問題同樣是複雜度。我們每次選擇一個點進行鬆弛,選擇的時候需要遍歷一遍所有的點,顯然這是非常耗時的。複雜度應該是,這裡的E是邊的數量,Dijkstra中每個點只會鬆弛一次,也就意味著每條邊最多遍歷一次。

我們觀察一下會發現,外面這層迴圈也就算了,裡面這層迴圈很沒有必要,我們只是找了一個最值而已。完全可以使用資料結構來代替迴圈查詢,維護最值的場景我們也已經非常熟悉了,當然是使用優先佇列。

使用優先佇列之後這段程式碼會變得非常簡單,同樣也不超過十行,為了方便同學們除錯,我把連帶優先佇列實現的程式碼一起貼上來。

import heapq
import sys

# 優先佇列
class PriorityQueue:
  
    def __init__(self):
        self._queue = []
        self._index = 0

    def push(self, item, priority):
        # 傳入兩個引數,一個是存放元素的陣列,另一個是要儲存的元素,這裡是一個元組。
        # 由於heap內部預設由小到大排,所以對priority取負數
        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



que = PriorityQueue()

INF = sys.maxsize
edges = [[], [[27], [39], [614]], [[17], [310], [415]], [[19], [210], [62], [411]], [[311], [56]], [[46], [69]], [[32], [59]]] # 鄰接表儲存邊
dis = [sys.maxsize for _ in range(8)] # 記錄s到其他點的距離
s = 1
que.push(s, 0)
dis[s] = 0
visited = {}

while not que.empty():
    u = que.pop()
    if u in visited:
        continue
    visited[u] = True
    for v, l in edges[u]:
        if v not in visited and dis[u] + l < dis[v]:
            dis[v] = dis[u] + l
            que.push(v, dis[v])

print(dis)

這裡用visited來判斷是否之前訪問過的主要目的是為了防止負環的產生,這樣程式會陷入死迴圈,如果確定程式不存在負邊的話,其實可以沒必要判斷。因為先出佇列的一定更優,不會存在之後還被更新的情況。如果想不明白這點加上判斷也沒有關係。

我們最後分析一下複雜度,每個點最多進入佇列一次,加上優先佇列的調整耗時,整體的複雜度是,比之前的複雜度要提速了很多,非常適合邊很多,點相對較少的圖。有時候spfa卡時間了,我們會選擇Dijkstra。

今天的文章到這裡就結束了,如果喜歡本文的話,請來一波素質三連,給我一點支援吧(關注、轉發、點贊)。

原文連結,求個關注

- END -

相關文章