學習圖論(四)——最短路問題
一、DFS或BFS搜尋(單源最短路徑)
思想:遍歷所有從起點到終點的路徑,選取一條權值最短的路徑。
下面程式碼是參考部落格中的程式碼,加上本人一些註釋
void DFS(int u,int dist) //u 為當前節點; dist 為當前點到起點的距離
{
//min 表示目前起點到終點的最短距離,初始化為 無窮(用一個很大的數去表示)
if(dist>min) //當前距離已經大過目前起點終點的最短距離,沒必要往下
return ;
if(u==dest) //到達目的地
if(min>dist)
{
min =dist;
return ;
}
for(int i=1;i<=n;++i) //遍歷每一條邊,標記點
{
//對於同一個點,edge[i][i] = 0,下面條件中讓 edge[u][i]大於0,是為了防止陷入無限迴圈
//即老是搜尋同一個點。
if(edge[u][i]!=INF&&edge[u][i]&&!vis[i])
{
vis[i]=1;
DFS(i,dist+edge[u][i]); //更新當前距離
vis[i]=0;
}
}
return ;
}
二、弗洛伊德演算法(多源最短路徑)
思想:通過比較得出圖中的任意兩個點,依次通過0個,1個,2個…中介點所形成的路徑中最短的那一條。
一開始如果兩點(U和V)之間的路徑(路徑①)上沒有其他點,那麼這條路徑必定是這兩點過上的最短路徑。現在如果兩點之間有另外一條路徑(路徑②),該路徑上存在第三個點(T),若這條路徑比原來的路徑①短,則將該路徑②更新為U和V之前的最短路徑,而這條路徑②,是由U-T,T-U兩條直接相連的路徑構成。依次往下,當存在一條路徑連線U、T、V且上面有第四個點時,在這條路徑上U到V的距離是否更小?…
void Floyd(int all) //all 共有多少節點
{
//三層迴圈表示的是:從 j 到 k 經過點 i 後 j和k之間的距離有沒有縮小
for(int i=1;i<=all;++i)
for(int j=1;j<=all;++j)
for(int k=1;k<=all;++k)
//最好加入 egde[j][i] 和 egde[i][k] 都小於inf的條件,防止資料爆掉
if(egde[j][k]>egde[j][i]+egde[i][k]) //判斷藉助中轉點後,路程是否縮小
egde[j][k]=egde[j][i]+egde[i][k]; //每個 egde[i][j]都表示當前時刻下 i 到 j 的最短距離
}
使用Floyd演算法後,二維陣列edge表示的邊都是兩點之間的最短路,所以可以輸出任意兩點之間最短的路徑。
三、迪傑斯特拉演算法(單源最短路徑)
思想: 通過 n-1 層迴圈,每一次,尋找距離起點最近的點,做標記(不再訪問),然後用該點作為其他點到起點路徑上的中介,更新其他點到起點之間的距離。即,假設起點為U,中介點為T,圖上任意沒有被標記過的點為V,判斷U-V的距離是否大於U-T-V距離,是的話更新U-V的距離為U-T-V的距離。(用一個一維陣列存放在圖上其他點到起點的距離),這種對兩點直接距離的更新稱為鬆弛操作(記住一些專業名詞對以後的閱讀有好處)。然後再次從未標記的點中尋找和起點最近的點,做同樣的操作,知道遍歷所有的點。最終得到的 dist陣列中存放的是圖上任意點到起點的最短距離。
為什麼可以實現找到最短路?
因為這個演算法先是找到和起點直接相連的距離最短的點,在更新其他點到起點的距離的時候,都是利用已計算過和起點的最短距離的點作為中介點去比較,所以每次得到的必然都是最短距離。
void Dijkstra(int u)
{
int min;//表示某一點到起點的最短距離
int k;// k 記錄為訪問的點中距離起點最近的點
for(int i=1; i<=n; ++i)
{
flag[i]=0;// flag 用於標記節點是否已經訪問過;
//dist 陣列,儲存圖上各點到起點的距離
dist[i]= edge[u][i]; // edge 存放的是兩個節點之間的距離,若兩個點之間沒有邊,則賦值為 INF(極大的數)
pre[i] = 0; //pre 陣列用來儲存 最短路徑中節點 i 的上一個節點是哪一個
}
flag[u]=1;//標記起點,沒必要訪問
dist[u]=0;
//開始Dijkstra演算法,遍歷 n-1次,即遍歷除起點外其他所有的點
for(int i=1; i<n; ++i)
{
//尋找離起點最近的點
min = INF;
for(int j=1; j<=n; ++j)
if(!flag[j]&&min>dist[j])
{
min=dist[j];
k = j;
}
//相應的點後,更新圖上其他點到起點的路徑距離
flag[k]=1;
for(int j=1; j<=n; ++j)
if(edge[k][j]!=INF)// k 和 j 之間存在邊時
{
int temp = edge[k][j]+dis[k]; //i 到 j 的新路徑
if(!flag[j]&&temp<dis[j]) //如果該路徑距離小於原本 j到i 的距離,重新整理替換
{
dis[j]=temp;
pre[j]=k;
}
}
}
}
此處補充四個小概念:(N表示節點數,M表示邊數)
①稀疏圖:M遠比N²小的圖稱為稀疏圖
②稠密圖:M相對較大的圖稱為稠密圖
③大頂堆:堆頂元素最大的堆
④小頂堆:堆頂元素最小的堆
紫書上對該演算法進行了優化並且封裝,其中涉及優先佇列的知識,基本思想一致,用優先佇列只是進行了優化,使得即使是稠密圖,使用該優化演算法的速度也比用鄰接矩陣的演算法快。因為執行 push存在前提。
(用優先佇列,是因為演算法中d[i]越小,越應該先出隊(每次先取和起點距離最近的點))
下面給出紫書把此演算法封裝成結構體的程式碼
連結所指大佬提供了迪傑斯特拉演算法堆優化的另一種思路:不需要定義結構體過載 <,而是直接對距離取相反數
//把整個演算法以及所用到的資料結構封裝到一個結構體中
struct Dijkstra
{
struct Edge
{
int from,to,dist;
Edge(int u,int v,int d):from(u),to(v),dist(d) {}
}; //存放邊的結構體
int n,m;
vector<Edge> edges;
vector<int> G[MAXN];
bool done[MAXN];// 標記是否訪問過
int d[MAXN];//起點到各個點的距離
int p[MAXN];// 記錄最短路徑
void inti(int n)
{
this->n=n; //賦值節點數
//清空操作,是為了可以重複使用此結構體
for(int i=1; i<=n; ++i)
G[i].clear();
edges.clear();
}
void AddEdge(int f,int t,int d)
{
edges.push_back(Edge(f,t,d));
m = edges.size(); //每次新增邊後都把邊數賦值給 m 最後得到總邊數
G[f].push_back(m-1); //m-1是儲存了 邊在edges中的編號(下標),可以通過此編號訪問邊
}
//定義一個結構體作為優先佇列中的元素型別
struct HeapNode{
int d,u; //d 是距離, u 是節點
//表示優先順序為從小到大
bool operator<(const HeapNode& a)const
{
return d>a.d; //小堆頂
//個人理解,佇列的每次push操作都要根據優先順序判斷再放置隊頂元素,
//即是否 隊頂<新入隊元素的操作(STL預設操作是大根堆)
//過載<運算子後使得比較結果顛倒,優先順序變成從小到大
}
};
//主演算法
void dijkstra(int u)
{
priority_queue<HeapNode> q; //建立一個優先佇列
for(int i=1;i<=n;++i)
d[i] = INF;
d[u] = 0;//起點到起點的距離當然為0
memset(done,0,sizeof(done));
HeapNode v(0,u); //起點
q.push(v);
while(!q.empty())
{
v=q.top();
q.pop();
if(done[v.u]) //已經訪問過的點不再訪問
continue;
done[v.u] = true;
//可以省去done陣列,改為 if(v.d!=d[v.u]) 防止節點重複擴充套件
//思路同,只是實現方式改變
for(int i=0;i<G[v.u].size();++i) //這種情況下每條邊只被訪問了一次
{
Edge& e = edges[G[v.u][i]];
if(d[e.to]>d[v.u]+e.dist)
{
d[e.to]=d[v.u]+e.dist;
p[e.to]=v.u;
q.push((HeapNode){d[e.to],e.to});
}
}
}
}
};
注意:迪傑斯特拉演算法不可以求含有負權邊的最短路!
具體原因等我搞明白了再寫一篇部落格。
四、Bellman-Ford(貝爾曼 - 福特)演算法(單源最短路徑,可以求解含負權邊的圖)
思想:(節點數N,邊數M)進行**至多**N-1次迴圈,每一次都更新圖上任意點到起點的距離(鬆弛操作)。其實個人覺得類似Dijkstra演算法,對任意點到起點的距離進行更新,只是每一次都遍歷所有邊去更新。至多N-1次,是考慮了最壞的情況,即每一次遍歷所有邊,只有和起點相連的邊得到更新 。
核心程式碼:
// u[i]和v[i] 分別存放第 i 條邊的前後頂點,w[i]存放權值
bool BellmanFord()
{
for(int i=1;i<=n;++i)
d[i]=(i==1?0:INF);
//至多遍歷 n-1 次
//考慮最壞的情況,即每一次遍歷所有邊,只有和起點相連的邊得到更新
for(int i=1;i<n;++i)
for(int j=1;j<=m;++j)
{
int x=u[i],y=v[i];
if(d[x]<INF)
d[y]=min(d[y],d[x]+w[i]);
}
//判斷是否存在 含有負權的環
for(int i=1;i<=m;++i)
if(d[v[i]]>d[u[i]]+w[i])
return false;
return true;
}
此演算法的缺點是效率不高。因為要進行N-1次迴圈,但可能在第一次就已經得到答案了,亦即是做了很多無用的工作。
下面是對該演算法的改進,一般稱為SPFA演算法。
思想:在Bellman-Ford演算法的基礎上,使用佇列儲存待鬆弛的點,對每個出隊的點,遍歷跟它相鄰的點,如果這兩點間的邊能夠鬆弛且相鄰點不在佇列中,則把相鄰點存入佇列。(因為鬆弛操作可能對該相鄰點的相鄰點產生影響)
核心程式碼:
void SPFA()
{
vector<int> d(n+1,INF); //距離
vector<int> cnt(n+1,0); //記錄每個點遍歷的次數
vector<int> inq(n+1,0); //記錄節點是否在佇列內
d[s]=0;
cnt[s]=inq[s]=1;
deque<int> q(1,s);
while(!q.empty())
{
int u=q.front(),i=0,to,dist;
inq[u] = 0; //出隊
q.pop_front();
for(; i<g[u].size(); ++i)
{
to=g[u][i].to;//g[u][i].to表示u-i邊箭頭端(末端)的點
dist=g[u][i].dist;
if(d[to]>d[u]+dist)
{
d[to]=d[u]+dist;
if(!inq[to]) //如果點不在佇列內再入隊
{
//若有負權環,則會無限迴圈下去,所以肯定有一個點遍歷的次數大於 n
if(n==++cnt[to])return 0; //判斷是否為負權環
inq[to]=1;
//SLF優化:減少重複擴充套件的次數。
if(!q.empty()&&d[to]<q.front()) //確保佇列非空才能訪問q.front()
q.push_front(to);
else
q.push_back(to);
}
}
}
}
其中運用了SLF優化(關於此優化,好像還沒有確切的證明,但實際運用上確實優化了不少,如果有讀者知曉麻煩評論告知一下,多謝)
SLF優化:判斷將要入隊的節點到源點的距離是否比隊首元素小,是的話插入隊首,否則插入隊尾。所以上面程式碼用了雙向佇列。這樣做可能會減少原隊首的某些鄰接點或者一些本來沒有SLF優化時需要入隊的點的入隊次數,即減少了擴充套件次數。但是存在反例使得使用了SLF優化反而使程式變慢。所以一般可以用,但不是絕對都能用。(我對此的理解還很淺薄)
學習、學好圖論的路,還很長…