react中通過useRoutes從後臺動態獲取路由表
1 概述
最短路問題是圖論中一類經典問題,也是圖論中較為基礎的演算法。本文旨在簡要概述解決最短路問題的一些常見演算法及其原理。
對於一張帶權重的圖G,G上一條路徑p=<v0,v1,…,vk>的權重w(p)為這條路徑各邊邊權之和:w(p)=∑w(vi-1,vi)。在從u到v的所有路徑中,稱最小權重為從u到v的最短路徑權重,稱的權重最小的路徑為從u到v的最短路徑。顯而易見的是,從u到v的最短路徑可能不止一條。如果u和v不連通,我們將從u到v的最短路權重記為+∞。
最短路演算法通常都依賴於這樣一個性質:最短路徑的子路徑也是最短路徑。這一點很好理解:如果一條最短路徑的子路徑不是最短路徑,我們一定能用這條子路徑兩端點間的最短路徑替換這條子路徑,從而找到了一條權重更小的路徑,因而現在的這條路徑並不是最短路徑。於是,最短路徑是具有最優子結構的,我們便可以使用動態規劃來解決這個問題。事實上,本文討論的這些最短路演算法都是基於動態規劃的。
一條最短路能包含環路嗎?我們首先討論權重為負值的環路(即負環)對最短路徑權重的影響。容易發現,如果一條最短路徑包含了一個負環,我們可以重複地經過這個負環,從而使得最短路徑權重變為-∞(我們可以使用Bellman-Ford演算法來判斷一張圖是否具有負環)。由此可以發現,討論包含負環的最短路徑意義不大。此外,最短路也不會包含權重為正值的環路,因為只要將環路從最短路徑上刪除,我們就能得到一條權值更小的路徑。類似的,最短路也不會包含權重為0的環路。由此可以發現,在一張圖上,從一個起點出發,它到其它結點的最短路徑構成了一棵樹。這是一條非常有趣的性質。
2 單源最短路徑演算法
單源最短路徑問題是指:給定一張圖G,從圖中指定的源點(起點),計算它到圖中其它所有結點的最短路徑。
單源最短路徑問題是最短路徑的一個基本問題,解決此問題後,我們也可以解決以下幾個單源最短路徑問題的變體:
單匯最短路徑問題:即計算圖中每個結點到指定的匯點(目的地)的最短路徑。顯然,只要我們反向建圖,這個問題就被轉化成了單源最短路徑問題。
單結點對最短路徑問題:即計算圖中某個結點對間的最短路徑。顯然,只要解決了單源最短路徑問題,這個問題也迎刃而解。
所有結點對最短路徑問題:即計算圖中所有結點對之間的最短路徑。我們可以對每個結點求一次單源最短路徑來解決這個問題,不過在更為稠密的圖上,我們有另外的演算法來解決這個問題。我們將在後續的部分中專門討論這個問題。
2.1 鬆弛操作
本文討論的單源最短路徑演算法都依賴於鬆弛操作。
對於每個結點v,我們維護兩個屬性dist和path,dist用來記錄從源點s到v的最短路徑權重,path用來記錄最短路徑上v的前驅結點。在初始狀態,所有結點的dist都被賦值為+∞。我們現在要解決的問題就是如何更新dist。這一操作依賴於如下一個性質,不妨稱之為“三角形定理”:設x、y為圖G上兩結點,g[x][y]為從x到y的距離,則應有dist[x]+g[x][y]≥dist[y]。這個定理的正確性是顯而易見的。倘若其不成立,則現有的dist[y]並不是從源點s到y的最短路徑權重,我們就用dist[x]+g[x][y]更新dist[y],並將path[y]更新為x,這樣我們就找到了一條更短的從s到y的路徑,也就完成了一次鬆弛操作。顯然,只要鬆弛的次數足夠多,我們便可以算出從s到y的最短路徑。
2.2 Dijkstra演算法
2.2.1 Dijkstra演算法
Dijkstra演算法是一種用於解決邊權非負的圖上的單源最短路徑的演算法。
Dijkstra演算法是基於貪心思想實現的。每一次,我們新拓展一個dist最小的結點,然後以它為中間點進行鬆弛操作,嘗試更新其它結點的dist。由於圖中的邊權都非負,因而一個結點在被拓展之後其dist一定不會被再度更新。因此,我們只要把所有結點都拓展一遍,就能解決單源最短路徑問題。這個演算法的複雜度是Θ(n2)的。
1 void dijkstra(int s){ 2 memset(dist,0x3f,sizeof(dist)); 3 dist[s]=0; 4 int x; 5 for(int i=1;i<=n;i++){ 6 int MIN=0x3f3f3f3f; 7 for(int j=1;j<=n;j++) 8 if(!vis[j]&&dist[j]<MIN) x=j,MIN=dist[j]; 9 //在所有未擴充套件的節點中選取一個dist值最小的點進行更新 10 vis[x]=1;//標記為已拓展 11 for(int j=1;j<=n;j++) 12 if(dist[x]+g[x][j]<dist[j]) 13 dist[j]=dist[x]+g[x][j],path[j]=x; 14 //更新到其他節點的最短路,path陣列用於記錄路徑 15 } 16 }//效率O(n^2)
2.2.2 堆優化Dijkstra演算法
前文中提到的Dijkstra演算法顯然還有很大的提升空間。容易發現,我們在尋找dist最小的結點上浪費了很多時間。注意到這一過程可以被視作以下兩個操作的組合:向一個集合新增一個新元素;求出這個集合中的最小元素。於是我們可以引入堆這一資料結構來優化這一過程。此外,在稀疏圖上,我們還可以使用前向星來優化列舉其它結點的過程。
堆優化Dijkstra演算法的複雜度是O(mlogn)的,在稀疏圖上明顯更優。這一複雜度是比較穩定的。
1 struct node{ 2 int u,v,w,nxt; 3 //u記錄起點,v記錄終點,w記錄邊權,nxt記錄該點出發的上一條邊 4 }e[M];//邊目錄 5 int num,f[N];//f表示該點出發的最後一條邊(按加邊順序) 6 void add(int u,int v,int w){ 7 e[++num].u=u;e[num].v=v;e[num].w=w; 8 e[num].nxt=f[u];f[u]=num; 9 }//前向星加邊 10 typedef pair<int,int> T; 11 void dijkstra(int s){//堆優化dijkstra 12 memset(vis,0,sizeof(vis)); 13 memset(d,0x3f,sizeof(d)); 14 priority_queue< T,vector<T>, greater<T> >q;//小根堆 15 d[s]=0;q.push(make_pair(d[s],s)); 16 //以dist為第一關鍵字,結點序號為第二關鍵字 17 //pair的作用在於防止在堆的操作導致結點序號資訊的丟失 18 while(!q.empty()){ 19 pair<int,int> t=q.top();q.pop(); 20 int dd=t.first,u=t.second; 21 if(vis[u]) continue;vis[u]=1; 22 for(int i=f[u];i;i=e[i].nxt){ 23 int v=e[i].v,w=e[i].w; 24 if(d[v]>dd+w){ 25 d[v]=dd+w;q.push(make_pair(d[v],v)); 26 } 27 } 28 } 29 }
2.2.3 拓展:如何把負邊權轉化為非負邊權
我們給任意一個結點v賦予一個權值h[v]。對每條邊(u,v),我們將其邊權w改為w'=w+h[u]-h[v]。於是,一條從結點i到結點j的路徑p的權重w(p)就變成了w'(p)=w(p)+h[i]-h[i](裂項相消)。這個值只與原路徑權重、起點與終點的邊權有關,而與路徑上的中間結點無關。因此,如果一條從i到j的路徑在不使用這個權重時比另一條路徑更短,那麼在使用這個權重仍然比另一條短。於是我們只要設計一個合適的權重函式,便能將負邊權轉化為非負邊權,從而能在包含邊權為負的邊但不包含負環的圖上使用Dijkstra演算法。
2.3 Bellman-Ford演算法與SPFA演算法
2.3.1 Bellman-Ford演算法
Bellman-Ford演算法也可以被用來判斷一張圖是否具有負環。
Bellman-Ford演算法的思路非常簡單:初始時,我們將源點s的dist賦值為0,其它結點的dist都賦值為+∞;然後我們對每條邊鬆弛n-1次之後,再沒有負環的情況下,就求出了從源點到所有結點的最短路。
為什麼n-1次鬆弛操作就能保證得到正確答案?我們可以這樣理解:第i次鬆弛操作實際上求的是包含了至多i條邊的最短路徑權重,而由於最短路徑不包含環路,因此一條最短路徑最多包含n-1條邊,因此在沒有負環的情況下,n-1次鬆弛操作後得到的dist一定是從源點到該結點的最短路徑權重。倘若這個時候還能進行鬆弛操作,那麼就說明這張圖上存在負環。
Bellman-Ford演算法的複雜度是O(n*m)的。
1 int Bellman-Ford(int x){ 2 memset(d,0x3f,sizeof(d)); 3 d[x]=0; 4 for(int i=1;i< n;i++)//進行n-1次鬆弛 5 for(int j=1;j<=m;j++){ 6 int u=e[j].u,v=e[j].v,w=e[j].w; 7 if(d[v]>d[u]+w) d[v]=d[u]+w; 8 //如果是有向圖,一次鬆弛就搞定了 9 if(d[u]>d[v]+w) d[u]=d[v]+w; 10 //無向圖還要再反向鬆弛一次,因為一條無向邊相當於兩條有向邊 11 } 12 for(int i=1;i<=m;i++) 13 if(d[e[i].v]>d[e[i].u]+e[i].w) return 0; 14 //如果還能鬆弛,說明存在負環 15 return 1; 16 }//效率O(n*m)
2.3.2 SPFA演算法
SPFA演算法實際上是Bellman-Ford演算法的另一種實現方式。
回顧Bellman-Ford演算法的實現過程,我們不難發現有些鬆弛操作是多餘的。那麼在實現過程中,我們能否通過儘量減少不必要的鬆弛操作來進行優化呢?答案是肯定的。如果我們用搜索的方式來實現Bellman-Ford演算法,那麼我們便可以通過剪枝的方式來提高這一演算法的效率。這便是SPFA演算法。
下文將給出一種基於廣度優先搜尋實現的SPFA演算法。在這種演算法中,我們每一次取出隊首元素進行鬆弛操作,將能更新的所有不在佇列中的結點加入佇列。
這樣優化後的SPFA演算法雖然時間複雜度仍然是O(n*m)的,但在多數實際問題中,它是遠遠達不到這個上界的,效率近似於Dijkstra演算法。事實上,在某些特殊情況下,SPFA演算法甚至快於Dijkstra演算法。不過SPFA演算法並不穩定,在另一些特殊情況下,比如存在負環的情況下,其執行效率與Bellman-Ford演算法沒有明顯差異。因而為了求穩,在演算法競賽中使用更多的是更穩定的Dijkstra演算法。
1 void spfa(int x){ 2 queue<int>q; 3 memset(d,0x3f,sizeof(d)); 4 q.push(x),vst[x]=1,d[x]=0; 5 //vst陣列用來標記該結點是否在佇列中 6 while(!q.empty()){ 7 int u=q.front();q.pop();vst[u]=0; 8 for(int i=f[u];i;i=e[i].nxt){ 9 int v=e[i].v,w=e[i].w; 10 if(d[v]>d[u]+w){ 11 d[v]=d[u]+w; 12 if(!vst[v]){q.push(v),vst[v]=1;} 13 //如果這個結點已經在佇列中,我們顯然沒有必要再讓它進隊一次 14 //這實際上就是一種剪枝 15 } 16 //如果這個結點無法被鬆弛,那麼它也不會再鬆弛相鄰的其它結點 17 //因而這個結點也沒有再次進隊的必要 18 } 19 } 20 }//如果存在負環,效率為O(n*m)
2.3.3 SPFA演算法的深度優先搜尋實現方式
不難發現,上述用BFS實現的SPFA演算法雖然在一定程度上實現了優化,但是並不能很容易地判定負環的存在性。那麼我們不妨嘗試換一種實現方式,即用DFS來實現SPFA演算法。
由前文的討論,在沒有負環的情況下,最短路徑上不會出現環路。因此在這一情況下,如果我們在進行DFS時每次搜尋一個可以被鬆弛的結點,那麼同一個結點不會在搜尋棧中出現兩次,否則圖中便含有一個負環。因此,我們維護一個初始值為0的vis陣列,用來標記該結點是否在搜尋棧中。搜尋到當前結點u時,我們將vis[u]改為1;當完成可被u鬆弛的結點的搜尋後,再將vis[u]改回0。
1 void spfa(int u){ 2 if(flag) return;vis[u]=1; 3 for(int i=f[u];i;i=e[i].nxt){ 4 int v=e[i].v,w=e[i].w; 5 if(d[u]+w<d[v]){ 6 if(vis[v]){flag=1;return;} 7 //重新經過了一個在當前搜尋棧中的節點,說明存在負環 8 d[v]=d[u]+w;spfa(v); 9 } 10 } 11 vis[u]=0;//遍歷完畢,出棧 12 }
2.3.4 雙端佇列優化SPFA
我們不妨利用貪心的思想對SPFA作進一步優化:如果我們先搜尋dist較小的結點,應當可以在一定程度上減少一些多餘的鬆弛操作。使用雙端佇列替換普通的佇列便可實現這一點。每當有一個新的結點需要進隊時,我們比較它與隊首元素的dist,如果它的更小,便將它從隊首插入,否則仍然從隊尾插入。
1 void spfa(int x){ 2 memset(vis,0,sizeof(vis)); 3 memset(d,0x3f,sizeof(d)); 4 d[x]=0;deque<int>q; 5 q.push_back(x);vis[x]=1;cnt[x]++; 6 while(!q.empty()){ 7 int u=q.front();q.pop_front();vis[u]=0; 8 for(int i=f[u];i;i=e[i].nxt){ 9 int v=e[i].v,w=e[i].w; 10 if(d[v]>d[u]+w){ 11 d[v]=d[u]+w; 12 if(!vis[v]){ 13 if(++cnt[v]>n){flag=1;return;} 14 if(q.empty()||d[v]<d[q.front()]) q.push_front(v); 15 //隊空或小於隊首,從隊首加入 16 //注意,隊空後q.front()會RE 17 else q.push_back(v); 18 vis[v]=1; 19 } 20 } 21 } 22 } 23 }//雙端佇列優化spfa
3 所有結點對最短路徑問題:Floyd演算法
3.1 原理
如前文所述,所有結點對最短路徑問題可以歸結為計算每個結點的單源最短路徑問題,因此我們可以對每個結點都呼叫一遍Dijkstra演算法或者SPFA演算法。對於較為稀疏的圖,這種方法當然是可行的;但對於稠密圖而言,這種思路似乎還是略顯粗暴(這種思路下,效率最高的反而是未經優化的Dijkstra演算法,時間複雜度為Θ(n3),與Floyd演算法相當)。
給定一張圖G=(V,E),我們考慮結點集合V的一個子集{1,2,…,k}(k≤n)。對於任意結點對(i,j),考慮所有中間結點均在這個子集中的從i到j的路徑,並記權重最小的一條路徑為路徑p。現在我們分兩種情況討論:
·k不是路徑p的中間結點。那麼路徑p上的所有結點都屬於集合{1,2,…,k-1},從而也屬於集合{1,2,…,k}。
·k是路徑p的中間結點。我們類似地定義從i到k的路徑p1和從k到j的路徑p2,於是p1、p2上的點都屬於集合{1,2,…,k-1}。
於是我們便得出這樣一個時間複雜度為Θ(n3)的演算法:依次列舉中間結點k、起點i和終點j,然後計算從i到j的所有中間結點均在集合{1,2,…,k}中的最短路徑權重。我們有狀態轉移方程fij=min{fij,fik+fkj}。這便是Floy演算法。由上文的討論,容易發現這種DP的方式是不具有後效性的,因而可以保證其正確性。
關於f陣列的初始化:如果i與j通過G中的一條邊直接相連,則f[i][j]=這條邊的邊權;否則f[i][j]=+∞。
1 void floyd(){ 2 for(int k=1;k<=n;k++)//列舉中間點,注意必須放在最外層 3 for(int i=1;i<=n;i++) 4 for(int j=1;j<=n;j++) 5 f[i][j]=min(f[i][j],f[i][k]+f[k][j]); 6 }
3.2 求解有向圖的傳遞閉包
有向圖G=(V,E)的傳遞閉包為圖G*=(V,E*),其中,E*={(i,j)|G中包含一條從i到j的路徑}。
顯然,如果G中包含一條從i到j的路徑,那麼一定有一條從i到j的最短路徑。因此如果我們給G中每條邊附上邊權1,便可用Floyd演算法解決這一問題。
在高等教育出版社出版的《離散數學及其應用(第2版)》中還提到了一種用關係矩陣來計算傳遞閉包的Warshall演算法,其本質仍是Floyd演算法的變體,在此處僅給出虛擬碼:
1 for k = 1 to n 2 for i = 1 to n 3 for j =1 to n 4 M[i,j]=M[i,j]+M[i,k]M[k,j] 5 //M初始賦值為G對應的矩陣,涉及的加法均為邏輯加
參考文獻
(美)Thomas H.Cormen,Charles E.Leiserson,Ronald L.Rivest,Clifford Stein,殷建平,徐雲,王剛等譯.演算法導論[M].北京,機械工業出版社,2021.
屈婉玲,耿素雲,張立昂.離散數學及其應用(第2版)[M].北京,高等教育出版社,2018.