1. 程式人生 > 其它 >【淺談】 單元最短路徑兩種演算法 & 在路由選擇中的應用

【淺談】 單元最短路徑兩種演算法 & 在路由選擇中的應用

  • 寫在前面:因為能力和記憶有限,為方便以後查閱,特寫看上去 “不太正經” 的隨筆。隨筆有 “三” 隨:隨便寫寫;隨時看看;隨意理解。

 

注:本篇文章涉及資料結構(圖)離散數學演算法計算機網路相關知識,但都只為加深印象淺層剖析,讀者可根據自身情況選擇閱讀,若求甚解,勿往下讀,以免浪費時間

 

不知道讀者聽說過的是哪個版本:單源最短路徑,最短道路樹,兩結點間的最短路徑;總的來說,沒什麼區別,注意與最小生成樹 (也稱最小支撐樹或最小生成/支撐子圖) 區分即可。

 

 

 幾種演算法:

   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,完==============