定義
最短路問題的定義為:設 \(G=(V,E)\) 為連通圖,圖中各邊 \((v_i,v_j)\) 有權 \(l_{ij}\) (\(l_{ij}=\infty\) 表示 \(v_i,v_j\) 間沒有邊) ,\(v_s,v_t\) 為圖中任意兩點,求一條道路 \(\mu\),使得它是從 \(v_s\) 到 \(v_t\) 的所有路中總權最小的路,即:\(L(\mu)=\sum_{(v_i,v_j)\in \mu}l_{ij}\) 最小。
下圖左側是一幅帶權有向圖,以頂點 0 為起點到各個頂點的最短路徑形成的最短路徑樹如下圖右側所示:
帶權有向圖的實現
在實現最短路演算法之前需要先實現帶權有向圖。在上一篇部落格 《如何在 Java 中實現最小生成樹演算法》 中我們實現了帶權無向圖,只需一點修改就能實現帶權有向圖。
帶權有向邊
首先應該實現帶權有向圖中的邊 DirectedEdge
,這個類有三個成員變數:指出邊的頂點 v
、邊指向的頂點 w
和邊的權重 weight
。程式碼如下所示:
package com.zhiyiyo.graph;
/**
* 帶權有向邊
*/
public class DirectedEdge {
int v, w;
double weight;
public DirectedEdge(int v, int w, double weight) {
this.v = v;
this.w = w;
this.weight = weight;
}
public int from() {
return v;
}
public int to() {
return w;
}
public double getWeight() {
return weight;
}
@Override
public String toString() {
return String.format("%d->%d(%.2f)", v, w, weight);
}
}
帶權有向圖
帶權有向圖的實現非常簡單,只需將帶權無向圖使用的 Edge
類換成 DirectedEdge
類,並作出少許調整即可:
package com.zhiyiyo.graph;
import com.zhiyiyo.collection.stack.LinkStack;
import com.zhiyiyo.collection.stack.Stack;
public class WeightedDigraph {
private final int V;
protected int E;
protected LinkStack<DirectedEdge>[] adj;
public WeightedDigraph(int V) {
this.V = V;
adj = (LinkStack<DirectedEdge>[]) new LinkStack[V];
for (int i = 0; i < V; i++) {
adj[i] = new LinkStack<>();
}
}
public int V() {
return V;
}
public int E() {
return E;
}
public void addEdge(DirectedEdge edge) {
adj[edge.from()].push(edge);
E++;
}
public Iterable<DirectedEdge> adj(int v) {
return adj[v];
}
public Iterable<DirectedEdge> edges() {
Stack<DirectedEdge> edges = new LinkStack<>();
for (int v = 0; v < V; ++v) {
for (DirectedEdge edge : adj(v)) {
edges.push(edge);
}
}
return edges;
}
}
最短路演算法
API
最短路演算法應該支援起始點 \(v_s\) 到任意頂點 \(v_t\) 的最短距離和最短路徑的查詢:
package com.zhiyiyo.graph;
/**
* 最短路徑
*/
public interface ShortestPath {
/**
* 從起點到頂點 v 的最短距離,如果頂點 v 不可達則為無窮大
* @param v 頂點 v
* @return 最短路徑
*/
double distTo(int v);
/**
* 是否存在從起點到頂點 v 的路徑
* @param v 頂點 v
* @return 是否存在
*/
boolean hasPathTo(int v);
/**
* 從起點到頂點 v 的最短路徑,若不存在則返回 null
* @param v 頂點 v
* @return 最短路徑
*/
Iterable<DirectedEdge> pathTo(int v);
}
Dijkstra 演算法
我們可以使用一個距離陣列 distTo[]
來儲存起始點 \(v_s\) 到其餘頂點 \(v_t\) 的最短路徑,且 distTo[]
陣列滿足以下條件:
可以使用 Double.POSITIVE_INFINITY
來表示無窮大,有了這個陣列之後我們可以實現 ShortestPath
前兩個方法:
package com.zhiyiyo.graph;
public class DijkstraSP implements ShortestPath {
private double[] distTo;
@Override
public double distTo(int v) {
return distTo[v];
}
@Override
public boolean hasPathTo(int v) {
return distTo[v] < Double.POSITIVE_INFINITY;
}
}
為了實現儲存 \(v_s\) 到 \(v_t\) 的最短路徑,可以使用一個邊陣列 edgeTo[]
,其中 edgeTo[v] = e_wv
表示要想到達 \(v_t\),需要先經過頂點 \(v_w\),接著從 edgeTo[w]
獲取到達 \(v_w\) 之前需要到達的上一個節點,重複上述步驟直到發現 edgeTo[i] = null
,這時候就說明我們回到了 \(v_s\)。 獲取最短路徑的程式碼如下所示:
@Override
public Iterable<DirectedEdge> pathTo(int v) {
if (!hasPathTo(v)) return null;
Stack<DirectedEdge> path = new LinkStack<>();
for (DirectedEdge e = edgeTo[v]; e != null; e = edgeTo[e.from()]) {
path.push(e);
}
return path;
}
演算法流程
雖然我們已經實現了上述介面,但是如何得到 distTo[]
和 edgeTo[]
還是個問題,這就需要用到 Dijkstra 演算法了。演算法的思想是這樣的:
-
初始化
distTo[]
使得除了distTo[s] = 0
外,其餘的元素都為Double.POSITIVE_INFINITY
。同時初始化edgeTo[]
的每個元素都是null
; -
將頂點 s 的所有相鄰頂點 \(v_j\) 加入集合 \(V'\) 中,設定
distTo[j] = l_sj
即初始化最短距離為鄰邊的權重; -
從 \(V'\) 中取出距離最短即
distTo[m]
最小的頂點 \(v_m\),遍歷 \(v_m\) 的所有鄰邊 \((v_m, v_w)\),如果有 \(l_{mw}+l_{sw}<l_{sw}\),就說明從 \(v_s\) 走到 \(v_m\) 再一步走到 \(v_w\) 距離最短,我們就去更新distTo[m]
,同時將 \(v_w\) 新增到 \(V'\) 中(如果 \(v_w\) 不在的話); -
重複上述過程直到 \(V'\) 變為空,我們就已經找到了所有 \(v_s\) 可達的頂點的最短路徑。
上述過程中有個地方會影響演算法的效能,就是如何從 \(V'\) 中取出最小距離對應的頂點 \(v_m\)。如果直接遍歷 \(V'\) 最壞情況下時間複雜度為 \(O(|V|)\),如果換成最小索引優先佇列則可以將時間複雜度降至 \(O(\log|V|)\)。
最小索引優先佇列
上一篇部落格 《如何在 Java 中實現最小生成樹演算法》 中介紹了最小堆的使用,最小堆可以在對數時間內取出資料集合中的最小值,對應到最短路演算法中就是最短路徑。但是有一個問題,就是我們想要的是最短路徑對應的那個頂點 \(v_m\),只使用最小堆是做不到這一點的。如何能將最小堆中的距離值和頂點進行繫結呢?這就要用到索引優先佇列。
索引優先佇列的 API 如下所示,可以看到每個元素 item
都和一個索引 k
進行繫結,我們可以通過索引 k
讀寫優先佇列中的元素。想象一下堆中的所有元素放在一個陣列 pq
中,索引優先佇列可以做到在對數時間內取出 pq
的最小值。
package com.zhiyiyo.collection.queue;
/**
* 索引優先佇列
*/
public interface IndexPriorQueue<K extends Comparable<K>> {
/**
* 向堆中插入一個元素
*
* @param k 元素的索引
* @param item 插入的元素
*/
void insert(int k, K item);
/**
* 修改堆中指定索引的元素值
* @param k 元素的索引
* @param item 新的元素值
*/
void change(int k, K item);
/**
* 向堆中插入或修改元素
* @param k 元素的索引
* @param item 新的元素值
*/
void set(int k, K item);
/**
* 堆是否包含索引為 k 的元素
* @param k 索引
* @return 是否包含
*/
boolean contains(int k);
/**
* 彈出堆頂的元素並返回其索引
* @return 堆頂元素的索引
*/
int pop();
/**
* 彈出堆中索引為 k 為元素
* @param k 索引
* @return 索引對應的元素
*/
K delete(int k);
/**
* 獲取堆中索引為 k 的元素,如果 k 不存在則返回 null
* @param k 索引
* @return 索引為 k 的元素
*/
K get(int k);
/**
* 獲取堆中的元素個數
*/
int size();
/**
* 堆是否為空
*/
boolean isEmpty();
}
實現索引優先佇列比優先佇列麻煩一點,因為需要維護每個元素的索引。之前我們是將元素按照完全二叉樹的存放順序進行儲存,現在可以換成索引,而元素只需根據索引值 k
放在陣列 keys[k]
處即可。只有索引陣列 indexes[]
和元素陣列 keys[]
還不夠,如果我們想實現 contains(int k)
方法,目前只能遍歷一下 indexes[]
,看看 k
在不在裡面,時間複雜度是 \(O(|V|)\)。何不多維護一個陣列 nodeIndexes[]
,使得它滿足下述關係:
如果能在 nodeIndexes[k]
不是 -1,就說明索引 \(k\) 對應的元素存在與堆中,且索引 k 在 indexes[]
中的位置為 \(d\),即有下述等式成立:
有了這三個陣列之後我們就可以實現最小索引優先佇列了:
package com.zhiyiyo.collection.queue;
import java.util.Arrays;
import java.util.NoSuchElementException;
/**
* 最小索引優先佇列
*/
public class IndexMinPriorQueue<K extends Comparable<K>> implements IndexPriorQueue<K> {
private K[] keys; // 元素
private int[] indexes; // 元素的索引,按照最小堆的順序擺放
private int[] nodeIndexes; // 元素的索引在完全二叉樹中的編號
private int N;
public IndexMinPriorQueue(int maxSize) {
keys = (K[]) new Comparable[maxSize + 1];
indexes = new int[maxSize + 1];
nodeIndexes = new int[maxSize + 1];
Arrays.fill(nodeIndexes, -1);
}
@Override
public void insert(int k, K item) {
keys[k] = item;
indexes[++N] = k;
nodeIndexes[k] = N;
swim(N);
}
@Override
public void change(int k, K item) {
validateIndex(k);
keys[k] = item;
swim(nodeIndexes[k]);
sink(nodeIndexes[k]);
}
@Override
public void set(int k, K item) {
if (!contains(k)) {
insert(k, item);
} else {
change(k, item);
}
}
@Override
public boolean contains(int k) {
return nodeIndexes[k] != -1;
}
@Override
public int pop() {
int k = indexes[1];
delete(k);
return k;
}
@Override
public K delete(int k) {
validateIndex(k);
K item = keys[k];
// 交換之後 nodeIndexes[k] 發生變化,必須先儲存為區域性變數
int nodeIndex = nodeIndexes[k];
swap(nodeIndex, N--);
// 必須有上浮的操作,交換後的元素可能比上面的元素更小
swim(nodeIndex);
sink(nodeIndex);
keys[k] = null;
nodeIndexes[k] = -1;
return item;
}
@Override
public K get(int k) {
return contains(k) ? keys[k] : null;
}
public K min() {
return keys[indexes[1]];
}
/**
* 獲取最小的元素對應的索引
*/
public int minIndex() {
return indexes[1];
}
@Override
public int size() {
return N;
}
@Override
public boolean isEmpty() {
return N == 0;
}
/**
* 元素上浮
*
* @param k 元素的索引
*/
private void swim(int k) {
while (k > 1 && less(k, k / 2)) {
swap(k, k / 2);
k /= 2;
}
}
/**
* 元素下沉
*
* @param k 元素的索引
*/
private void sink(int k) {
while (2 * k <= N) {
int j = 2 * k;
// 檢查是否有兩個子節點
if (j < N && less(j + 1, j)) j++;
if (less(k, j)) break;
swap(k, j);
k = j;
}
}
/**
* 交換完全二叉樹中編號為 a 和 b 的節點
*
* @param a 索引 a
* @param b 索引 b
*/
private void swap(int a, int b) {
int k1 = indexes[a], k2 = indexes[b];
nodeIndexes[k2] = a;
nodeIndexes[k1] = b;
indexes[a] = k2;
indexes[b] = k1;
}
private boolean less(int a, int b) {
return keys[indexes[a]].compareTo(keys[indexes[b]]) < 0;
}
private void validateIndex(int k) {
if (!contains(k)) {
throw new NoSuchElementException("索引" + k + "不在優先佇列中");
}
}
}
注意對比最小堆和最小索引堆的 swap(int a, int b)
方法以及 less(int a, int b)
方法,在交換堆中的元素時使用的依據是元素的大小,交換之後無需調整 keys[]
,而是交換 nodeIndexes[]
和 indexes[]
中的元素。
實現演算法
通過上述的分析,實現 Dijkstra 演算法就很簡單了,時間複雜度為 \(O(|E|\log |V|)\):
package com.zhiyiyo.graph;
import com.zhiyiyo.collection.queue.IndexMinPriorQueue;
import com.zhiyiyo.collection.stack.LinkStack;
import com.zhiyiyo.collection.stack.Stack;
import java.util.Arrays;
public class DijkstraSP implements ShortestPath {
private double[] distTo;
private DirectedEdge[] edgeTo;
private IndexMinPriorQueue<Double> pq;
private int s;
public DijkstraSP(WeightedDigraph graph, int s) {
pq = new IndexMinPriorQueue<>(graph.V());
edgeTo = new DirectedEdge[graph.V()];
// 初始化距離
distTo = new double[graph.V()];
Arrays.fill(distTo, Double.POSITIVE_INFINITY);
distTo[s] = 0;
visit(graph, s);
while (!pq.isEmpty()) {
visit(graph, pq.pop());
}
}
private void visit(WeightedDigraph graph, int v) {
for (DirectedEdge edge : graph.adj(v)) {
int w = edge.to();
if (distTo[w] > distTo[v] + edge.getWeight()) {
distTo[w] = distTo[v] + edge.getWeight();
edgeTo[w] = edge;
pq.set(w, distTo[w]);
}
}
}
// 省略已實現的方法 ...
}
後記
Dijkstra 演算法還能繼續優化,將最小索引堆換成斐波那契堆之後時間複雜度為 \(O(|E|+|V|\log |V|)\),這裡就不寫了(因為還沒學到斐波那契堆),以上~~