如何在 Java 中實現 Dijkstra 最短路演算法

之一Yo發表於2022-04-07

定義

最短路問題的定義為:設 \(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[] 陣列滿足以下條件:

\[\begin{equation} distTo(t) = \left\{ \begin{aligned} 0 \quad & t=s \\ l_{st} \quad & t\neq s 且\ t\ 可達\\ \infty \quad & t\ 不可達 \end{aligned} \right. \end{equation} \]

可以使用 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 演算法了。演算法的思想是這樣的:

  1. 初始化 distTo[] 使得除了 distTo[s] = 0 外,其餘的元素都為 Double.POSITIVE_INFINITY。同時初始化 edgeTo[] 的每個元素都是 null

  2. 將頂點 s 的所有相鄰頂點 \(v_j\) 加入集合 \(V'\) 中,設定 distTo[j] = l_sj 即初始化最短距離為鄰邊的權重;

  3. \(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\) 不在的話);

  4. 重複上述過程直到 \(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[],使得它滿足下述關係:

\[\begin{equation} \text{nodeIndexes}(k) = \left\{ \begin{aligned} d \quad & k \in \text{indexes} \\ -1 \quad & k \notin \text{indexes} \end{aligned} \right. \end{equation} \]

如果能在 nodeIndexes[k] 不是 -1,就說明索引 \(k\) 對應的元素存在與堆中,且索引 k 在 indexes[] 中的位置為 \(d\),即有下述等式成立:

\[\text{indexes}[\text{nodeIndexes}[k]] = k\\ \text{nodeIndexes}[\text{indexes}[d]] = 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|)\),這裡就不寫了(因為還沒學到斐波那契堆),以上~~

相關文章