1. 程式人生 > 其它 >【演算法筆記】最短路

【演算法筆記】最短路

前言

因為發現自己對好多演算法的理解都不深,所以想起來寫這麼個東西。

一些基礎的東西

  1. 我們每一次設的 \(dist\) 都是“對於最短路徑的估計”。

    也就是說它最先是不確定的。

    只有當我們求了最短路過了之後它才真正是“最短路徑”。

  2. 關於鬆弛

    說白了就是這樣子的一個圖:

    我們對於任意的 \(u,v\) ,如果 \(dist[s -> u] > dist[s->v]+w[v->v]\)
    也就是說存在新的更短的路徑那麼就更新估計。

SSSP

SSSP(單源最短路徑)是求從源點 \(s\) 到其它所有點的最短路徑問題。

也可以說是:對在權圖 \(G=(V,E)\)

,從一個源點 \(s\) 到匯點 \(t\) 有很多路徑,其中路徑上權和最少的路徑,稱從 \(s\)\(t\) 的最短路徑。

一般常用的有幾種演算法:

  • Dijkstra
  • SPFA
  • Bellman-ford
  • Floyd

那麼,先從最基礎也是最實用的 Dijkstra 說起。

Dijkstra 演算法:

定義&過程:

該演算法於 1959 年由荷蘭的電腦科學家 Dijkstra 發明。

簡單來說還是基於SSSP最基礎的思想:鬆弛。

Dijkstra採用貪心演算法的策略。

他每次遍歷到始點距離最近且未訪問過的頂點的鄰接節點,直到擴充套件到終點為止。

根據我們鬆弛的三角形不等式:

\[dist[x] + w[x][y] >= dist[y] \]

這個演算法主要分為以下的幾個步驟:

  1. 將我們的頂點集合 \(V\) 分成兩個部分:\(S\)\(T\) ,定義 \(S\) 是已經求出(就是跑過的)的頂點集合(開始的時候只有一個點,就是源點 \(s\)), 對於 \(T\) : \(V-S =T\) (就是剩下的部分)

  2. \(T\) 中的所有元素依次(按編號遞增)加入到 \(S\) 裡面。 保證以下兩個條件:

\(\alpha\) : 源點 到 S 中的任意一個點的距離都不大於源點到 T 中的任意一個點的最短路長度

\(\beta\)S中的點的 dist = 源點到這個點的距離,T中的點的 dist = 從源點到這個點的最短路長度,且中間經過的點只有在S中的頂點。

那麼有一個結論:

源點到 \(T\) 中頂點的,或是從源點到\(T\) 中頂點的直接路徑的權值;或是從源點經過 \(S\) 中的頂點到該頂點的路徑權值之和。

不過這裡有一個更加易於理解的過程:

1. memset(dist,INF,sizeof(dist);

2. dist[s]=0;

3.找出一個沒有被標記的節點 v,標記一下。

4.對於每個和 v 相連的節點,如果它滿足三角形不等式,更新。

5. 直到所有點都被標記。

但是這個演算法的複雜度是 \(\text{O}(n^2)\) 的。

根據它貪心的本質,有了用堆優化的方法。

對於它的實現是這樣的:

  • 將源點 \(s\) 加入堆。

  • 取出堆頂,維護一下堆的性質。

  • 我們從這個堆頂的點出發,利用三角形不等式去鬆弛和它相鄰的所有點。 如果它不在堆裡(以前沒訪問過),就加入堆,並更新 dist

  • 如果它已經在堆裡了,也就是說從源點到這個點有更短的路徑(以前來過),更新dist

  • 如果我們的堆頂取出來的時候是終點 \(t\) 了,根據貪心策略,可以知道這個時候就已經有了最短的路徑,不會再有更短的了,結束。

(當然如果是求整個圖的 \(dis\) ,就直接到堆為空(所有點被標記)停止就可以了)

例題:

(上面的兩道題都可以用dij過掉)

程式碼:


#include<bits/stdc++.h>
using namespace std;

const int si=2e6+2;
int n,m,s;
vector<pair<int,int> >g[si];
priority_queue<pair<int,int> >q;
int dis[si];

int main(){
    cin>>n>>m>>s;
    for(register int i=1,x,y,z;i<=m;++i){
        cin>>x>>y>>z;
        g[x].push_back(make_pair(y,z));
       // g[y].push_back(make_pair(x,z));
    }
    for(register int i=1;i<=n;++i){
        dis[i]=1e9;
    }
    dis[s]=0;
    q.push(make_pair(0,s));
    while(!q.empty()){
        register int x,y;
        x=q.top().second;
        y=-q.top().first;
        q.pop();
        if(dis[x]!=y){
            continue;
        }
        for(register int i=0,to,val;i<g[x].size();++i){
            to=g[x][i].first;
            val=g[x][i].second;
            if(dis[to]>dis[x]+val){
                dis[to]=dis[x]+val;
                q.push(make_pair(-dis[to],to));
            }//鬆弛
        }
    }
    for(register int i=1;i<=n;++i){
        cout<<dis[i]<<" ";
    }
    return 0;
}
//每次從dis最小的開始鬆弛


bellman-frod & SPFA

這兩個演算法對我來說其實一般是用來判斷負環的,所以簡單講一下。

Bellman-定義

\(\text{bellman}\) 是基於迭代思想的演算法。

做法十分簡單。

掃描所有邊,如果 \(dist[y] > dist[x] + w[x][y]\) ,那麼用 \(dist[x] + w[x][y]\) 更新 \(dist [y]\) 。直到它沒有更新操作發生。

說白了就是個暴力(

很明顯的,它的複雜度是 \(O(nm)\) 級別的。

SPFA-定義

\(\text{SPFA}\) , 則是對\(\text{bellman}\)的一個佇列優化。

這個佇列裡的元素是“有更新潛力的”。

也就是說它可能還能更新。

所以每次鬆弛擴充套件的時候就把索擴充套件的元素放進隊即可。

開始的時候會有一個佇列,讓源點處在其中。

每次執行\(\text{bellman}\)的那種鬆弛操作之後,將其放入佇列,直到佇列是空的為止(沒有有更新潛力的元素)。

但是 \(\text{SPFA}\) ,常常成為毒瘤出題人卡的主要物件。

(在隨機圖上是比較優秀的,但是如果出題人構造了一個菊花圖一類的東西……那您就可以原地去世了)。

例題:

(當然,如果您用堆優化Bellman-Frod,那麼它和Dij就沒有本質上的差別了。)

程式碼(P3371):


#include<bits/stdc++.h>
using namespace std;

const int si_e=1e6+10;
const int si_n=1e5+10;

int n,m,tot=0,s;
struct node{
	int Next,ver,w,head;
}e[si_e];
int dist[si_n];
bool vis[si_n];
queue<int>q;

void add(int u,int v,int w){
	e[++tot].ver=v,e[tot].w=w;
	e[tot].Next=e[u].head,e[u].head=tot;
}

void SPFA(int s){
	memset(dist,0x3f,sizeof dist);
	memset(vis,false,sizeof vis);
	dist[s]=0,vis[s]=true;
	q.push(s);
	while(!q.empty()){
		int u=q.front();
		q.pop();
		vis[u]=false;
		for(register int i=e[u].head;i;i=e[i].Next){
			int v=e[i].ver,w=e[i].w;
			if(dist[v]>dist[u]+w){
				dist[v]=dist[u]+w;
				if(!vis[v]) q.push(v),vis[v]=true;
			}
		}
	}
}

int main(){
	cin>>n>>m>>s;
	for(register int i=1,u,v,w;i<=m;++i){
		cin>>u>>v>>w;
		add(u,v,w);
	}
	SPFA(s);
	for(register int i=1;i<=n;++i){
		cout<<dist[i]<<" ";
	}
	return 0;
}

關於SPFA和Bellman判斷負環

Bellman:

如果經過 \(n\) 輪迭代,迭代仍然未結束,那麼就沒有負環。

如果在 \(n-1\) 輪之內結束了,那麼就有負環。

(下面是我一兩個月以前寫差分約束的時候寫的\(\text{bellman}\)判負環)

(這個東西在差分約束裡面很有用)


bool bellman_ford(int n,int m){
    for(register int i=1;i<=n;++i){
        dist[i]=0;
    } 
    for(register int i=1;i<=n;++i){
        for(register int j=0;j<m;++j){
            edge &e=es[j];
            if(dist[e.to]>dist[e.from]+e.dist){
                dist[e.to]=dist[e.from]+e.dist;
            }
        }
    }
    for(register int j=0;j<m;++j){
        edge &e=es[j];
        if(dist[e.to]>dist[e.from]+e.dist){
            return false;
        }
    }
    return true;
}


SPFA

假設 \(cnt[x]\) 表示 \(1\)\(x\) 的最短路里有多少邊。

\(cnt[1] = 0\)

在鬆弛的時候,更新一下 \(cnt[]\)

那麼怎麼更新?

\(cnt[y]=cnt[x]+1\)

如果更新的時候, $n \le cnt[y] $ ,那麼就有負環。

反之則亦然。

\(\text{SPFA}\) 就不放程式碼了。

其實和上面的 \(\text{bellman}\) 差不多。

當然還有一種辦法是判一個點的入隊次數。


附:關於負邊權處理

演算法 能否處理負邊權
\(\text{Dijkstra} -\text{O}(n^2)\) 不行
\(\text{HeapDij} - \text{O}(m \log n)\) 不行
$\text{SPFA} -\text{O}(nm/km) $ 可以
\(\text{Bellman-Ford} - \text{O}(nm)\) 可以

(您會發現,所有基於貪心的演算法都不行,您可以想一想為什麼(這應該很好想))。


Floyd

本質上來說,\(\text{floyd}\) 是一個動態規劃演算法。

它能夠在 \(\text{O}(n^3)\) 的複雜度裡解決任意兩點之間的最短距離問題。

而且一般圖比較稠密。

假設 \(f[k][i][j]\) 表示經過一些編號不超過k的節點,從i到j的最短路長度

(當然這個k是可以改定義的)

考慮如何轉移。

因為是dp,所以\(k\)這個狀態可以從 \(k-1\) 轉移過來。

然後從 \(i\)\(j\) 可能是直接過去(不是隻有一條邊,而是 \(f[k-1][i][j]\) 這個狀態)。

也可能先從 \(i\)\(k\) 再到 \(j\)\(f[k-1][i][k]+f[k-1][k][j]\)

然後轉移方程就出來了。

\[f[k][i][j]=\min(f[k-1][i][j],f[k-1][i][k]+f[k-1][k][j]) \]

不過注意在寫迴圈的時候,k要放在最外層。

因為它代表“階段”,如果不是第一層那麼轉移順序會炸開來。

至於初始化。用鄰接矩陣就可以了。

根據定義 :\(f[0][i][j]=a[i][j]\) ,然後跑一個 \(n^3\) 的DP就行了。

當然因為轉移的時候 \(k\) 只會從 \(k-1\) 轉移而來。

所以也可以和揹包一樣滾動陣列把第一維滾掉。

也就是:

\[f[i][j]=\min(f[i][j],(f[i][k]+f[k][j])) \]

程式碼:


#include<bits/stdc++.h>
using namespace std;

const int si=4e2+10;
int n,m;
int f[si][si];

int main(){
	cin>>n>>m;
	memset(f,0x3f,sizeof f);
	for(register int i=1;i<=n;++i){
		f[i][i]=0;
	}
	for(register int i=1,u,v,w;i<=m;++i){
		cin>>u>>v>>w;
		f[u][v]=min(f[u][v],w);
	}
	for(register int k=1;k<=n;++k){
		for(register int i=1;i<=n;++i){
			for(register int j=1;j<=n;++j){
				f[i][j]=min(f[i][j],f[i][k]+f[k][j]);
			}
		}
	}
	//f[i][j]=dis[i][j]; 
	return 0;
}

傳遞閉包

給定若干的元素和若干對二元關係。

在關係具有傳遞性的情況下,推匯出更多的二元關係。

這就是傳遞閉包。

比如給你 \(a,b,c\)

\(a<b,b<c\)

那麼你就要通過關係推出 \(a<c\)

我們可以用 \(\text{floyd}\) 原理解決。

\(f[i][j]=true\) 表示i和j有一個關係。

反之則沒有。

\(f[i][i]=true\)

比如在上面的例子裡就是 \(f[a][b]=1,f[b][c]=1\)

然後跑一遍\(\text{floyd}\)

在轉移的時候將所有可以推出的關係更新即可。

最長路

一般使用SPFA(因為需要負數)。

我們只需要把所有邊權改成負數跑SPFA即可。

(dis初始化還是正無窮)。

然後取出來給dis取絕對值即可。

(當然如果圖中有負邊那就要小心點,如果全是正邊就直接改)。


練習題:


Bellman_Ford的證明

  1. 經過一輪鬆弛,從起點出發到某個相鄰點\(u\)的最短路徑一定會被確定下來。(不可能出現:所有點都還不是最短路徑值)
  2. 再經過一輪鬆弛,從 \(u\) 出發到某個相鄰點\(v\)的最短路徑一定會被確定下來。
  3. 不含有負環的圖中,一條簡單路徑最多 \(n\) 個點,\(n-1\) 條邊。
  4. 最多經過 \(n-1\) 輪鬆弛,便可以把到最遠的點的最短距離確定下來。
  5. \(n-1\) 輪必然就能把最遠點的最短路徑確定下來,最遠點的最短路徑都確定下來了,其他點肯定在之前已經確定最短路徑值。

Dijsktra的證明

  1. 沒有負邊權,那麼路徑值最小的點,就不可能在後續的過程中更新,將其染色。
  2. 比如起點的路徑值為 \(0\),這個值就不可能被更新,所以先染色。
  3. 因為每步都是走正邊權,從其他路徑繞過來,肯定路徑值必然增加。
  4. 被確定的最小值點,既然已經比其他點值小,就不可能從其他點迂迴後更新它。
  5. 所以每步的最小值,一定是路徑值最小的時候,立即染色,不可能在後邊更新了。