最短路算法分析
如下圖所示,我們把邊帶有權值的圖稱為帶權圖。邊的權值可以理解為兩點之間的距離。一張圖中任意兩點間會有不同的路徑相連。最短路就是指連接兩點的這些路徑中最短的一條。
對於所有求最短路的算法,都是基於一個最基礎的思想,那就是:松弛。
什么叫松弛呢?簡單的說,就是刷新最短路。
那,怎么刷新呢?我們想,能刷新最短路的有啥?就是用最短路(邊也可能是最短路)。要用魔法打敗魔法
以下圖為例,點1到點3的距離是2,點3到點2的距離是1,而圖中1到2的距離是6,那么,很明顯點1到點3到點2比點1直接到點2短得多,那么,我們的最短路dis[1][2]就等於dis[1][3]+dis[3][2]。我們可以這么理解:1到3這條邊的邊權已經是1到3的最短路了,同樣,2到3也是,那我們就可以用這兩條最短路來刷新1到2的最短路,這就是用最短路來松弛最短路了。
那么,基於松弛操作,我們就有了非常多種求最短路的算法,這些算法各有神通,各有缺陷,都應該熟練掌握且能在考場上准確判斷該用那種算法。
1、Floyed:
學過最短路的人,大多數都會認為:Floyed就是暴力,簡單粗暴,簡單得很。其實,如果你仔細品讀這個算法,會發現:雖然代碼簡單,但是其思想卻非常的巧妙!
我們來分析一下:
我們要求最短路,我們需要通過中轉點來進行松弛,那我們要怎么找中轉點呢?
顯然,必須得枚舉。那,怎么枚舉呢?我們自己畫畫圖就知道,一些最短路可以拐來拐去,也就是中轉點有很多,那我們怎么枚舉?這就是Floyed的巧妙之處,它用到的是動態規划的思想。
對於規模很大的問題,我們的一般策略應該是:縮小規模。不管它可能有多少個,我們慢慢從少往多推。
假設我們現在不允許有中轉點,那么各個點之間的最短路應該是題目給出來的邊權。
現在我們只允許通過1號節點來進行轉移,注意是1號而不是1個,如果討論個數的話其實就回到了我們上面的問題:“怎么枚舉?”。現在我們只需要枚舉i和j,比較dis[i][1]+dis[1][j]和dis[i][j]的大小,如果小於則刷新,這就是松弛。注意這里i不一定小於j,我們現在的對象是圖,圖描述的是元素間的網狀關系,不同於順序表描述的順序關系。
接着,我們允許通過1號點和2號點進行轉移,注意這里的“和”,一方面,2號點本身可以獨自松弛一些路徑,另一方面,之前通過1號點松弛的一些路徑中,可能有和2號點連着的,什么意思呢?比如說,在上一輪我們用dis[5][1]+dis[1][2]松弛了dis[5][2],然后我們再用這個路徑去松弛其他路徑,實際上就相當於通過1號點和2號點進行轉移,所以我們的條件是“允許通過1號點和2號點”,這也就解決了我們的問題:“怎么找中轉點?”。不必考慮中轉點的數量,用編號更大的點做中轉點時,實際上也包含了編號小的中轉點。
所以,這里我們仍然只需要枚舉i和j,用dis[i][2]+dis[2][j]去嘗試松弛dis[i][j]就行了,這里的中轉點我們可以用k來表示。
所以,我們需要三重循環:第一重枚舉中轉點,k=1~n,第二重就是起點i,也是從1~n,第三重終點j也是一樣的,這里的k循環一定要在最外層,如果不在最外層,就有可能將k號點可以松弛的邊漏掉,影響結果。在循環最里面判斷dis[i][k]+dis[k][j]是否比dis[i][j]來的優,是的話則更新,這就是Floyed算法的實現。
核心代碼:
for(k=1;k<=n;k++)
for(i=1;i<=n;i++)
for(j=1;j<=n;j++)
if(dis[i][j]>dis[i][k]+dis[k][j])
dis[i][j]=dis[i][k]+dis[k][j];
根據這種算法,我們還需注意初始化的問題:題目沒有給出連邊的兩個點應該初始化為無窮大,這樣才能滿足我們的判斷條件,另外一個點到它本身的距離應該初始化為0,也是為了滿足判斷條件,因為中轉點可能在起點或終點上。
Floyed的時間復雜度很明顯:就是O(n3),它的功能非常強大,根據代碼我們可以發現:它可以求出任意兩點之間的最短路,而且碼量極小,可以處理所有情況的邊權,但有一點:它無法處理負環,這個我們將結合Bellman-Ford一起說。它的劣勢也十分明顯:時間復雜度太高,只能處理n極小的圖。那我們能不能進行優化呢?
答案是不行。所謂的優化,其實就是減少沒必要的操作,但是對於這個算法,你可以在中間某一層循環結束后結束算法嗎?不可以。我們的Floyed的松弛操作是通過枚舉進行的,也就是無法保證最優,那能不能判斷一下,當前這個k循環里有沒有進行過松弛,如果沒進行過就退出呢?
仔細想一想,這是一張圖,你當前這個點沒有用,不代表它后面的點也沒有用,這只是編號的大小順序而已,所以,無法優化。那,n如果稍微大一點,不就炸掉了?所以,我們來講另外一種算法。
2、Dijkstra:
我們上面提到,Floyed慢的原因是它無法保證求得的最短路一定是最優的,那我們能不能保證每次求得最短路是最優的呢?肯定可以,但是,這時候我們的求的只能是一個點到其它點的最短路,因為你無法保證每一個點都能在某一輪找到與別的點的最短路,這就是Floyed的問題。求一個點到其它點的最短路的問題,稱為單源最短路問題。Floyed處理的就是多源最短路問題。順便說一下,Floyed是處理多源最短路的最優算法,也是我們的不二選擇。所以,以下的算法都是求單源最短路的算法。
有了這樣分析的基礎,我們不難想到:用dis[i]記錄i到源點(我們不妨稱為x)的最短路,那么一開始,我們得到了許多由x連出的邊,那我們想要得到一個不會再改變的最短路,也就是一個確定的dis[i],我們怎么做?這里就要用到貪心的策略。不再改變,意味着無法進行松弛,那什么樣的情況下dis[i]無法再進行松弛了呢?那就是不存在一個k使得dis[k]+l[k][i](l表示邊長)<dis[i],也就是說,沒有點可以轉移這條路徑,那說明這個i到x的距離一定是最短的,也就是dis[i]是所有dis[]中最小的,這樣一定能保證這條路徑是最短的,所以,我們需要找到一個dis[i]最小的點i,那么它到x的最短路已經確定了,我們拿它來進行松弛。這樣,每一輪都可以確定一個點到源點x的最短路,只要經過n-1輪,我們就得到了任意一點到源點的最短路。
另外還要注意,我們已經求得最短路的點,我們還有必要再去管它嗎?顯然沒有。也就是說,在找點的時候,我們要找的應該是還沒有求得最短路的點,不然如果你這個點的最短路是全圖最短路里最短的,那不是一直選你,然后做無用的重復判斷?所以,我們還需要一個數組f[]來記錄點i是否已求得最短路,f[i]=1表示已求得,f[i]=0表示未求得。
那么,實現的話,我們需要一層i來枚舉還剩下的點數,i=1~n-1,里面要有一個j=1~n來找離源點最近的點t,然后標記f[t],再用t來嘗試進行松弛操作,還需要一次j循環。
核心代碼:
for(i=1;i<=n-1;i++)
{
int t,minn=100000001;
for(j=1;j<=n;j++)
if(f[i]==0&&dis[j]<minn)
{
minn=dis[j];
t=j;
}
f[t]=1;
for(j=first[t];j;j=a[j].next)
if(dis[a[j].to]>dis[t]+a[j].len)
dis[a[j].to]=dis[t]+a[j].len;
}
這里采用鄰接表來存儲,其優點不必闡述。另外,這里f[]要先初始化為0,然后dis[]初始化為無窮大,很好理解,另外要在題目給出的邊中先初始化一些源點有連邊的dis[i]=l[x][i]。f[x]=1,因為源點到本身的距離已經確定,也就帶來了dis[x]=0。
總共要進行n-1次循環,時間復雜度為O(n),內部又有找點的操作,時間復雜度為O(n),所以Dijkstra算法的時間復雜度為O(n2),比Floyed快飛了。
但實際上,Dijkstra只有比Floyed快這一優勢,而且還不是因為它算法比較優秀,只是因為它減少了起點的枚舉。而且,它只能求單源最短路,而且還不能處理負邊權和負環。什么是負邊權呢?負邊權,顧名思義就是邊權為負,那為什么Dijkstra不能處理呢?Dijkstra的策略是每次保證求得一條最短路,那你現在有了負邊權,就無法保證我找到的距離源點最近的點一定是最近的了,那這個算法就廢了。Floyed就可以解決這樣的問題,因為它不管你的邊權是什么樣的,只管算就好了,不像Dijkstra有那樣的要求,它只要保證最優子結構,你邊權的正負並不會影響它的最優。
當然,這個算法還是可以優化的。我們要找一個距離源點最近的,那我們一定要一個一個去找嗎?我們看到,這個最近的值是在一個區間里被挑出來的,那我們不難想到用堆來維護這個區間。這個堆的關鍵字是點到源點的距離,但是我們還要記點的編號,因為一會兒還要用點來進行松弛。取堆頂的點,用這個點嘗試一波松弛,在這過程中,我們如果發現了被松弛的路徑,那么這個終點就要入堆,因為被松弛意味着它到源點的距離變了,我們當然要把它入堆去和其它的點作比較。那么這種情況下,我們的初始化就只要將源點和0入堆就行了,不需要額外做dis[i]=w[x][i],因為現在dis[i]是可以通過dis[x]+w[x][i]算出來的。
這里會出現一個點在堆中出現多次的問題,不必擔心,這並不會影響我們的結果。因為堆中雖然有相同點,但堆中存儲的它們到源點的距離一定各不相同,那么在進行松弛時,選的一定是最優的那一條路徑,也就是dis最小的那一個,那么剩下就是多余的,會隨着不斷的取堆頂的操作一個個被清除,不必再單獨考慮。
核心代碼:
struct node
{
int id,len;
};//堆的類型
node heap[1000001];
//堆要開大一點,一點可能在堆里重復出現
void put(node x)//入堆
//入堆的元素要和堆保持同一類型
{
int now,next;
node t;
heap[++top]=(node)x;
now=top;
while(now>1)
{
next=now/2;
if(heap[next].cnt<=heap[now].cnt)return;
t=(node)heap[next];
heap[next]=(node)heap[now];
heap[now]=(node)t;
now=next;
}
}
int get()//取堆頂元素的編號並刪除
{
int now,next,x;
node t;
x=heap[1].id;
heap[1]=(node)heap[top--];
now=1;
while(now*2<=top)
{
next=now*2;
if(heap[next].cnt>heap[next+1].cnt&&next<top)next++;
if(heap[now].cnt<=heap[next].cnt)break;
t=(node)heap[now];
heap[now]=(node)heap[next];
heap[next]=(node)t;
now=next;
}
return x;
}
void add()......//存邊
void dijkstra()
{
int i;
for(i=1;i<=n;i++)dis[i]=1000000001;
dis[A]=0;
top=1;
heap[1].id=A;
heap[1].cnt=dis[A];
while(top!=0)
{
int t=get();
for(i=first[t];i;i=a[i].last)
{
if(dis[a[i].to]>dis[t]+a[i].len)
{
dis[a[i].to]=dis[t]+a[i].len;
put((node){a[i].to,dis[a[i].to]});
}
}
}
}
3、Bellman-Ford:
我們上面一直着重於找一個中轉點來進行松弛,我們能不能不管中轉點呢?當然可以。我們之前的策略是,找一個中轉點,然后用它連的邊實現松弛。我們現在不用中轉點,那就簡單點,直接找邊,那我怎么知道要選哪條邊?實際上你也無法知道。因為這不滿足最優子結構。那,我們不妨把所有邊的試一遍,實際上這不能稱之為“邊”,而應該稱其為“路徑”,你不可能拿幾條邊在那里一直玩,這里需要呈現出一個結構,一條條的邊組成了路徑。我們每次拿我們已知的可能的最短路徑dis[k]來進行松弛:如果dis[k]+w[k][j]<dis[j],刷新dis[j],k是源點到每一個的最短路徑“估計值”。那,這樣一輪下來,我們可以得到一些新的最短路徑,我們再利用這些最短路徑來松弛,又可以得到新的最短路徑,通過這樣的方式一層一層建立起整個最短路的結構。那,這樣的操作要進行多少輪呢?這就要看一個最短路中最多會包含幾條邊了。最多幾條呢?不難想到,n個點的圖,最短路徑上最多有n-1條邊。我們知道,連接n個點只需要n-1條邊,再多了就會形成環,那環有沒有可能是最短路呢?
不可能。假如我們這張圖不存在負環,那重復走路肯定不優。如果存在負環呢?那我們就可以一直繞圈圈,就不存在最短路了。那,是不是這張圖都沒有最短路了呢?不是!只有可以到達負環的點才不存在最短路。
如圖所示,對於在負環上及可以到達負環的點①②③④,都不存在最短路徑,但是⑤卻有一個最短路徑:⑤->⑥的最短路徑為2。為什么說④也沒有最短路徑呢?因為它可以到達負環,那它走的所有路徑都可以先去負環里面多轉幾圈,只要進了負環里,就沒有最短路了。因此,只要存在最短路徑,那么最多經過n-1輪,一定可以求出所有的最短路徑。
核心代碼:
for(i=1;i<=n-1;i++)
for(k=1;k<=n;k++)
if(dis[u[i]]+w[i]<dis[v[i]])
dis[v[i]]=dis[u[i]]+w[i];
這里因為主要涉及對單個邊的操作,我們采用少見的邊集數組,記錄單個邊的信息。i表示當前邊的編號,u[i]表示這條邊的起點,v[i]表示這條邊的終點,w[i]表示這條邊的權值(邊權)。那么,剛才講了什么事負環,還沒講怎么處理負環。Bellman-Ford處理負環非常簡單,只要在n-1輪結束后判斷一下:是否還能再進行松弛操作,也就是判斷是否還有dis[v[i]]>dis[u[i]]+w[i],如果有,那就說明這個源點會到到達一個負環,那對於這個源點就不存在最短路了。實際上,到了提高組,我們需要對負環做出處理,這就需要用到我們的Bellman-Ford來實現了,這里不必闡述。
那,Floyed為什么沒法處理負環呢?實際上,Floyed也可以處理一些負環,關鍵是這個點必須在負環上。用Floyed我們可以直接表示環,就是dis[i][i],如果dis[i][i]<0,我們知道,Floyed的dis[i][i]初始化都是為0,那就說明,出現了負環,且這個點就在負環上。但是,如果這個點不在負環上,但它可到達負環,那Floyed就無法識別了,因為如果這個點不在負環上的話,它可以到負環,但是反過來,負環不一定可以到這個點,如果我的邊是單向的,那不可能找到一個中轉點來實現這個點到負環的最短路的松弛,因為從負環到這個點的長度是無限大,那這個dis[i][i]就是恆為0。當然,如果這張圖是無向圖,那Floyed也是可以處理負環的。
小優化:其實不難發現,有時候我們並不需要經過n-1輪才能求得結果,所以,我們可以在一輪結束以后判斷:當前這一輪進行松弛,如果有,那才有繼續下一輪的必要,如果沒有,那就可以提前退出了,因為你已經沒有任何一條路徑可以再進行松弛了,你再下一輪只是再進行一論完全相同的操作,實在是沒有必要。
4、SPFA:
根據我們上面的小優化,我們其實可以再進行一個大的優化。小優化說的是,出現被松弛過的路徑,才有松弛成功的可能。如果一條路徑已經無法松弛,那對於這個算法而言,這條路徑就沒用了。那么,我們怎么判斷哪些最短路徑被松弛過了呢?這樣就不能看邊了,而應該看點。如果dis[i]刷新了,那么說明這個i可以為我們接下來的松弛提供幫助,我們應該將它記錄起來。一個點是可以松弛很多路徑的,也就是會拓展出很多點,然后我們取出記錄的第一個點,用這個點繼續重復上面的操作:判斷dis[i]是否大於dis[t]+w[t][i],這里的t代表松弛的中轉點,然后繼續拓展節點,記錄,最后把當前進行松弛的點刪掉。取第一個,拓展,刪除第一個,再取第一個,拓展,再刪除,我們可以用隊列來實現這一過程,因此SPFA也被稱為是Bellman-Ford的隊列優化。
還要注意一點,這個隊列里的元素是可以重復入隊的,因為一個點的最短路徑可以不斷更新。另外,當一個點已經在隊列里的時候,我們是不用將它入隊的,因為我們記錄這個點的目的是等會要使用它,所以我們記的是它的編號,這里一定要和Dijkstra堆優化分清楚,Dijkstra需要的是它的路徑長度,我們這里不需要,所以就算這個點的最短路徑中間可能會變,但是不影響我們的結果,我們只需要記住這個點可能可以作為松弛的中轉點,也就是我們可以用它進行松弛,就夠了。
核心代碼:
int head=0,tail=1;
q[head]=x;
f[x]=1;
dis[x]=0;
while(head<tail)
{
int i,t=q[head];
for(i=first[t];i;i=a[i].next)
if(dis[a[i].to]>dis[t]+a[i].len)
{
dis[a[i].to]=dis[t]+a[i].len;
if(f[a[i].to]==0)q[++tail]=a[i].to;
}
f[q[head]]=0;
head++;
}
初始化的時候,我們需要將x入q數組,還要給x打上標記。對於dis[],依舊不需要初始化dis[i],但我們需要初始化dis[x]=0,不然我們無法用x進行松弛。至於為什么出隊要放后面,這個是根據代碼來定的,我們一開始的x存在q[head]里,所以要先處理完再出隊,如果一開始x存在q[tail]里,那這個出隊操作就要放在最前面了。
既然SPFA是Bellman-Ford的優化,那么Bellman-Ford能實現的功能SPFA也都能實現。負的邊權正常算,如何判斷負環呢?一條最短路徑上的邊不會超過n-1條,反之,一條最短路徑上的點也不會超過n個,所以,我們可以用一個count數組來記錄元店到結點的最短路上結點的數量,如果一個點到源點的最短路上結點的數目大於n個,說明出現了環,這個環一定是負環。這種方法在我們用隊列實現SPFA時使用。
什么?難道還能用其他方法實現嗎?SPFA的過程,說白了實際上是一個搜索的過程。為什么這樣說呢,我們每一次取一個被松弛的點,用這個點松弛其他的點,再用松弛成功的點進行同樣的操作,很想一個點分支出來很多點,每一個分支點有分支出來很多點,而我們求最短路的過程實際上就是將這些點按順序全部訪問一遍的過程,這不就是搜索嗎?那么,我們的搜索有深搜和廣搜,用隊列實現的是廣搜,那么另一種自然是用棧實現的深搜了,只不過我們常用廣搜來實現。
深搜的過程自己腦海里走一遍就好了,現在關鍵在於:我們如何判斷負環?我們知道,深搜有一個非常關鍵的步驟:標記。那么按照常理,我們在搜索的時候遇到被標記過的結點應該忽略,但在這里,如果我們遇到被標記過的結點,說明這個點當前最短路上出現了不止一次,這就出現了負環。這種方法不常用,但我們需要了解一下,懂得其中牽扯到的過往的知識。
以上兩種方法請自己嘗試實現,如有困難可以參考SPFA判負環
一些教材上有寫道一種判斷的方法:如果一個點入隊次數超過n,那么存在負環。這種方法請大家不要使用!我們首先來分析一下:為什么要找入隊次數呢?這一點我很不理解。我們入隊的目的是為了下一次取其中的點來進行松弛操作,入隊的次數是沒有任何意義的!可能有的同學會這么認為:入隊就是被松弛了,那被松弛了就會入隊。這種想法存在兩個問題:我們不管入隊的問題,一個點被松弛,肯定是由其它的點來進行的,可不可以是它本身呢?一般來說,一個點到自己的距離都為0,這樣肯定是松弛不了的,有一些題目,它可能輸入一條自己到自己的邊,邊權還為負數,這就還可能通過本身來松弛。但是,這種情況下就是負環了!所以我們可以不管這種情況。一般情況下,一個點最多能被n-1個點松弛,但是特殊情況,它可以被n個點松弛,但這種情況就是負環,所以我們可以得出結論:一個點被松弛的次數如果超過n-1,就存在負環。但是,這里又有問題了,我可以有重邊啊!那一個點可能被同一個點松弛了不止一次,這種方法就錯了。
另外,一個點被松弛了,不代表它就會入隊,因為可能它已經在隊列里了,那這時候它就不能入隊,這又有問題了,所以建議大家不要使用這種方法。
題目:P3385 【模板】負環
各算法的對比: