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

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


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

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

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

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

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

深度優先搜索(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()便可。code

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

基本模型:blog

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,用鄰接表存節省複雜度。請看:圖的存儲結構之鄰接表(詳解)

相關文章
相關標籤/搜索