最短路徑演算法總結

B發表於2021-04-21

定義

(還記得這些定義嗎?如果對 圖的概念 儲存 不瞭解請點選連結)

  • 路徑
  • 最短路
  • 有向圖中的最短路、無向圖中的最短路
  • 單源最短路、每對結點之間的最短路

性質

對於邊權為正的圖,任意兩個結點之間的最短路,不會經過重複的結點。

對於邊權為正的圖,任意兩個結點之間的最短路,不會經過重複的邊。

對於邊權為正的圖,任意兩個結點之間的最短路,任意一條的結點數不會超過 \(n\) ,邊數不會超過 \(n-1\)

Floyd 演算法

是用來求任意兩個結點之間的最短路的。

複雜度比較高,但是常數小,容易實現。(我會說只有三個 for 嗎?)

適用於任何圖,不管有向無向,邊權正負,但是最短路必須存在。(不能有個負環)

實現

我們定義一個陣列 f[k][x][y] ,表示只允許經過結點 \(1\)\(k\) ,結點 \(x\) 到結點 \(y\) 的最短路長度。

很顯然, f[n][x][y] 就是結點 \(x\) 到結點 \(y\) 的最短路長度。

我們來考慮怎麼求這個陣列

f[0][x][y] :邊權,或者 \(0\) ,或者 \(+\infty\)f[0][x][x] 什麼時候應該是 \(+\infty\) ?)

f[k][x][y] = min(f[k-1][x][y], f[k-1][x][k]+f[k-1][k][y])

上面兩行都顯然是對的,然而這個做法空間是 \(O(N^3)\)

但我們發現陣列的第一維是沒有用的,於是可以直接改成 f[x][y] = min(f[x][y], f[x][k]+f[k][y])

for (int k = 1; k <= n; ++k)
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= n; ++j)
    f[i][j] = min(f[i][j], f[i][k] + f[k][j]);

時間複雜度是 \(O(N^3)\) ,空間複雜度是 \(O(N^2)\)

應用

"給一個正權無向圖,找一個最小權值和的環。"
首先這一定是一個簡單環。

想一想這個環是怎麼構成的。

考慮環上編號最大的結點 u。

f[u-1][x][y] 和 (u,x), (u,y)共同構成了環。

在 Floyd 的過程中列舉 u,計算這個和的最小值即可。

\(O(n^3)\)

"已知一個有向圖中任意兩點之間是否有連邊,要求判斷任意兩點是否連通。"
該問題即是求 圖的傳遞閉包

我們只需要按照 Floyd 的過程,逐個加入點判斷一下。

只是此時的邊的邊權變為 \(1/0\) ,而取 \(\min\) 變成了 運算。

再進一步用 bitset 優化,複雜度可以到 \(O(\frac{n^3}{w})\)

// std::bitset<SIZE> f[SIZE];
for (k = 1; k <= n; k++)
for (i = 1; i <= n; i++)
 if (f[i][k]) f[i] = f[i] & f[k];

Bellman-Ford 演算法

一種基於鬆弛(relax)操作的最短路演算法。

支援負權。

能找到某個結點出發到所有結點的最短路,或者報告某些最短路不存在。

在國內 OI 界,你可能聽說過的“SPFA”,就是 Bellman-Ford 演算法的一種實現。(優化)

實現

假設結點為 \(S\)

先定義 \(dist(u)\)\(S\)\(u\) (當前)的最短路徑長度。

\(relax(u,v)\) 操作指: \(dist(v) = min(dist(v), dist(u) + edge\_len(u, v))\) .

\(relax\) 是從哪裡來的呢?

三角形不等式: \(dist(v) \leq dist(u) + edge\_len(u, v)\)

證明:反證法,如果不滿足,那麼可以用鬆弛操作來更新 \(dist(v)\) 的值。

Bellman-Ford 演算法如下:

while (1) for each edge(u, v) relax(u, v);

當一次迴圈中沒有鬆弛操作成功時停止。

每次迴圈是 \(O(m)\) 的,那麼最多會迴圈多少次呢?

答案是 \(\infty\) !(如果有一個 \(S\) 能走到的負環就會這樣)

但是此時某些結點的最短路不存在。

我們考慮最短路存在的時候。

由於一次鬆弛操作會使最短路的邊數至少 \(+1\) ,而最短路的邊數最多為 \(n-1\)

所以最多執行 \(n-1\) 次鬆弛操作,即最多迴圈 \(n-1\) 次。

總時間複雜度 \(O(NM)\)(對於最短路存在的圖)

relax(u, v) {
	dist[v] = min(dist[v], dist[u] + edge_len(u, v));
}
for (i = 1; i <= n; i++) {
	dist[i] = edge_len(S, i);
}
for (i = 1; i < n; i++) {
	for each edge(u, v) {
		relax(u, v);
	}
}

注:這裡的 \(edge\_len(u, v)\) 表示邊的權值,如果該邊不存在則為 \(+\infty\)\(u=v\) 則為 \(0\)

應用

給一張有向圖,問是否存在負權環。

做法很簡單,跑 Bellman-Ford 演算法,如果有個點被鬆弛成功了 \(n\) 次,那麼就一定存在。

如果 \(n-1\) 次之內演算法結束了,就一定不存在。

佇列優化:SPFA

Shortest Path Faster Algorithm

很多時候我們並不需要那麼多無用的鬆弛操作。

很顯然,只有上一次被鬆弛的結點,所連線的邊,才有可能引起下一次的鬆弛操作。

那麼我們用佇列來維護“哪些結點可能會引起鬆弛操作”,就能只訪問必要的邊了。

q = new queue();
q.push(S);
in_queue[S] = true;
while (!q.empty()) {
	u = q.pop();
	in_queue[u] = false;
	for each edge(u, v) {
		if (relax(u, v) && !in_queue[v]) {
			q.push(v);
			in_queue[v] = true;
		}
	}
}

雖然在大多數情況下 SPFA 跑得很快,但其最壞情況下的時間複雜度為 \(O(NM)\) ,將其卡到這個複雜度也是不難的,所以考試時要謹慎使用(在沒有負權邊時最好使用 Dijkstra 演算法,在有負權邊且題目中的圖沒有特殊性質時,若 SPFA 是標算的一部分,題目不應當給出 Bellman-Ford 演算法無法通過的資料範圍)。

SPFA 的優化之 SLF

即 Small Label First。

即在新元素加入佇列時,如果隊首元素權值大於新元素權值,那麼就把新元素加入隊首,否則依然加入隊尾。

該優化在確實在一些圖上有顯著效果,但是如果有負權邊的話,可以直接卡到指數級。


Dijkstra 演算法

Dijkstra 是個人名(荷蘭姓氏)。

IPA:/ˈdikstrɑ/或/ˈdɛikstrɑ/。

這種演算法只適用於非負權圖,但是時間複雜度非常優秀。

也是用來求單源最短路徑的演算法。

實現

主要思想是,將結點分成兩個集合:已確定最短路長度的,未確定的。

一開始第一個集合裡只有 \(S\)

然後重複這些操作:

  1. 對那些剛剛被加入第一個集合的結點的所有出邊執行鬆弛操作。
  2. 從第二個集合中,選取一個最短路長度最小的結點,移到第一個集合中。

直到第二個集合為空,演算法結束。

時間複雜度:只用分析集合操作, \(n\)delete-min\(m\)decrease-key

如果用暴力: \(O(n^2 + m) = O(n^2)\)

如果用堆 \(O(m \log n)\)

如果用 priority_queue\(O(m \log m)\)

(注:如果使用 priority_queue,無法刪除某一箇舊的結點,只能插入一個權值更小的編號相同結點,這樣操作導致堆中元素是 \(O(m)\) 的)

如果用線段樹(ZKW 線段樹): \(O(m \log n + n) = O(m \log n)\)

如果用 Fibonacci 堆: \(O(n \log n + m)\) (這就是為啥優秀了)。

等等,還沒說正確性呢!

分兩步證明:先證明任何時候第一個集合中的元素的 \(dist\) 一定不大於第二個集合中的。

再證明第一個集合中的元素的最短路已經確定。

第一步,一開始時成立(基礎),在每一步中,加入集合的元素一定是最大值,且是另一邊最小值,每次鬆弛操作又是加上非負數,所以仍然成立。(歸納)(利用非負權值的性質)

第二步,考慮每次加進來的結點,到他的最短路,上一步必然是第一個集合中的元素(否則他不會是第二個集合中的最小值,而且有第一步的性質),又因為第一個集合內的點已經全部鬆弛過了,所以最短路顯然確定了。

H = new heap();
H.insert(S, 0);
dist[S] = 0;
for (i = 1; i <= n; i++) {
	u = H.delete_min();
	for each edge(u, v) {
		if (relax(u, v)) {
			H.decrease_key(v, dist[v]);
		}
	}
}

Johnson 全源最短路徑演算法

Johnson 和 Floyd 一樣,是一種能求出無負環圖上任意兩點間最短路徑的演算法。該演算法在 1977 年由 Donald B. Johnson 提出。

任意兩點間的最短路可以通過列舉起點,跑 \(n\) 次 Bellman-Ford 演算法解決,時間複雜度是 \(O(n^2m)\) 的,也可以直接用 Floyd 演算法解決,時間複雜度為 \(O(n^3)\)

注意到堆優化的 Dijkstra 演算法求單源最短路徑的時間複雜度比 Bellman-Ford 更優,如果列舉起點,跑 \(n\) 次 Dijkstra 演算法,就可以在 \(O(nm\log m)\) (取決於 Dijkstra 演算法的實現)的時間複雜度內解決本問題,比上述跑 \(n\) 次 Bellman-Ford 演算法的時間複雜度更優秀,在稀疏圖上也比 Floyd 演算法的時間複雜度更加優秀。

但 Dijkstra 演算法不能正確求解帶負權邊的最短路,因此我們需要對原圖上的邊進行預處理,確保所有邊的邊權均非負。

一種容易想到的方法是給所有邊的邊權同時加上一個正數 \(x\) ,從而讓所有邊的邊權均非負。如果新圖上起點到終點的最短路經過了 \(k\) 條邊,則將最短路減去 \(kx\) 即可得到實際最短路。

但這樣的方法是錯誤的。考慮下圖:

\(1 \to 2\) 的最短路為 \(1 \to 5 \to 3 \to 2\) ,長度為 \(−2\)

但假如我們把每條邊的邊權加上 \(5\) 呢?

新圖上 \(1 \to 2\) 的最短路為 \(1 \to 4 \to 2\) ,已經不是實際的最短路了。

Johnson 演算法則通過另外一種方法來給每條邊重新標註邊權。

我們新建一個虛擬節點(在這裡我們就設它的編號為 \(0\) )。從這個點向其他所有點連一條邊權為 \(0\) 的邊。

接下來用 Bellman-Ford 演算法求出從 \(0\) 號點到其他所有點的最短路,記為 \(h_i\)

假如存在一條從 \(u\) 點到 \(v\) 點,邊權為 \(w\) 的邊,則我們將該邊的邊權重新設定為 \(w+h_u-h_v\)

接下來以每個點為起點,跑 \(n\) 輪 Dijkstra 演算法即可求出任意兩點間的最短路了。

一開始的 Bellman-Ford 演算法並不是時間上的瓶頸,若使用 priority_queue 實現 Dijkstra 演算法,該演算法的時間複雜度是 \(O(nm\log m)\)

實現

#include <algorithm>
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

//節點編號1 ~ |V|
const int MAXV = 100 + 1;
const int INF  = 1e5;

vector<vector<int>> dist(MAXV, vector<int>(MAXV, INF)); // 距離矩陣,第一維是起始點,0編號存放S'到各個點的距離
vector<vector<int>> parent(MAXV, vector<int>(MAXV, 0)); //前驅子圖,第一維是起始點

struct E {
    int v;
    int w;
    E(int v, int w) : v(v), w(w) {}
    E() {}
};
typedef pair<int, int> P; //first存d,second存下標

struct Graph {
    vector<E> adj[MAXV];
    int n;
    int m;
};
Graph graph;

bool bellman_ford(int s) {
    dist[s][s] = 0;
    int n      = graph.n + 1;
    for (int k = 1; k <= n - 1; ++k) {
        for (int u = 0; u <= graph.n; ++u) { //u從0開始,0代表新新增的點
            for (int j = 0; j < graph.adj[u].size(); ++j) {
                E e   = graph.adj[u][j];
                int v = e.v;
                if (dist[s][v] > dist[s][u] + e.w) {
                    dist[s][v] = dist[s][u] + e.w;
                }                        //end if
            }                            //end for
        }                                //end for
    }                                    //end for
                                         //檢查有沒有從s可達的負圈
    for (int u = 0; u <= graph.n; ++u) { // u從0開始
        for (int j = 0; j < graph.adj[u].size(); ++j) {
            E e = graph.adj[u][j]; //邊
            if (dist[s][e.v] > dist[s][u] + e.w) {
                return false;
            } //endif
        }     //end for
    }         //end for
    return true;
}

struct cmp {
    bool operator()(P &p1, P &p2) {
        return p1.first > p2.first;
    }
};

//O(nlogn + mlogn), m>n時,O(mlogn); 斐波那契堆 O(nlogn + m)
void dijkstra(int s) {
    dist[s][s] = 0;
    priority_queue<P, vector<P>, cmp> pq;
    pq.push(P(0, s));
    while (!pq.empty()) {
        P v_m = pq.top();
        pq.pop(); //共執行n次,logn
        int u = v_m.second;
        if (dist[s][u] < v_m.first) continue;           //d[u]小於上界,說明之前更新過了,重複放入的元素
        for (int i = 0; i < graph.adj[u].size(); ++i) { //共執行m次
            E e = graph.adj[u][i];
            if (dist[s][e.v] > dist[s][u] + e.w) {
                dist[s][e.v]   = dist[s][u] + e.w;
                parent[s][e.v] = u;
                pq.push(P(dist[s][e.v], e.v)); //logn
            }
        }
    }
}

//O(mnlogn+n*nlogn),m>n時,O(mnlogn); 對於斐波那契堆為O(mn + n*nlogn)
void johnson() {
    //create G' add S' s' = 0
    for (int i = 1; i <= graph.n; ++i) {
        graph.adj[0].push_back(E(i, 0)); //w = 0
    }
    if (!bellman_ford(0)) {
        cout << "the input graph contains a negative-weight cycle" << endl;
        return;
    }
    //d(u)= shortest path from S to u,w'(u,v)=w(u,v)+dist(u)-dist(v) >= 0
    for (int u = 1; u <= graph.n; ++u) {
        for (int v = 0; v < graph.adj[u].size(); ++v) {
            graph.adj[u][v].w = graph.adj[u][v].w + dist[0][u] - dist[0][v]; //update w'
        }
    }
    for (int u = 1; u <= graph.n; ++u) {
        dijkstra(u);
        for (int v = 1; v <= graph.n; ++v) {                   //更新所有從u出發到所有頂點的距離
            dist[u][v] = dist[u][v] - dist[0][u] + dist[0][v]; //d'(u,v)=d(u,v)+dist(u)-dist(v)
        }
    }
}

void createGraph() {
    int u, v, k, w;
    cin >> graph.n >> graph.m;

    for (k = 1; k <= graph.m; ++k) {
        cin >> u >> v >> w;
        graph.adj[u].push_back(E(v, w));
    }
}

void print_shortest_path(int s, int v) {
    if (v == s) {
        cout << s << " ";
        return;
    }
    if (parent[s][v] == 0) {
        cout << "no path from " << s << " to " << v << endl;
        return;
    }
    print_shortest_path(s, parent[s][v]);
    cout << v << " ";
}
void print_path() {
    for (int s = 1; s <= graph.n; ++s) {
        cout << "start point=" << s << ":" << endl;
        for (int v = 1; v <= graph.n; ++v) {
            print_shortest_path(s, v);
            cout << ",dist=" << dist[s][v] << endl;
        }
        cout << endl;
    }
}

int main() {
    createGraph();
    johnson();
    print_path();
}

正確性證明

為什麼這樣重新標註邊權的方式是正確的呢?

在討論這個問題之前,我們先討論一個物理概念——勢能。

諸如重力勢能,電勢能這樣的勢能都有一個特點,勢能的變化量只和起點和終點的相對位置有關,而與起點到終點所走的路徑無關。

勢能還有一個特點,勢能的絕對值往往取決於設定的零勢能點,但無論將零勢能點設定在哪裡,兩點間勢能的差值是一定的。

接下來回到正題。

在重新標記後的圖上,從 \(s\) 點到 \(t\) 點的一條路徑 \(s \to p_1 \to p_2 \to \dots \to p_k \to t\) 的長度表示式如下:

\((w(s,p_1)+h_s-h_{p_1})+(w(p_1,p_2)+h_{p_1}-h_{p_2})+ \dots +(w(p_k,t)+h_{p_k}-h_t)\)

化簡後得到:

\(w(s,p_1)+w(p_1,p_2)+ \dots +w(p_k,t)+h_s-h_t\)

無論我們從 \(s\)\(t\) 走的是哪一條路徑, \(h_s-h_t\) 的值是不變的,這正與勢能的性質相吻合!

為了方便,下面我們就把 \(h_i\) 稱為 \(i\) 點的勢能。

上面的新圖中 \(s \to t\) 的最短路的長度表示式由兩部分組成,前面的邊權和為原圖中 \(s \to t\) 的最短路,後面則是兩點間的勢能差。因為兩點間勢能的差為定值,因此原圖上 \(s \to t\) 的最短路與新圖上 \(s \to t\) 的最短路相對應。

到這裡我們的正確性證明已經解決了一半——我們證明了重新標註邊權後圖上的最短路徑仍然是原來的最短路徑。接下來我們需要證明新圖中所有邊的邊權非負,因為在非負權圖上,Dijkstra 演算法能夠保證得出正確的結果。

根據三角形不等式,圖上任意一邊 \((u,v)\) 上兩點滿足: \(h_v \leq h_u + w(u,v)\) 。這條邊重新標記後的邊權為 \(w'(u,v)=w(u,v)+h_u-h_v \geq 0\) 。這樣我們證明了新圖上的邊權均非負。

這樣,我們就證明了 Johnson 演算法的正確性。


不同方法的比較

Floyd Bellman-Ford Dijkstra Johnson
每對結點之間的最短路 單源最短路 單源最短路 每對結點之間的最短路
沒有負環的圖 任意圖(可以判定負環是否存在) 非負權圖 沒有負環的圖
\(O(N^3)\) \(O(NM)\) \(O(M\log M)\) \(O(NM\log M)\)

注:表中的 Dijkstra 演算法在計算複雜度時均用 priority_queue 實現。

輸出方案

開一個 pre 陣列,在更新距離的時候記錄下來後面的點是如何轉移過去的,演算法結束前再遞迴地輸出路徑即可。

比如 Floyd 就要記錄 pre[i][j] = k; ,Bellman-Ford 和 Dijkstra 一般記錄 pre[v] = u

具體程式碼實現可看相應的文章:Dijkstra 演算法Bellman Ford 演算法SPFA 演算法Floyd演算法

Reference

相關文章