1. 最短路徑問題
一個最直觀最常用的最短路徑問題就是用地圖軟件或者導航系統來獲取從一個地方到另一個地方的路徑。在一副加權有向圖中,從頂點s到頂點t的最短路徑是所有從s到t的路徑中的權重最小者。
我們的重點是單點最短路徑問題,也就是說給定任意一個頂點,找到其對其他所有頂點的最短路徑。
2. 加權有向圖的數據結構
加權有向邊的數據結構
//加權有向邊的數據類型 public class DirectedEdge { private final int v; //邊的起點 private final int w; //邊的終點 private final double weight; //邊的權重 public DirectedEdge(int v, int w, double weight) { this.v = v; this.w = w; this.weight = weight; } public double weight() { return weight; } public int from() { return v;} public int to() { return w;} public String toString() { return String.format("%d - %d %.2f", v, w, weight); } }
加權有向圖的數據類型
//加權有向圖的數據類型 public class EdgeWeightedDigraph { private final int V; //頂點數 private int E; //邊數 private Bag<DirectedEdge>[] adj; //鄰接表 public EdgeWeightedDigraph(int V) { this.V = V; this.E = 0; adj =(Bag<DirectedEdge>[]) new Bag[V]; for(int v= 0 ;v<V;v++) adj[v] = new Bag<DirectedEdge>(); } public void addEdge(DirectedEdge e) { int v = e.from(); adj[v].add(e); E++; } public Iterable<DirectedEdge> adj(int v) { return adj[v];} public int V() { return V;} public int E() { return E;} public Bag<DirectedEdge> edges() { Bag<DirectedEdge> d = new Bag<DirectedEdge>(); for(int v=0;v<V;v++) { for(DirectedEdge e: adj[v]) d.add(e); } return d; } }
最短路徑的數據結構
從一個頂點s到其他所有頂點的最短路徑的集合其實本質是一顆樹。因為如果最短路徑中存在環的話,那么從s到環中任意一個頂點的距離就不是唯一的,也就不存在最短的定義了。
因此我們本質是要得到一顆最短路徑樹。
1)最短路徑的邊。使用一個父鏈接數組edgeTo[]。edgeTo[v]的值為樹中連接v和它的父節點的邊(也是從s到v的最短路徑上的最后一條邊)
2)到達起點的距離。用一個distTo[]數組來存從距離。distTo[v]表示從s到v的最短路徑的長度。

上圖表示的是從0出發的最短路徑樹。
3. Dijkstra算法(非負權重)
Dijkstra算法本身和即時的prim算法非常接近。prim算法添加的離樹最近的邊,Dijkstra算法添加的是離起點最近的非樹頂點。
只要理解了prim的實現,Dijkstra的算法就非常好理解了。核心還是維護一個distTo[]數組,每訪問一個頂點就更新一下數組里的值,直到所有的頂點訪問后,數組里存的就是最短路徑。
需要注意的是Dijkstra只適用於所有權重為非負的情況。
public class DijkstraSP { private DirectedEdge[] edgeTo; private double[] distTo; private IndexMinPQ<Double> pq; public DijkstraSP(EdgeWeightedDigraph G, int s) { edgeTo = new DirectedEdge[G.V()]; distTo = new double[G.V()]; pq = new IndexMinPQ<>(G.V()); for(int v=0;v<G.V();v++) distTo[v] = Double.POSITIVE_INFINITY; distTo[s] = 0.0; pq.insert(s, 0.0); while(!pq.isEmpty()) relax(G, pq.delMin()); } public void relax(EdgeWeightedDigraph G, int v) { for(DirectedEdge e:G.adj(v)) { int w = e.to(); if(distTo[w] > distTo[v] + e.weight()) { distTo[w] = distTo[v] + e.weight(); edgeTo[w] = e; if(pq.contains(w)) pq.change(w, distTo[w]); else pq.insert(w, distTo[w]); } } } }
4. 理解負權重
負權重的出現其實不只體現在數學上,在實際應用中其實非常實用。以任務調度為例。

以上圖為例。邊的權重指的是任務需要的時間。例如任務0需要41個時間單位才能完成,任務0完成后才能開始任務1,任務1需要82個時間單位,然后才能開始任務2...
而6指向3的路徑權重為-20,這是指6號任務需要在3號任務開始后的20個時間單位內開始,或者說3號任務不能早於6號任務的20個時間單位。
5. 一般的無環加權有向圖的最短路徑算法(可以處理負權重)
無環加權有向圖的算法的核心在於依照有向圖的拓撲排序來松弛每條邊。而拓撲排序又保證了每條邊只會被放松一次,因此這是一種最優的最短路徑搜索方法。
public class AcyclicSP { private double[] distTo; private DirectedEdge[] edgeTo; public AcyclicSP(EdgeWeightedDigraph G, int s) { distTo = new double[G.V()]; edgeTo = new DirectedEdge[G.V()]; for(int v =0 ;v<G.V(); v++) distTo[v] = Double.POSITIVE_INFINITY; distTo[s] = 0.0; Topological top = new Topological(G); //暫未實現的類,只是為了調用來計算有向圖的拓撲排序 for(int v:top.order()) relax(G, v); } public void relax(EdgeWeightedDigraph G, int v) { for(DirectedEdge e: G.adj(v)) { int w = e.to(); if(distTo[w] > distTo[v] + e.weight()) { distTo[w] = distTo[v] + e.weight(); edgeTo[w] = e; } } } }
6. Bellman - Ford算法
Bellman - Ford算法的特點在於加入了一個用來保存即將被放松的頂點的隊列。然后按照隊列的順序放松頂點。
public class BellmanFordSP { private double[] distTo; //距離 private DirectedEdge[] edgeTo; //最短路徑的最后一條邊 private Queue<Integer> queue; //待松弛的頂點 private boolean[] onQ; //記錄待松弛點,和queue一起使用 public BellmanFordSP(EdgeWeightedDigraph G, int s) { distTo = new double[G.V()]; edgeTo = new DirectedEdge[G.V()]; onQ = new boolean[G.V()]; for(int v=0;v<G.V();v++) distTo[v] = Double.POSITIVE_INFINITY; distTo[s] = 0.0; queue.enqueue(s); onQ[s] = true; while(!queue.isEmpty()) { int v= queue.dequeue(); onQ[v] = false; relax(G, v); } } public void relax(EdgeWeightedDigraph G, int v) { for(DirectedEdge e:G.adj(v)) { int w = e.to(); if(distTo[w] > distTo[v] + e.weight()) { distTo[w] = distTo[v] + e.weight(); edgeTo[w] = e; if (!onQ[w]) { queue.enqueue(w); onQ[w] = true; } } } } }
7. 總結
以上所有求有向圖最短路徑的方法,本質其實都是按一定順序對所有的頂點做松弛。
Dijkstra算法用了一個優先隊列才存儲即將要被松弛的點。這樣保證每次都是選擇當前最短路徑的點來做松弛。但是這樣做的前提是所有邊的權重是正的,否則,選最短的做松弛后結果未必是最短的。
而一般的最短路徑方法則巧妙的選擇了用有向圖的拓撲排序的順序依次對頂點做松弛,這其實是最為簡單,速度最快的方法,而且可以處理負權重。
最后的Bellman - Ford算法用了一個隊列來保留上一輪被更新過的頂點。並且相比前兩種算法,其實每個頂點的距離可能都被更新過多次。因此最壞情況的效率會低一些,但是更具有通用性。
至此,整個圖結構的部分就全部結束了。其實無向圖也好,有向圖也好,最小生成樹,最短路徑也好,構建的方法都非常類似,只是具體細節的處理差異。這幾篇博客中的代碼並非原創,都是摘自《算法》一書中,但是確實是我自己一行一行敲出來的,這個過程也有助於我自己理解和記憶。寫到后面就發現,這些代碼其實都非常類似。雖然圖結構相比之前的一些算法理論上難一些,但代碼總體來講都非常簡練。與大家共勉!
參考資料: 《算法》第四版
