【淺談】 單元最短路徑兩種演算法 & 在路由選擇中的應用
- 寫在前面:因為能力和記憶有限,為方便以後查閱,特寫看上去 “不太正經” 的隨筆。隨筆有 “三” 隨:隨便寫寫;隨時看看;隨意理解。
注:本篇文章涉及資料結構(圖),離散數學,演算法,計算機網路相關知識,但都只為加深印象淺層剖析,讀者可根據自身情況選擇閱讀,若求甚解,勿往下讀,以免浪費時間。
不知道讀者聽說過的是哪個版本:單源最短路徑,最短道路樹,兩結點間的最短路徑;總的來說,沒什麼區別,注意與最小生成樹 (也稱最小支撐樹或最小生成/支撐子圖) 區分即可。
幾種演算法:
1.迪傑斯特拉 / 迪科斯徹(Dijkstra)演算法:
步驟:
1)選擇一個頂點作為起(源)點,用陣列 dis[ ] 記錄源點到其餘各點的最短距離(distance),增加一個已訪問(visited)點集 vis[ ],並將起點加入 vis[ ] 集合中;
2)由當前訪問結點開始,更新由該點到其餘未訪問點的距離,若訪問點與某點之間的道路不存在,則距離記為 +∞ ,只有當新距離<原來距離時,才能改變相應距離的值dis[ ]
設當前訪問結點為 a,對於未訪問結點 b 的距離更新,應判斷 dis[a] + ab間的道路長度( 記為Edge(a,b) ) < dis[b] 是否成立,成立說明經過a再到b的道路更短,故可將dis[b]的值更新為 dis[a]+Edge(a,b);
3)在未訪問點中選擇 dis[] 值最小的一個結點,作為下一個訪問點並加入vis[ ] 集合中;(可以看到每次選擇的點的 dis[ ] 值已經固定)
4)回到步驟2,當所有結點已訪問 (即所有點加入vis[ ]陣列) 時,跳出迴圈,演算法結束,此時 dis[ ] 陣列儲存的值即為源點到其它所有點的最短距離。
舉個栗子:
我們以結點a作為起始點,運用上述方法:
步驟 | 訪問結點 | vis點集 | dis點集(加粗表示已訪問) | 準備加入的邊 |
0(初始化) | / | / |
dis[a]=0,dis[b]=∞ dis[c]=∞,dis[d]=∞ dis[e]=∞,dis[f]=∞ |
/ |
1 | a | a |
dis[a]=0,dis[b]=5(new) dis[c]=3(new),dis[d]=∞ dis[e]=∞,dis[f]=∞ |
ac |
2 | c | a,c |
dis[a]=0,dis[b]=4(new) dis[c]=3,dis[d]=5 dis[e]=8(new),dis[f]=∞ |
cb |
3 | b | a,c,b |
dis[a]=0,dis[b]=4 dis[c]=3,dis[d]=5 dis[e]=8,dis[f]=∞ |
cd |
4 | d | a,c,b,d |
dis[a]=0,dis[b]=4 dis[c]=3,dis[d]=5 dis[e]=8,dis[f]=9(new) |
ce |
5 | e | a,c,b,d,e |
dis[a]=0,dis[b]=4 dis[c]=3,dis[d]=5 dis[e]=8,dis[f]=9 |
df |
6 | f | a,c,b,d,e,f(結束end) |
不難發現每步選擇訪問的點是未訪問結點中 dis[ ] 值最小的,
對於加入邊的繪製,卻依然要看圖分析,這很明顯不利於機器輸出,這裡提供一種解決方案:
加入一個新的陣列集合 path[ ] ,記錄相應結點的前驅即可,這樣對於每個訪問點,呼叫一次path可找到相應加入的邊;此外,不斷呼叫path找到前驅,直到path值為起點,就可以描繪出兩點間的最短路徑。
對於 path[ ] 相關值的更新也是比較巧妙的,表格上dis值中標有new(紅色突出)的結點,說明經由當前訪問結點後再到這些點的距離更小,進行資料更新,那麼其前驅必定是當前訪問結點,隨之更新即可;
簡而言之,初始將所有結點path值設為自身(∀m∈Graph,path[m]=m),設 k 為當前訪問結點,對於未曾訪問的結點 n,當 dis[k]+Edge(k,n)<dis[n] 成立 (為true) 時,dis[n]=dis[k]+Edge(k,n),path[n]=k;
例中最短道路樹為 (見下圖紅色標註):
該方法繪製出的是無向樹的結構,樹是連通且無環的圖,屬於圖的一種特殊形態。
=============相信讀到這裡的你對dijkstra演算法核心部分的程式碼實現已經有了基本框架============
程式碼如下(可輸入本例資料進行測試,結點名稱換成數字編號即可):
// #include<bits/stdc++.h> #include<iostream> using namespace std; void CreateGraph(int m,int n,int** a){//生成無向鄰接矩陣圖 int u,v; float edge; cout<<"Enter the 1.from 2.to 3.weight:"<<endl; for (int i = 1; i <= n; i++){//initialize the Matrx for (int j = 1; j <= n; j++) a[i][j] = 0x7fffffff; a[i][i]=0; } for(int i=1;i<=m;++i){ cin>>u>>v>>edge; a[u][v]=a[v][u]=edge; } } // used即為visit陣列,dist即為distance陣列,k為每次所選結點編號; // amount為所有點單源最短路徑之和,rec用於尋找路徑(相當於path) void Dikjstra(int n,int** a){ int start; cout<<"Finding the lowest cost edge..."<<endl<<"Enter the start number:"; cin>>start;//選擇起始點編號 int k,used[n+1]={0},amount=0; int *rec=new int[n+1];//路徑陣列,記錄前驅結點編號 float dist[n+1]; used[start]=1; for(int i=1;i<=n;i++){//初始化,相當於第一個點選的是起點,對其餘邊鬆弛 dist[i] = a[start][i]; rec[i]=start; } //*******************執行n-1次,每次選一點同時更新dist值*****************// for(int i=1;i<n;i++){ int tmin = 0x7fffffff; //tmin最開始設定為無窮,然後在其他未選點中遍歷dist值,更小則更新 //************該迴圈執行選點操作***********// for(int j=1;j<=n;j++) if( !used[j] && tmin >dist[j]){ tmin = dist[j]; k = j; //編號也隨之更新決定下一次選點 } cout<<"Choose "<<k<<endl; used[k] = 1;//選k點 //**********該迴圈執行鬆弛操作更新dist值*********// for(int j=1;j<=n;j++) if(dist[k] + a[k][j] < dist[j] && !used[j]){ dist[j] = dist[k] + a[k][j]; rec[j]=k;//j的前驅為k,即經過k到j } } //************************************************************************// cout<<"dist[] value is:"<<endl; for(int i=1;i<=n;i++){ printf("%f ",dist[i]); amount+=dist[i]; } cout<<endl<<"The whole cost is:"<<amount<<endl; } int main(){ int m,n;//邊數m,點數n int** a;//鄰接矩陣 cout<<"Enter the number of the node & edge..."<<endl; cin>>n>>m; a=new int* [n+1]; for (int i =1; i<=n+1; i++) a[i]=new int[n+1]; CreateGraph(m,n,a); Dikjstra(n,a); }View Code
演算法分析:
Dijkstra演算法每一步選擇dis值最小的點作為區域性最優解,不考慮子問題的解(貪心與動態規劃的區別在此),屬於貪心演算法。
因此對於(所有)貪心演算法,我們可以採用以下格式分析,框架部分就不列舉了,直接拿該例項品嚐:
①貪心策略:對於帶權圖,規定Edge(i,j)表示 i 到 j 的距離,即邊權,且所有權重非負,dis[ ]表示只經過vis(已訪問)點集,各點相對於起點的最短路徑長度,short[ ]表示完整圖上各點相對起點的最短距離。設 s 為起始點。
②命題:當演算法進行至第 k 步時,對於vis中每個結點 i,有dis[ i ] = short[ i ]。
③對命題的證明:採用步數歸納法。
當k=1時,vis中只有點s,dis[s]=short[s]=0;
設演算法進行至第 k 步時,命題成立,即dis[k]=short[k],驗證k+1步:
如圖所示,vis點集為圈內所示,虛線為目前vis陣列選擇點組成的最短道路樹。假設用該演算法選擇的是v點,對應最後一次出vis的點是u,可知dis[v]在vis外為最小,但是k+1步演算法正確性不可知,不能確定選擇v點最優,此類證明中大致都套用反證的思想,不妨假設存在一條新的到v點的道路(綠色箭頭標出),使得dis[v]取得最小值,那麼此時k+1步選擇的不是v,我們假設為y點,對應最後一次出vis點也會隨之變化,也不妨假設為x點,由前述條件顯然dis[y]>dis[v],那麼dis[y]+Edge(y,v)>dis[v]同樣成立,此時不能保證dis[v]最小,故第k+1步選擇的必定是v點,證畢。
④時間複雜度分析:初始化dis陣列花費O(n),對於當前訪問結點與所有未訪問點間的比較(更新dis值的那部分),vis每次加入一個點,共比較(n-1)+(n-2)+...+2+1=O(n2),綜合兩部分,時間複雜度為O(n2)。
=============2022-02-07,21:22:57,才疏學淺,多有疏漏,隨緣更新============
Dijkstra演算法的弊端在於解決不了負邊權的問題,這裡與大家一起考慮一下,如果存在負邊權,那麼當目前所訪問的結點正好與該邊有關的話,與該邊關聯的另一個結點必定會更新dis值,並且值會比當前訪問節點小;這裡考慮一下我們的貪心策略,每次選擇dis值最小的,後面加入的點的最短道路樹會包含前k步生成的最短道路樹(這個在上文證明過程中有所體現),後選的結點dis值會更大,但負邊權會讓dis值不增反減,就不滿足dis小的先選的貪心策略,故不可使用Dijkstra演算法。
注:真正遇到說明Dijkstra演算法侷限性時請舉例說明!
接下來引入的演算法可以解決負邊權問題,因為它不是一次選一個點,固定部分子圖結構,而是每次考慮所有可能更新dis值的情況,通過鬆弛操作收斂於正確值。
2.貝爾曼-福特(Bellman-Ford)演算法:
Bellman-Ford演算法的核心在於鬆弛操作,其實鬆弛操作與Dijkstra更新dis值的判斷條件無異,區別在於執行物件與次數,下面介紹鬆弛操作。
鬆弛操作:
我們一般稱對圖上的某邊進行鬆弛操作,記該邊Edge(u,v),u為起點,v為終點,邊鬆弛操作即是判斷經過該邊是否更短,即dis值更小。
這一塊程式碼反而更容易理解:
void Relax(u,v){//Relax方法/函式,就是以u為中間結點判斷uv邊加入後dis[v]值是否更新 if(dis[v]>dis[u]+Edge(u,v)){ dis[v]=dis[u]+Edge(u,v); path[v]=u; } }
Bellman-Ford演算法就是對每一條邊進行鬆弛操作,重複n-1次,得到更新後的dis值;最後再對每一條邊執行鬆弛操作判斷是否存在總長為負值的環路,並將該環上及該環可達的結點dis值修改為 -∞ 即可,也可直接讓該演算法返回False。
步驟:
1)選擇起點並初始畫圖,dis值初始化與Dijkstra一樣,可見DIjkstra例項表格第0步
2)n-1次重複對每條邊進行鬆弛操作,這裡使用虛擬碼說明:
1 for i=1 to n-1: 2 for each Edge(u,v) in Graph: 3 Relax(u,v)//可直接替換為鬆弛相應程式碼
3)負權環的檢查與判斷:
1 for each Edge(u,v) in Graph: 2 if dis[v]>dis[u]+Edge(u,v)://判斷條件與鬆弛操作相同 3 return FALSE //執行的操作不同
例項1(以Dijkstra中使用栗子為例):
step1:Initialize 初始化
為方便起見,之後把記錄表稱為備忘錄。
a為起點的距離記錄表 | dis[a] | dis[b] | dis[c] | dis[d] | dis[e] | dis[f] |
a -> others | 0 | ∞ | ∞ | ∞ | ∞ | ∞ |
step2:First Relax of the Graph 整個圖的第一次鬆弛
要點:1.每次鬆弛一條邊,對每一條邊都要進行一次鬆弛操作,順序可自定
2.dis值的更新可以在當前備忘錄基礎上改,這樣每次鬆弛操作的結果會有所不同,但整個演算法結束後會得到相同的結果
3.第2點的操作完全正確,但為了方便起見,我們每次更新dis時只使用上一個step的備忘錄,也就是把更新的值記錄到新的備忘錄中,當整個圖遍歷完成後生成新的備忘錄
不妨執行一次step2試試:
以初始的備忘錄為參照,假設鬆弛順序為 ab,ba,ac,ca,bc,cb,bd,db,cd,dc,ce,ec,de,ed,df,fd,ef,fe
其中只有ab,ac的鬆弛更新了dis值:
①ab(a到b,對應Relax中的u,v):dis[b]=+∞,dis[a]+Edge(a,b)=0+5=5;得dis[b]>dis[a]+Edge(a,b),更新dis[b]對應值於下表。進行下一條邊鬆弛時依然用初始的備忘錄,更新資料只對下一個step有效,以此類推。
②ba(b到a,對應Relax中的u,v):dis[a]=0,dis[b]+Edge(b,a)=+∞+5=+∞;得dis[a]<dis[b]+Edge(b,a),不滿足更新條件,dis[a]值不變,寫入新表。進行下一條邊鬆弛時依然用初始的備忘錄,新備忘錄資料只對下一個step有效,以此類推。
③ac(a到c,對應Relax中的u,v):dis[c]=+∞,dis[a]+Edge(a,c)=0+3=3;得dis[c]>dis[a]+Edge(a,c),更新dis[c]對應值於下表。進行下一條邊鬆弛時依然用初始的備忘錄,更新資料只對下一個step有效,以此類推。
④ca之後就不作詳細分析,step2執行一次後生成的新備忘錄如下表所示:
a為起點的距離記錄表 | dis[a] | dis[b] | dis[c] | dis[d] | dis[e] | dis[f] |
a -> others | 0 | 5 | 3 | ∞ | ∞ | ∞ |
step3:Loop 重複step2直至step2執行n-1次,生成第n個備忘錄作為新備忘錄。
step4:Check Negative Circle 判斷負權環
要點:對圖的每一條邊進行與鬆弛操作等同的條件判斷,滿足條件返回FALSE;換句話說,就是再執行一次step2,如果還有dis值更新(滿足更新條件判斷),就返回FALSE
本例最終結果如下表所示,結果與Dijkstra演算法一樣,不存在負權環,返回TRUE。
a為起點的距離記錄表 | dis[a] | dis[b] | dis[c] | dis[d] | dis[e] | dis[f] |
a -> others | 0 | 4 | 3 | 5 | 8 | 9 |
例項2(為了理解記憶的極簡例子):
圖片來源於百度百科,個人覺得不錯就借用了。
第1行初始化,2,3,4,5行執行了4次例一中的step2。
可見每對圖的所有邊鬆弛一次,相當於在上一個step2的基礎上多走一步,即累計執行k次step2,新備忘錄記錄的是從起點到其他點最多走k步(經過k條邊)所得到的dis的最小值。
上圖:第2行最多可走1步,故只有B可達,對應的dis值更新
第3行最多可走2步,或是說在第2行的基礎上再走一步,故只有B、C可達,對應的dis值更新
第4行最多可走3步,第5行最多可走4步
對於頂點數為n的連通圖來說,當可行步數為n-1時,可以保證經過最短路徑走到所有的點;對於有向圖來說,則是保證走到所有可達點。
大多數圖在不到n-1步時備忘錄就不變了,因為深度不到n-1,不需要走這麼多步便可遍歷完所有非環路徑,本例反而是特殊。
演算法分析:
有了例項2的理解基礎,下面內容應該也不難掌握。
我們發現Bellman-Ford演算法從走1步出發到n-1步,每一次都在上一步的基礎上不做選擇對每條邊鬆弛,使得步數深度+1,生成新的備忘錄。
原問題根據步數劃分為若干子問題,並且始終記錄下所有最短路徑狀態給下一步提供重要依據,這種依據就是我們說的備忘錄。具有上述特點的Bellman-Ford演算法屬於動態規劃,而動態規劃問題最核心的狀態轉移方程要求我們寫出:
dis[v]=min(dis[v],dis[u]+Edge(u,v) ) ------ for each edge u->v
path[v]=u ------ if dis[v]>dis[u]+Edge(u,v)
此外,對於負權環為何可用鬆弛條件判斷不做證明,就簡單地理解一下:
已知執行n-1步時我們已經可以畫出最短道路樹,n個頂點,n-1條邊,當再執行一遍時就可找到走n步最短的dis值,此時若某點的dis值更新,說明該點的前驅改變,此時圖中必定有環路產生,並且說明經過該環路這點dis值會變小,是負權環,走∞次該環,相應dis值為-∞。
=============2022-02-11,16:36:58,未完待續……============
路由選擇問題上的應用:
前言:路由選擇問題分為演算法與協議兩部分,一種協議與一種演算法匹配,一定要懂得對號入座。
1.路由選擇演算法(繁瑣的分類是在演算法部分):
我們所接觸的以下兩種演算法,都屬於動態路由演算法,並且都是負載遲鈍的,至於第三種分類方式,LS是全域性式,DV是分散式路由選擇演算法。
1)鏈路狀態(Link State,LS)演算法:
a.對應OSPF協議
b.全域性式路由選擇演算法,全域性網路拓撲結構已知(就是整個圖的資料結構)
c.使用Dijkstra演算法,詳情見演算法介紹部分
2)距離向量(Distance-Vector,DV)演算法:
a.對應RIP協議
b.分散式路由選擇演算法,每個點維護本地路由表,資訊只能從鄰居結點獲取(就是根據鄰居的路由表計算更新本地路由表)
c.使用Bellman-Ford演算法,詳情見演算法介紹部分
2.路由選擇協議(與路由選擇演算法對應):
1)開放最短路優先協議 (Open Shortest Path First,OSPF):
內部閘道器協議,基於LS演算法,適用於大型網路。
2)路由選擇資訊協議 (Routing Information Protocol,RIP):
內部閘道器協議,基於D-V演算法,適用於小型網路。
3.練手例題:
1)利用Dijkstra演算法找到A到其它點的最短路徑及距離
Answer:
step | visited | dis(B),path(B) | dis(C),path(C) | dis(D),path(D) | dis(E),path(E) | dis(F),path(F) |
1 | A | 3,A(choose B) | 5,A | ∞ | ∞ | ∞ |
2 | AB | 4,B(choose C) | 5,B | 5,B | ∞ | |
3 | ABC | 5,B(choose D) | 5,B | ∞ | ||
4 | ABCD | 5,B(choose E) | ∞ | |||
5 | ABCDE | 7,E(choose F) | ||||
6 | ABCDEF |
每列標有choose的為選定結點,對應值已不再更新,因此下文length值為choose結點dis值,路徑只需根據path值不斷找前驅即可。
A->B: length=3
A->B->C: length=4
A->B->D: length=5
A->B->E: length=5
A->B->E->F: length=7
2)利用Distance Vector(D-V)演算法找到A到其它點的最短路徑及距離
注:此為距離向量法的第一次完整運算,因此寫得比較詳細,具體方法步驟參考Bellman-Ford演算法部分例項一
超詳細解法:
已知該路由選擇演算法每個結點都會維護一張自己的路由表,由題,要求起點為A,因此迭代完成後只需要取A點的路由表即可。
這裡把各路由表分開解釋(以行為單位儲存各結點路由表更新資料):
這裡有幾點要事先說明:上圖僅用來說明,自己熟練後解題有更快的書寫方法;最初儲存的路由表應該是深度為1的路由表而不是初始化表,因為初始化表不能完整地表示整個網路的抽象圖資料結構(自己試試用第一行能畫出來什麼,You can try try),可以理解為沒有初始化那一步;紅字表示資料更新
以A表為例:
初始表如圖所示,此時深度為1,僅能從A點走1步:
若要使其深度+1需要進行鬆弛操作,而在分散式網路中沒有儲存各邊費用的集合,Bellman-Ford演算法中的For each Edge(u,v)需要有所改變,因此我們只能根據從鄰居結點獲取的資料進行更新操作。
A有B、C、D三個鄰居,分別獲取它們的路由表:
然後分別進行計算,
對於表B(即經過B點),先看AB距離為2(見表A),再看B到其他點的距離(見表B),若距離和小於表A中所儲存的值,更新A中相應值,並把相應path值更新為B。AB+BC=2+7=9>AC=3,值不變;AB+BD=2+∞>AD=6,值不變。
接著看錶C,先看AC距離為3(見表A),再看C到其他點的距離(見表C)。比較AC+CB與AB;AC+CD與AD;可見AC+CD=3+2=5<AD=6,更新表A中的AC值,並設定path[C]=B(C的前驅是B)。
接著看錶D,方法同上。
此時表A為深度=2的狀態,如圖
對於表BCD深度為2的狀態更新方法同A (每個鄰居的路由表都要進行判斷),B獲取的是AC的路由表,C獲取的是ABD的路由表,D獲取的是AC的路由表;BD不是鄰居,不能獲取對方的路由表。
迭代至深度為n-1(n為結點數),以達到圖的最大深度,深度為n的迭代為判斷負權環的操作,一般給的題目不會有負權環,因此路由表不發生變化,避免扣細節分我們選擇迭代到深度n,關於迭代次數確定與負權環判斷原理請見B-F演算法例項二和演算法分析部分。
更快地手撕寫法:
如果我們把每個結點的路由表整合在一張n階矩陣內可以簡化書寫,每生成一個新的矩陣深度+1,如圖
這裡根據深度引入一個快速解題法,depth=k表示從每個點至多走k步到其他點,矩陣中的值就是在這個前提下所有路徑中的最小值,一般D-V演算法的題目結點數都不會太多,甚至是沒有doge~,總之以程式化的方式走演算法是真心不如直接看來得快。
小插曲:
以上所有內容都能清晰理解記憶,要是不能秒解題目請親自提刀上門。
==============2022-03-22,14:04:56,完==============