結點對最短路徑之Floyd演算法原理詳解及實現
上兩篇部落格介紹了計算單源最短路徑的Bellman-Ford演算法和Dijkstra演算法。Bellman-Ford演算法適用於任何有向圖,即使圖中包含負環路,它還能報告此問題。Dijkstra演算法執行速度比Bellman-Ford演算法要快,但是其要求圖中不能包含負權重的邊。
在很多實際問題中,我們需要計算圖中所有結點對間的最短路徑。當然,我們可以使用上述兩種演算法來計算每一個頂點的單源最短路徑,對於圖G=(V,E)來說,使用Bellman-Ford演算法計算結點對最短路徑的時間複雜度為O(V^2 * E),使用Dijkstra演算法計算結點對最短路徑的時間複雜度為O(V^3)。本文將會介紹一種應用更廣泛的演算法,而且它可以應用於有負權重邊但沒有負環路的圖中,其時間複雜度為O(V^3),那就是Floyd-Warshall演算法。
1. Floyd演算法的原理
在上一篇部落格 中,提到了圖的一個重要性質:一條最短路徑的子路徑也是一條最短路徑。因此,一條最短路徑要麼只包含一條直接相連的邊,要麼就經過一條或多條到達其它頂點的最短路徑。
上圖給出的是頂點i到頂點j的路徑示意圖。i到j的路徑為<i,...,k,...,j>,其中頂點k是路徑i到j的一個編號最大的中間頂點,即路徑<i,...,k>中的所有頂點編號求取自集合{1,2,3,...,k-1},路徑<k,...,j>也是一樣的。因為路徑<i,...,k,...,j>為最短路徑,那麼路徑<i,...,k>和路徑<k,...,j>
於是,我們可以推出如下遞迴公式。
dij(k) = wij 當k=0;
dij(k) = min(dij(k-1), dik(k-1)+dkj(k-1)) 當k>0;
上述公式中dij為頂點i到頂點j的當前路徑的長度,k是當前遞迴中路徑的最大頂點編號。當k=0時,路徑的中間頂點的編號不大於0,即不存在任何中間頂點,這種情況頂點i到頂點j的路徑必然只是一條連線這兩個頂點的邊,因此其長度為該邊的權重。當k>0,每次遞迴時加入編號為k的頂點,可以根據其它"當前最短路徑"構造頂點i到頂點j的一條新路徑,並與其原路徑進行比較,從中選擇更短的。這是一種自底向上的動態規劃演算法。
2. Floyd演算法的C實現
本文實現的Floyd演算法所需要的輸入與前面的部落格介紹的不一樣。前面介紹的所有圖演算法需要的圖都是用鄰接表表示的。下面給出的Floyd演算法需要的圖使用鄰接矩陣表示的,即權重圖。該實現使用前驅子圖(二維矩陣)來記錄結點對的最短路徑的目的頂點的前驅頂點編號(前一個頂點的編號)。
/**
* Floyd 尋找結點對的最短路徑演算法
* w 權重圖
* vertexNum 頂點個數
* lenMatrix 計算結果的最短路徑長度儲存矩陣(二維)
* priorMatrix 前驅子圖(二維),路徑<i, ..., j>重點j的前一個頂點k儲存在priorMatrix[i][j]中
*/
void Floyd_WallShall(int **w, int vertexNum, int **lenMatrix, int **priorMatrix)
{
// 初始化
for (int i = 0; i < vertexNum; i++)
{
for (int j = 0; j < vertexNum; j++)
{
*((int*)lenMatrix + i*vertexNum + j) = *((int*)w + i*vertexNum + j);
if (*((int*)w + i*vertexNum + j) != INF && i != j)
{
*((int*)priorMatrix + i*vertexNum + j) = i;
}
else
{
*((int*)priorMatrix + i*vertexNum + j) = -1;
}
}
}
// Floyd演算法
for (int k = 0; k < vertexNum; k++)
{
for (int i = 0; i < vertexNum; i++)
{
for (int j = 0; j < vertexNum; j++)
{
int Dij = *((int*)lenMatrix + i*vertexNum + j);
int Dik = *((int*)lenMatrix + i*vertexNum + k);
int Dkj = *((int*)lenMatrix + k*vertexNum + j);
if (Dik != INF && Dkj != INF && Dij > Dik + Dkj)
{
*((int*)lenMatrix + i*vertexNum + j) = Dik + Dkj;
*((int*)priorMatrix + i*vertexNum + j) = *((int*)priorMatrix + k*vertexNum + j);
}
}
}
}
}
上述程式需要輸入一個鄰接矩陣,頂點的個數,以及用於儲存結果路徑長度的矩陣和前驅子圖矩陣。這些矩陣本質上均是一個二維陣列。該演算法首先對長度矩陣和前驅子圖進行初始化,也就是遞推公式當k=0時的操作,然後就進入迴圈反覆更新結點對的路徑。演算法沒計算一次所有結點對的路徑,需要進行V^2次運算,而演算法需要從小到大依次將V個頂點加入到圖中進行運算,於是整個演算法的時間複雜度為O(V^3)。
這裡簡單說一下前驅子圖priorMatrix。我們可以通過前驅子圖找到任意結點對的最短路徑。例如我們要找到頂點i到頂點j的一條最短路徑,可以先找到k=priorMatrix[i][j],此時就知道路徑為<i,...,k,j>,然後我們再找到路徑<i,...,k>的前驅頂點,即priorMatrix[i][k],如此類推。這一操作的正確性由上面提到的性質(一條最短路徑的子路徑也是一條最短路徑)保證。
下面給出一個應用上述演算法的例子。
int w[5][5] = { 0, 3, 8, INF, -4,
INF, 0, INF, 1, 7,
INF, 4, 0, INF, INF,
2, INF, -5, 0, INF,
INF, INF, INF, 6, 0};
int lenMatrix[5][5];
int priorMatrix[5][5];
Floyd_WallShall((int**)w, 5, (int**)lenMatrix, (int**)priorMatrix);
for (int i = 0; i < 5; i++)
{
for (int j = 0; j < 5; j++)
{
if (lenMatrix[i][j] == INF)
{
printf("從%d到%d\t\t長度:INF\n", i, j);
}
else
{
printf("從%d到%d\t\t長度:%d\t\t路徑:", i, j, lenMatrix[i][j]);
printIJPath((int**)priorMatrix, 5, i + 1, j + 1);
}
}
}
printIJPath方法的定義如下。
/**
* 根據前驅子圖列印i到j的路徑,輸入頂點編號從1開始,輸出頂點編號從1開始
*/
void printIJPath(int **prior, int vertexNum, int i, int j)
{
i--; j--;
printf("%d", j + 1);
int k = *((int*)prior + i*vertexNum + j);
while (k != -1)
{
printf(" <- %d", k + 1);
k = *((int*)prior + i*vertexNum + k);
}
printf("\n");
}
上述例程構造的圖以及執行結果如下圖所示。前驅子圖總priorMatrix[i][i]=-1。
0 | 1 | -3 | 2 | -4 |
3 | 0 | -4 | 1 | -1 |
7 | 4 | 0 | 5 | 3 |
2 | -1 | -5 | 0 | -2 |
8 | 5 | 1 | 6 | 0 |
-1 | 2 | 3 | 4 | 0 |
3 | -1 | 3 | 1 | 0 |
3 | 2 | -1 | 1 | 0 |
3 | 2 | 3 | -1 | 0 |
3 | 2 | 3 | 4 | - |
3. 總結
Floyd演算法的時間複雜度為O(V^3),因為其實現程式碼很緊湊,所以時間複雜度的常數項很小。Floyd演算法是一種應用非常廣泛的計算結點對最短路徑的演算法。其實還有一種結合了Bellman-Ford演算法和Dijkstra演算法的Johnson演算法,該演算法在用於稀疏圖時執行速度比Floyd演算法更快,並且能夠報告圖中存在負環路的情況(得益於Bellman-Ford演算法)。Johnson演算法的時間複雜度為Bellman-Ford演算法的時間複雜度加上Dijkstra演算法的時間複雜度。如果使用二叉堆實現Dijkstra演算法的最小優先佇列,那麼Johnson演算法時間複雜度為O(VElgV+VE)=O(VElgV)。Johnson演算法的具體介紹可以參考其它資料,下面給出的個github專案中也有具體的C實現程式碼。
完整的程式可以看到我的github專案 資料結構與演算法
這個專案裡面有本部落格介紹過的和沒有介紹的以及將要介紹的《演算法導論》中部分主要的資料結構和演算法的C實現,有興趣的可以fork或者star一下哦~ 由於本人還在研究《演算法導論》,所以這個專案還會持續更新哦~ 大家一起好好學習~