看完就懂了!一篇搞定圖論最短路徑問題


看完就懂了!一篇搞定圖論最短路徑問題


最最原始的問題——兩點間的最短路

這類背景一般是類似:已知各城市之間距離,請給出從城市A到城市B的最短行車方案 or 各城市距離一致,給出需要最少中轉方案。

也就是,固定起始點的情況下,求最短路

這個問題用簡單的搜索就能輕松解決。(本部分內容不涉及圖論算法,可跳過)

假設用鄰接矩陣存圖,就比如下面這個例子:

深度優先搜索(dfs)的做法:

void dfs(int cur, int dis) //cur-當前所在城市編號,dis-當前已走過的路徑
{
    if(dis > min) return; //若當前路徑已比之前找到的最短路大,沒必要繼續嘗試(一個小優化,可以不寫)
    if(cur == n) //當前已到達目的城市,更新min
    {
        if(dis < min) min = dis;
        return;
    }
    
    for(int i = 1; i <= n; i++) //對1~n號城市依次嘗試
    {
        if(e[cur][i] != INF && book[i] == 0) //若cur與i可達,且i沒有在已走過的路徑中
        {
            book[i] = 1; //標記i為已在路徑中
            dfs(i, dis+e[cur][i]); //繼續搜索
            book[i] = 0; //對從i出發的路徑探索完畢,取消標記
        }
    }
}


順帶插播一下如何理解DFS算法,它的關鍵思想僅在於解決當下該如何做。至於“下一步如何做”則與“當下該如何做”是一樣的,把參數改為進入下一步的值再調用一下dfs()即可。

而在寫dfs函數的時候就只要解決當在第step的時候你該怎么辦,通常就是把每一種可能都去嘗試一遍。當前這一步解決后便進入下一步dfs(step+1),剩下的事情就不用管它了。

基本模型:

void dfs(int step)
{
    判斷邊界
    嘗試每一種可能 for(int i = 1; i <= n; i++)
    {
        繼續下一步 dfs(step+1)
    }
}


但對於所有邊權相同的情況,用廣度優先搜索會更快更方便。

比如上面提到的最少中轉方案問題,問從城市1到城市4需要經過的最少中轉城市個數。

用廣搜的做法:

int bfs()
{
    queue<pair<int,int>> que; //pair記錄城市編號和dis,也可以用結構體
    que.push({1,0}); //把起始點加入隊列
    book[1] = 1; //標記為已在路徑中
    while(!que.empty()) 
    {
        int cur = que.front();
        que.pop();
        for(int i = 1; i <= n; i++)
        {
            if(e[cur][i] != MAX && book[i] == 0) //若從cur到i可達且i不在隊列中,i入隊
            {
                que.push({i, cur.second+1});
                book[i] = 1;
                if(i == n) return cur.second; //如果已擴展出目標結點了,返回中轉城市數答案
            }
        }
    }
}

以上都是開胃,下面才是真的重點來了~


膨脹——任意兩點間的最短路

已經知道了求解固定兩點間的最短路,那要怎么求任意兩點間的最短路呢?顯然,可以進行n^2次的dfs或bfs輕松搞定(被打)。

觀察會發現,如果要讓兩點 i , j 間的路程變短,只能通過第三個點 k 的中轉。比如上面第一張圖,從 1->5 距離為10,但 1->2->5 距離變成9了。事實上,每個頂點都有可能使另外兩個頂點間的路程變短。這種通過中轉變短的操作叫做松弛。

當任意兩點間不允許經過第三個點時,這些城市之間的最短路程就是初始路程:

假如現在允許經過1號頂點的中轉,求任意兩點間的最短路,這時候就可以遍歷每一對頂點,試試看通過1號能不能縮短他們的距離。

for(int i = 1; i <= n; i++)
    for(int j = 1; j <= n; j++)
    {
        if(e[i][j] > e[i][1]+e[1][j]) e[i][j] = e[i][1]+e[1][j];
    }

更新后果然有好幾條變短了:

擴展一下,先允許1號頂點作為中轉給所有兩兩松弛一波,再允許2號、3號...n號都做一遍,就能得到最終任意兩點間的最短路了。

這就是Floyd算法,雖然時間復雜度是令人發怵的O(n^3),但核心代碼只有五行,實現起來非常容易。

for(int k = 1; k <= n; k++)
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= n; j++)
            if(e[i][j] > e[i][k]+e[k][j]) 
                e[i][j] = e[i][k]+e[k][j];

最常見的問題——單源最短路

傳說中如雷貫耳的“單源最短路”應該是做題中最常見到的問題了。也即,指定源點,求它到其余各個結點的最短路

比如給出這張圖,假設把1號結點作為源點。

還是用數組dis來存1號到其余各點的初始路程:

既然是求最短路徑,那先選一個離1號最近的結點,也就是2號結點。這時候,dis[2]=1 就固定了,它就是1到2的最短路徑。這是為啥?因為目前離1號最近的是2號,且這個圖的所有邊都是正數,那就不可能能通過第三個結點中轉使得距離進一步縮短了。因為從1號出發已經找不到哪條路比直接到達2號更短了。

選好了2號結點,現在看看2號的出邊,有2->3和2->4。先討論通過2->3這條邊能否讓1號到3號的路程變短,也即比較dis[3]和dis[2]+e[2][3]的大小。發現是可以的,於是dis[3]從12變為新的更短路10。同理,通過2->4也條邊也更新下dis[4]。

松弛完畢后dis數組變為:

接下來,繼續在剩下的 3 4 5 6 結點中選一個離1號最近的結點。發現當前是4號離1號最近,於是dis[4]確定了下來,然后繼續對4的所有出邊看看能不能做松弛。

balabala,這樣一直做下去直到已經沒有“剩下的”結點,算法結束。

這就是Dijkstra算法,整個算法的基本步驟是:

  1. 所有結點分為兩部分:已確定最短路的結點集合P、未知最短路的結點集合Q。最開始,P中只有源點這一個結點。(可用一個book數組來維護是否在P中)
  2. 在Q中選取一個離源點最近的結點u(dis[u]最小)加入集合P。然后考察u的所有出邊,做松弛操作。
  3. 重復第二步,直到集合Q為空。最終dis數組的值就是源點到所有頂點的最短路。

代碼:

for(int i = 1; i <= n; i++) dis[i] = e[1][i]; //初始化dis為源點到各點的距離
for(int i = 1; i <= n; i++) book[i] = 0; 
book[1] = 1; //初始時P集合中只有源點

for(int i = 1; i <= n-1; i++) //做n-1遍就能把Q遍歷空
{
    int min = INF;
    int u;
    for(int j = 1; j <= n; j++) //尋找Q中最近的結點
    {
        if(book[j] == 0 && dis[j] < min)
        {
            min = dis[j];
            u = j;
        }
    }
    book[u] = 1; //加入到P集合
    for(int v = 1; v <= n; v++) //對u的所有出邊進行松弛
    {
        if(e[u][v] < INF) 
        {
            if(dis[v] > dis[u] + e[u][v]) 
                dis[v] = dis[u] + e[u][v];
        }
    }
}

Dijkstra是一種基於貪心策略的算法。每次新擴展一個路徑最短的點,更新與它相鄰的所有點。當所有邊權為正時,由於不會存在一個路程更短的沒擴展過的點,所以這個點的路程就確定下來了,這保證了算法的正確性。

但也正因為這樣,這個算法不能處理負權邊,因為擴展到負權邊的時候會產生更短的路徑,有可能破壞了已經更新的點路程不會改變的性質。

於是,Bellman-Ford算法華麗麗的出場啦。它不僅可以處理負權邊,而且算法思想優美,且核心代碼只有短短四行。

(用三個數組存邊,第i條邊表示u[i]->v[i],權值為w[i])

for(int k = 1; k <= n-1; k++)
    for(int i = 1; i <= m; i++)
        if(dis[v[i]] > dis[u[i]] + w[i])
            dis[v[i]] = dis[u[i]] + w[i];

后兩行代碼的意思是,看看能否通過u[i]->v[i]這條邊縮短dis[v[i]]。加上第二行的for,也就是把所有的m條邊一個個拎出來,看看能不能縮短dis[v[i]](松弛)。

那把每一條邊松弛一遍后有什么效果呢?

比如求這個例子:

同樣用dis數組來存儲1號到各結點的距離。一開始時只有dis[1]=0,其他初始化為INF。

先來處理第一條邊 2->3 ,然鵝dis[3]是INF,dis[2]+2也是INF,松弛失敗。
第二條邊 1->2 ,dis[2]是INF,dis[1]-3是-3,松弛成功,dis[2]更新為-3。

就這樣對所有邊松弛一遍后的結果如下:

這時候dis[2]和dis[5]的值變小了,如果再做一輪松弛操作的話,之前不成功的松弛這時候也能也就可以起作用了。

換句話說,第一輪松弛后得到的是從1號出發“只能經過1條邊”到達其余各點的最短路,第二輪松弛后得到的是“只能經過2條邊”到達其余各點的最短路,如果進行第k輪松弛得到的就是“只能經過k條邊”到達其余各點的最短路。

那么到底需要進行多少輪呢?答案是n-1輪。因為在一個含有n個頂點的圖中,任意兩點間的最短路最多包含n-1條邊。也就解釋了代碼的第一行,是在進行n-1輪松弛。

完整代碼:

for(int i = 1; i <= n; i++) dis[i] = INF;
dis[1] = 0; //初始化dis數組,只有1號的距離為0

for(int k = 1; k <= n-1; k++) //進行n-1輪松弛
    for(int i = 1; i <= m; i++) //枚舉每一條邊
        if(dis[v[i]] > dis[u[i]] + w[i]) //嘗試進行松弛
            dis[v[i]] = dis[u[i]] + w[i];

此外,Bellman-Ford算法還可以檢測一個圖是否含有負權回路。如果在進行了n-1次松弛之后,仍然存在某個dis[v[i]] > dis[u[i]] + w[i]的情況,還可以繼續成功松弛,那么必然存在回路了(因為正常來講最短路徑包含的邊最多只會有n-1條)。

判斷負權回路也即在上面那段代碼之后加上一行:

for(int i = 1; i <= m; i++) 
    if(dis[v[i]] > dis[u[i]] + w[i]) flag = 1;

Bellman-Ford算法的時間復雜度是O(nm),貌似比Dijkstra還高。事實上還可以進行優化,比如可以加一個bool變量check用來標記數組dis在本輪松弛中是否發生了變化,如果沒有,就可以提前挑出循環。因為是“最多”達到n-1輪,實際情況下經常是早就已經達到最短,沒法繼續成功松弛了。

for(int k = 1; k <= n-1; k++) //進行n-1輪松弛
{
    bool check = 0;
    for(int i = 1; i <= m; i++) //枚舉每一條邊
        if(dis[v[i]] > dis[u[i]] + w[i]) //嘗試進行松弛
        {
            dis[v[i]] = dis[u[i]] + w[i];
            check = 1;
        }
    if(check == 0) break;
}            

另外一種優化是:每次僅對最短路估計值發生了變化的結點的所有出邊進行松弛操作。因為在上面的算法中,每實施一次松弛操作后,就會有一些頂點已經求得最短路之后便不會再改變了(由估計值變為確定值),既然都已經不受后續松弛操作的影響了卻還是每次都要判斷是否需要松弛,就浪費了時間。

可以用隊列來維護dis發生了變化的那些結點。具體操作是:

  1. 初始時將源點加入隊列。
  2. 每次選取隊首結點u,對u的所有出邊進行松弛。假設有一條邊u->v松弛成功了,那就把v加入隊列。然而,同一個結點同時在隊列中出現多次是毫無意義的(可以用一個bool數組來判哪些結點在隊列中)。所以剛提到的操作其實是,如果v不在當前隊列中,才把它加入隊列。
  3. 對u的所有出邊松弛完畢后,u出隊。接下來不斷的取出新的隊首做第2步操作,直到隊列為空。

一個例子:

用數組dis來存放1號結點到各點的最短路。初始時dis[1]為0。接下來將1號結點入隊。

現在看1號的所有出邊,對於1->2,比較dis[2]和dis[1]+e[1][2]的大小,發現松弛成功,dis[2]從INF變為2。並且2不在隊列中,所以2號結點入隊。同理,5號結點也松弛成功,入隊。

1號結點處理完畢,此時將1號出隊,接着對隊首也就是2號結點進行同樣的處理。在處理2->5這條邊的時候,雖然松弛成功,dis[5]從10更新為9了,但5號頂點已經在隊列中,所以5號不能再次入隊。

處理完2號之后就長這樣:

接着一直持續下去,直到隊列為空,算法結束。

代碼:

for(int i = 1; i <= n; i++) book[i] = 0; //初始時都不在隊列中
queue<int> que;
que.push(1); //將結點1加入隊列
book[1] = 1; //並打標記

while(!que.empty())
{
    int cur = que.empty(); //取出隊首
    for(int i = 1; i <= n; i++) 
    {
        if(e[cur][i] != INF && dis[i] > dis[cur]+e[cur][i]) //若cur到i有邊且能夠松弛
        {
            dis[i] = dis[cur]+e[cur][i]; //更新dis[i]
            if(book[i] == 0) //若i不在隊列中則加入隊列
            {
                que.push(i);
                book[i] = 1;
            }
        }
    }
    
    que.pop(); //隊首出隊
    book[cur] = 0;
}

這其實就是SPFA算法(隊列優化的Bellman-Ford),它的關鍵思想就在於:只有那些在前一遍松弛中改變了最短路估計值的結點,才可能引起它們鄰接點最短路估計值發生改變。

它也能夠判斷負權回路:如果某個點進入隊列的次數超過n次,則存在負環。


最短路徑算法的對比



文中的圖片和部分文字來自《啊哈!算法》,博文所做的是整理書的內容和自己的想法。

上面全都用鄰接矩陣存圖是因為注重算法本身,矩陣存圖好理解。但平時打題的時候最愛用鄰接表,幾乎不用鄰接矩陣,因為常見的是稀疏圖,也即邊數 m << n^2,用鄰接表存節省復雜度。請看:圖的存儲結構之鄰接表(詳解)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM