演算法專題 | 10行程式碼實現的最短路演算法——Bellman-ford與SPFA

TechFlow2019發表於2020-09-04

今天是演算法資料結構專題的第33篇文章,我們一起來聊聊最短路問題。

最短路問題也屬於圖論演算法之一,解決的是在一張有向圖當中點與點之間的最短距離問題。最短路演算法有很多,比較常用的有bellman-ford、dijkstra、floyd、spfa等等。這些演算法當中主要可以分成兩個分支,其中一個是bellman-ford及其衍生出來的spfa,另外一個分支是dijkstra以及其優化版本。floyd複雜度比較高,一般不太常用。

我們今天的文章介紹的是bellman-ford以及它的衍生版本spfa演算法。

圖的儲存

我們要對一張有向圖計算最短路,那麼我們首先要做的就是將一張圖儲存下來。關於圖的儲存的資料結構,常用的方法有很多種。最簡單的是鄰接矩陣,所謂的鄰接矩陣就是用一個二維矩陣儲存每兩個點之間的距離。如果兩個點之間沒有邊相連,那麼設為無窮大。

可以參考一下下圖。

這種方法的好處是非常直觀,實現也很簡單,但是缺點也很明顯。這個二維矩陣所消耗的空間複雜度是,這裡的V指的是頂點的數量。當頂點的數量稍稍大一些之後,帶來的開銷是非常龐大的。一般情況下我們的圖的邊的密集程度是不高的,也就是說大量點和點之間沒有邊相連,我們浪費了很多空間。

針對鄰接矩陣浪費空間的問題,後來又提出了一種新的資料結構就是鄰接表

所謂的鄰接表也就是說我們把頂點一字排開存入陣列當中,每個頂點對應一條連結串列。這條連結串列當中儲存了這個點可以到達的其他點的資訊。比如下圖當中V1點可以連通V2和V3,V1在陣列當中的下標為0,所以下標為0的元素是一個儲存了V2和V3下標的連結串列,也就是圖中的1和2。

鄰接表的好處是可以最大化利用空間,有多少條邊儲存多少資訊。但是也有缺點,除了實現稍稍複雜一點之外,另外一個明顯的缺點就是我們沒辦法直接判斷兩點之間是否有邊存在,必須要遍歷連結串列才可以。

除了鄰接矩陣和鄰接表之外,還有一些其他的資料結構可以完成圖的儲存。比如前向星、邊集陣列、鏈式前向星等等。這些資料結構並沒有比鄰接表有質的突破,對於非演算法競賽同學來說,能夠熟練用好鄰接表也就足夠了。

Bellman-Ford演算法

剛才上面描述當中提到的演算法除了floyd演算法是計算的所有點對之間的最短距離之外,其他演算法解決的都是單源點最短路的問題。所謂的單源點可以簡單理解成單個的出發點,也就是說我們求的是從圖上的一個點出發去往其他每個點的最短距離。既然如此,我們的出發點以及到達點都是確定的,不確定的只是它們之間的距離而已。

為什麼我們會將bellman-ford演算法和dijkstra演算法區分開呢?因為兩者的底層邏輯是不同的,bellman-ford演算法的底層邏輯是動態規劃, 而dijkstra演算法的底層邏輯是貪心。

bellman-ford演算法的得名也和人有關,我們之前在介紹KMP演算法的時候曾經說過。由於英文表意能力不強,所以很多演算法和公式都是以人名來取名。bellman-ford是Richard Bellman 和 Lester Ford 分別發表的,實際上還有一個叫Edward F. Moore也發表了這個演算法,所以有的地方會稱為是Bellman-Ford-Moore 演算法。

演算法的原理非常簡單,利用了動態規劃的思想來維護源點出發到各個點的最短距離

它的核心思維是鬆弛,所謂的鬆弛可以理解成找到了更短的路徑對原路徑進行更新。對於一個有V個節點的有向圖進行V-1輪鬆弛,從而找到源點到所有點的最短距離。

初始的時候我們會用一個陣列記錄源點到其他所有點的距離,對於與源點直接相連的點來說,這個距離就是它和源點的距離否則則是無窮大。對於第一輪鬆弛來說,我們尋找的是源點出發經過一個點到達其他點的最短距離。我們用s代表源點,我們尋找的就是s經過點a到達點b,使得距離小於s直接到b的距離。

第二輪鬆弛就是尋找的s經過兩個點到達第三個點的最短距離,同理,對於一個有V個點的圖來說,兩個點之間最多經過V-1個點,所以我們需要V-1輪鬆弛操作。

它的虛擬碼非常簡單,我們直接來看:

for (var i = 0; i < n - 1; i++) {
    for (var j = 0; j < m; j++) {//對m條邊進行迴圈
      var edge = edges[j];
      // 鬆弛操作
      if (distance[edge.to] > distance[edge.from] + edge.weight ){ 
        distance[edge.to] = distance[edge.from] + edge.weight;
      }
    }
}

Bellman-ford的演算法很好理解,實現也不難,但是它有一個缺點就是複雜度很高。我們前面說了一共需要V-1輪鬆弛,每一輪鬆弛我們都需要遍歷E條邊,所以整體的複雜度是,E指的是邊的數量。想想看,假設對於一個有1w個頂點,10w條邊的圖來說,這個演算法是顯然無法得出結果的。

所以為了提高演算法的可用性,我們必須對這個演算法進行優化。我們來分析一下複雜度巨大的原因,主要在兩個地方,一個地方是我們鬆弛了V-1次,另外一個地方是我們列舉了所有的邊。鬆弛V-1次是不可避免的,因為可能存在極端的情況需要V-1次鬆弛才可以達成。但我們每次都列舉了所有的邊感覺有點浪費,因為其中大部分的邊是不可能達成新的鬆弛的。那有沒有辦法我們篩選出來可能構成新的鬆弛的邊呢?

針對這個問題的思考和優化引出了新的演算法——spfa。

SPFA演算法

SPFA演算法的英文全稱是Shortest Path Faster Algorithm,從名字上我們就看得出來這個演算法的最大特點就是快。它比Bellman-ford要快上許多倍,它的複雜度是,這裡的k是一個小於等於2的常數

SPFA的核心原理和Bellman-ford演算法是一樣的,也是對點的鬆弛。只不過它優化了複雜度,優化的方法也很簡單,用一個佇列維護了可能存在新的鬆弛的點。這樣我們每次從這些點出發去尋找其他可以鬆弛的點加入佇列,這裡面的原理很簡單,只有被鬆弛過的點才有可能去鬆弛其他的點

SPFA的程式碼也很短,實現起來難度很低,單單從程式碼上來看和普通的寬搜區別並不大。

import sys
from queue import Queue
que = Queue()

# 鄰接表儲存邊
edges = [[]]
# 維護是否在佇列當中
visited = [False for _ in range(V)]
dis = [sys.maxsize for _ in range(V)]
dis[s] = 0

que.put(s)

while not que.emtpy():
 u = que.get()
    
    for v, l in edges[u]:
        if dis[u] + l < dis[v]:
            dis[v] = dis[u] + l
            if not visited[v]:
                que.add(v)
                
 dis[u] = False

到這裡,關於Bellman-ford和SPFA演算法的介紹就結束了,需要提醒一點,這兩個演算法都不能處理負環的情況。也就是權重之和是負數的環,這樣會無限鬆弛陷入死迴圈當中,可以在求最短路之前通過拓撲排序排查,也可以記錄每個點進入佇列的次數,通過設定閾值的方式進行排除。

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

原文連結,求個關注

- END -

相關文章