最短路学习笔记

0x01 前置芝士

路径

路径可以使有限或无限的。一条有限路径是一个边的序列 { e n } \{e_n\} {en},使得存在一个顶点序列 { v n + 1 } \{v_{n+1}\} {vn+1} 满足 e i = ( v i , v i + 1 ) e_i=(v_{i},v_{i+1}) ei=(vi,vi+1),其中 i ∈ [ 1 , n ] i\in [1,n] i[1,n]

通常, v 1 v_1 v1 称为路径的起点, v n + 1 v_{n+1} vn+1 称为路径的终点。

  • 对于无权图,路径的长度定义为它所经过的边的数量
  • 对于有权图,路径的长度定义为它所经过的边的边权之和

最短路

即为从某个点 s s s 到某个点 t t t 的所有路径中最短的。

最短路具有性质:不存在负环的图上两点间的最短路一定是简单路径。
直观理解:如果不是简单路径,即重复经过了某个点,则一定是绕了一个环回来。因为没有负环,故这个环的长度一定非负。因此不走这个环一定不会更劣。

如果 s → t s\to t st 没有路径,一般认为最短路长度为 + ∞ +\infty +

  • 单源最短路:求从一个固定起点 s s s 到图上其他所有点的最短路。
  • 全源最短路:求图上任意有序点对 ( s , t ) (s,t) (s,t) 间的最短路。

0x02 算法比较

其中 n n n 为点数, m m m 为边数。

算法 适用于 求解类型 时间复杂度
Floyd 任意图 全源 O ( n 3 ) O(n^3) O(n3)
Dijkstra 非负权图 单源 O ( n log ⁡ ( n + m ) ) O(n\log (n+m)) O(nlog(n+m))
Bellman-Ford 任意图 单源 O ( n m ) O(nm) O(nm)
SPFA 任意图 单源 O ( n m ) O(nm) O(nm)
Johnson 任意图 全源 O ( n m log ⁡ m ) O(nm \log m) O(nmlogm)

0x03 约定记号

  • w ( u , v ) w(u,v) w(u,v):图上有向边 ( u , v ) (u,v) (u,v) 的边权。
  • d i s ( u , v ) dis(u,v) dis(u,v):算法执行到当前步骤时 u → v u\to v uv 的最短路长度。
  • D ( u , v ) D(u,v) D(u,v) u → v u\to v uv 的实际最短路长度。

0x04 Floyd

算法思想

采用由小子图逐渐扩展到全图的 DP 思想,在扩展过程中更新最短路长度。

f k , i , j f_{k,i,j} fk,i,j 表示经过由节点 1 ∼ k 1\sim k 1k 构成的子图, i → j i\to j ij 的最短路长度。

初始化为如果存在边 ( i , j ) (i,j) (i,j) f 0 , i , j = w ( i , j ) f_{0,i,j}=w(i,j) f0,i,j=w(i,j),否则 f 0 , i , j = + ∞ f_{0,i,j}=+\infty f0,i,j=+,而 f i , i = 0 f_{i,i}=0 fi,i=0

状态转移方程为 f k , i , j = min ⁡ ( f k − 1 , i , 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}) fk,i,j=min(fk1,i,j,fk1,i,k+fk1,k,j)。最终 f n , i , j f_{n,i,j} fn,i,j 即为 i → j i \to j ij 最短路长度。

发现 f k f_k fk 只依赖于 f k − 1 f_{k-1} fk1,所以可以用滚动数组压掉 k k k 这一维。

于是这个算法为 O ( n 3 ) O(n^3) O(n3),一般 n ≤ 500 n\le 500 n500,通常使用邻接表存图。

模板代码(洛谷 B3647)

#include 
using namespace std;
constexpr int N=105;
int f[N][N];
int main(){
    cin.tie(0)->sync_with_stdio(0);
    int n,m,u,v,w;
    memset(f,0x3f,sizeof(f));
    cin>>n>>m;
    while(m--){
        cin>>u>>v>>w;
        f[u][v]=min(f[u][v],w);
        f[v][u]=min(f[v][u],w);
    }
    for(int i=1;i<=n;++i) f[i][i]=0;
    for(int k=1;k<=n;++k){
        for(int i=1;i<=n;++i){
            for(int j=1;j<=n;++j){
                f[i][j]=min(f[i][j],f[i][k]+f[k][j]);
            }
        }
    }
    for(int i=1;i<=n;++i){
        for(int j=1;j<=n;++j){
            cout<<f[i][j]<<' ';
        }
        cout<<'\n';
    }
}

0x05 Dijkstra

算法思想

Dijkstra = BFS + 贪心。该算法将结点分成两个集合:已确定最短路长度的点集(记为 S S S)的和未确定最短路长度的点集(记为 T T T)。一开始所有的点都属于 T T T

  1. 初始化 d i s ( s ) = 0 dis(s)=0 dis(s)=0,其他点的 d i s dis dis 均为 + ∞ +\infty +
  2. T T T 集合中取出一个 d i s dis dis 最小的结点,移到 S 集合中。
  3. 对刚刚被加入 S S S 集合的结点的所有出边执行松弛操作。
  4. 不断重复步骤 2 和 3,直到 T = ∅ T=\varnothing T=

在实现时使用 STL 内置的 priority_queue 优先队列实现集合 T T T。这个容器是一个大根堆,出入队时间复杂度均为 O ( log ⁡ n ) O(\log n) O(logn)

而同一时刻最多会有 n + m n+m n+m 个节点在优先队列中。其中 n n n 为点数, m m m 为边数。于是这样使用优先队列优化的 Dijksta 算法时间复杂度为 O ( n log ⁡ ( n + m ) ) O(n \log(n+m)) O(nlog(n+m))

正确性证明

我们要证明,在所有边权值非负时,Dijkstra 算法的正确性。即我们要证明每次执行步骤 2 被取出的点 u u u 此时一定满足 d i s ( u ) = D ( u ) dis(u)=D(u) dis(u)=D(u)

对于 s s s D ( s ) = 0 D(s)=0 D(s)=0,被取出时 d i s ( s ) = 0 dis(s)=0 dis(s)=0,此时命题成立。

u u u 为第一个在被取出时的点,使得 d i s ( u ) > D ( u ) dis(u)>D(u) dis(u)>D(u) 的节点。此时一定有 S ≠ ∅ S\neq \varnothing S=

此时一定存在路径 s → x → y → u s \rightarrow x \rightarrow y \rightarrow u sxyu s → u s \rightarrow u su 的最短路,其中 x x x 是路径上最后一个属于 S S S 的节点,而 y y y x x x 的后继。

根据最短路性质,此时有 d i s ( y ) = D ( y ) dis(y)=D(y) dis(y)=D(y)。由于边权非负,有 d i s ( y ) = D ( y ) ≤ D ( u ) ≤ d i s ( u ) dis(y)=D(y)\le D(u)\le dis(u) dis(y)=D(y)D(u)dis(u)。由于 u u u 被取出了 T T T y y y 仍然在 T T T 中, d i s ( y ) ≥ d i s ( u ) dis(y)\ge dis(u) dis(y)dis(u)

因此 d i s ( y ) = d i s ( u ) dis(y)=dis(u) dis(y)=dis(u),矛盾。于是算法正确。

模板代码(洛谷 P4779)

#include 
using namespace std;
constexpr int N=2e5+5;
struct Node{
	int u,dis;
	inline bool operator<(const Node &rhs) const {
		return dis>rhs.dis;  // priority_queue是大根堆,因此这里要反过来 
	}
};
vector<pair<int,int>> g[N];
int dis[N];
void dijkstra(int s){
	priority_queue<Node> q;
	memset(dis,0x3f,sizeof(dis));
	q.push({s,dis[s]=0});
	while(!q.empty()){
		Node t=q.top();q.pop();
		if(dis[t.u]<t.dis) continue;
		for(auto [v,w]:g[t.u]){
			if(dis[t.u]+w<dis[v]){
				dis[v]=dis[t.u]+w;
				q.push({v,dis[v]});
			}
		}
	} 
}
int main(){
	cin.tie(0)->sync_with_stdio(0);
	int n,m,s,u,v,w;
	cin>>n>>m>>s;
	while(m--){
		cin>>u>>v>>w;
		g[u].emplace_back(v,w);
	}
	dijkstra(s);
	for(int i=1;i<=n;++i) cout<<dis[i]<<' ';
	return 0;
}

0x06 Bellman-Ford

算法思想

我们先定义一下松弛操作:对于边 ( u , v ) (u,v) (u,v),令 d i s ( v ) = m i n ( d i s ( v ) , d i s ( u ) + w ( u , v ) ) dis(v)=min(dis(v),dis(u)+w(u,v)) dis(v)=min(dis(v),dis(u)+w(u,v))。即我们尝试使用路径 s → u → v s\to u\to v suv 的长度更新 d i s ( v ) dis(v) dis(v)

Bellman-Ford 算法中,初始 d i s ( s ) = 0 dis(s)=0 dis(s)=0,其它点的 d i s = + ∞ dis=+\infty dis=+。每轮中我们对每条边进行松弛操作,一共进行 n − 1 n-1 n1 轮。因此时间复杂度为 O ( n m ) O(nm) O(nm)

如果从 s s s 出发能到达一个负环,那么在负环上的松弛操作会永远持续。因此如果 n − 1 n-1 n1 轮松弛执行完毕仍存在可以松弛的边,则存在负环。

正确性证明

注意到,图上任意两点间最短路不会存在环,最多有 n − 1 n-1 n1 条边(除非存在负环)。而每一轮松弛都会将每个点的最短路推进一条边。因此最多执行 n − 1 n-1 n1 轮即可得到正确答案。

注:该算法由于时间复杂度过大,一般在 OI 中不会使用它,通常使用其队列优化 SPFA 替代。本人从未见过裸 Bellman-Ford 算法的题。因此这里先不提供模板代码。

0x07 SPFA

算法思想

SPFA 是对 Bellman-Ford 算法的队列优化。注意到,对于任何有效松弛操作,松弛边 ( u , v ) (u,v) (u,v) d i s ( u ) ≠ + ∞ dis(u)\neq +\infty dis(u)=+。而只上一次被松弛的点的出边才可能引起下一次松弛。因此用队列维护这些可以引出下一次松弛的点。

若要判负环,则记录起点到每个点最短路经过的边数。若边数 ≥ n \geq n n,则有负环。

这样时间复杂度上限仍为 O ( n m ) O(nm) O(nm),但通常可以得到很大的优化。

模板代码(洛谷 P3385)

#include 
using namespace std;
constexpr int N=2005,INF=0x3f3f3f3f;
vector<pair<int,int>> g[N];
int dis[N],cnt[N],n;
bool inq[N];  // 标记每个点是否在队列中 
bool spfa(){
	queue<int> q;
	q.push(1);
	dis[1]=0;
	inq[1]=true;
	while(!q.empty()){
		int u=q.front();q.pop();
		inq[u]=false;
		for(auto [v,w]:g[u]){
			if(dis[u]+w<dis[v]){
				dis[v]=dis[u]+w;
				cnt[v]=cnt[u]+1;
				if(cnt[v]>=n) return true;
				if(!inq[v]){
					q.push(v);
					inq[v]=true;
				}
			}
		}
	}
	return false;
}
int main(){
	cin.tie(0)->sync_with_stdio(0);
	int T,m,u,v,w;
	cin>>T;
	while(T--){
		cin>>n>>m;
		for(int i=1;i<=n;++i){
			g[i].clear();
			dis[i]=INF;
			cnt[i]=0;
			inq[i]=false;
		}
		while(m--){
			cin>>u>>v>>w;
			if(w<0) g[u].emplace_back(v,w);
			else{
				g[u].emplace_back(v,w);
				g[v].emplace_back(u,w);
			}
		} 
		if(spfa()) cout<<"YES\n";
		else cout<<"NO\n";
	}
	return 0;
}

0x08 Johnson

Johnson 和 Floyd 一样,是一种能求出无负环图上任意两点间最短路径的算法。

算法思想

注意到,如果这个图是非负权图,那么我们可以直接枚举起点,跑 n n n 次 Dijkstra。

考虑对与任意图,对它的的边权预处理,使得每条边的边权非负同时又能通过新图上的最短路求出原图最短路。

我们新建一个虚点 0 0 0,从这个点向每个点连长度为 0 0 0 的边。接下来我们使用 SPFA 求出 0 0 0 号点到每个点 i i i 的最短路 h i h_i hi。假如存在一条从 u u u v v v 的边,将它的边权设为 h u − h v + w ( u , v ) h_u-h_v+w(u,v) huhv+w(u,v)

接下来以每个点为起点各跑一遍 Dijkstra 即可。若采用优先队列优化,则时间复杂度为 O ( n 2 log ⁡ ( n + m ) ) O(n^2 \log (n+m)) O(n2log(n+m))

正确性证明

在新图上,对于一条路径 s → v 1 → v 2 → ⋯ → v n → t s\to v_1 \to v_2 \to \dots \to v_n \to t sv1v2vnt,它的长度表达式如下:
( h s − h v 1 + w ( s , v 1 ) ) + ( h v 1 − h v 2 + w ( v 1 , v 2 ) ) + ⋯ + ( h v n − h t + w ( v n , t ) ) (h_s-h_{v_1}+w(s,v_1))+(h_{v_1}-h_{v_2}+w(v_1,v_2))+ \dots +(h_{v_n}-h_t+w(v_n,t)) (hshv1+w(s,v1))+(hv1hv2+w(v1,v2))++(hvnht+w(vn,t))
化简后得到:
w ( s , v 1 ) + ⋯ + w ( v n , t ) + h s − h t w(s,v_1)+\dots+w(v_n,t)+h_s-h_t w(s,v1)++w(vn,t)+hsht
无论从 s s s t t t 走哪种路径, h s − h t h_s-h_t hsht 永远为常数。因此 h i h_i hi 符合势能的性质。

接下来我们需要证明重新标注的图的边权非负。根据三角形不等式, h v ≤ h u + w ( u , v ) h_v\le h_u+w(u,v) hvhu+w(u,v),因此 h u − h v + w ( u , v ) ≥ 0 h_u-h_v+w(u,v)\ge 0 huhv+w(u,v)0,证毕。

模板代码(洛谷 P5905)

#include 
#define int long long
using namespace std;
const int N=3005,M=9005,INF=0x3f3f3f3f3f3f3f3f,C=1e9;
struct Edge{int to,w,nxt;} e[M];
struct Node{
    int u,dis;
    inline bool operator<(const Node &rhs) const{
        return dis>rhs.dis;
    }
};
int hd[N],cnt[N],h[N],dis[N],tot,n;
bool inq[N];
inline void add(int u,int v,int w){
    e[++tot]={v,w,hd[u]},hd[u]=tot;
}
bool spfa(){
    queue<int> q;
    memset(h,0x3f,sizeof(h));
    q.push(0);
    inq[0]=true;
	h[0]=cnt[0]=0;
    while(!q.empty()){
        int u=q.front();q.pop();
        inq[u]=false;
        for(int i=hd[u];i;i=e[i].nxt){
            int v=e[i].to,w=e[i].w;
            if(h[u]+w<h[v]){
                h[v]=h[u]+w;
                cnt[v]=cnt[u]+1;
                if(cnt[v]>n) return true;
                if(!inq[v]){
                    q.push(v);
                    inq[v]=true;
                }
            }
        }
    }
    return false;
}
void dijkstra(int s){
    priority_queue<Node> q;
    memset(dis,0x3f,sizeof(dis));
    q.push({s,dis[s]=0});
    while(!q.empty()){
        Node t=q.top();q.pop();
        if(dis[t.u]<t.dis) continue;
        for(int i=hd[t.u];i;i=e[i].nxt){
            int v=e[i].to,w=e[i].w;
            if(dis[t.u]+w<dis[v]){
            	dis[v]=dis[t.u]+w;
                q.push({v,dis[v]});
            }
        }
    }
}
signed main(){
	cin.tie(0)->sync_with_stdio(0);
    int m,u,v,w,ans;
    cin>>n>>m;
    while(m--){
    	cin>>u>>v>>w;
        add(u,v,w);
    }
    for(int i=1;i<=n;++i) add(0,i,0);
    if(spfa()){
    	cout<<"-1";
        return 0;
    }
    for(int u=1;u<=n;++u){
        for(int i=hd[u];i;i=e[i].nxt){
            int v=e[i].to;
            e[i].w=h[u]-h[v]+e[i].w;
        }
    }
    for(int u=1;u<=n;++u){
        ans=0;
        dijkstra(u);
        for(int v=1;v<=n;++v){
            if(dis[v]==INF) ans+=v*C;
            else ans+=v*(dis[v]-h[u]+h[v]);
        }
        cout<<ans<<'\n';
    }
    return 0;
}

你可能感兴趣的:(学习笔记,算法,c++,笔记,图论)