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 的所有路径中最短的一条。

最短路具有一个性质:无负环图上两点间的最短路一定是简单路径。

直观理解:如果不是简单路径,那么路径上一定有环。因为没有负环,所以这个环的长度一定非负。因此不走这个环一定不会更劣。

因此,最短路一定不会重复经过某个边或点。因此在一张 n n n 个点的图上,最短路最多包含 n n n 个点, n − 1 n-1 n1 条边。

如果 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 + m ) log ⁡ m ) O((n+m) \log m) O((n+m)logm)
Bellman-Ford任意图单源 O ( n m ) O(nm) O(nm)
SPFA任意图单源 O ( n m ) O(nm) O(nm)
Johnson任意图全源 O ( n 2 log ⁡ ( n + m ) ) O(n^2 \log (n+m)) O(n2log(n+m))

0x03 约定记号

  • s s s:路径起点。
  • t t t:路径终点。
  • w u , v w_{u,v} wu,v:图上有向边 ( u , v ) (u,v) (u,v) 的边权。对于每条无向边 ( u , v ) (u,v) (u,v) 我们一般拆成两条有向边 ( u , v ) (u,v) (u,v) ( v , u ) (v,u) (v,u)
  • d i s u dis_u disu:算法执行到当前步骤时 s → u s\to u su 的最短路长度。
  • D u D_u Du s → u s\to u su 的实际最短路长度。

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),通常使用邻接矩阵存图,邻接矩阵直接当作初始 DP 数组( n n n 不会太大,一般不超过 500 500 500)。

要判断是否有负环,只需要在最后一步判断 f[u][u] 的值是否有 < 0 <0 <0 的。如果有,则 u u u 一定在一个负环上。但是 Floyd 只能判断某点是否在负环上,而不能判断某点是否能到达一个负环。

例如由点 1 , 2 1,2 1,2 构成的图,其中边 ( 1 , 2 ) (1,2) (1,2) 权值为 1 1 1,自环 ( 2 , 2 ) (2,2) (2,2) 权值为 − 1 -1 1。显然点 2 2 2 在负环上,点 1 1 1 不在负环上但可达负环。运行 Floyd 算法后会发现 f 2 , 2 = − 2 f_{2,2}=-2 f2,2=2,但是 f 1 , 1 = 0 f_{1,1}=0 f1,1=0,因为从点 1 1 1 进入负环后就出不来了。

模板代码(洛谷 B3647

#include <bits/stdc++.h>
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]=min(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 中。

我们先定义一下松弛操作:对于有向边 ( u , v ) (u,v) (u,v) 执行松弛操作,即为令 d i s v = min ⁡ ( d i s v , d i s u + w u , v ) dis_v=\min(dis_v,dis_u+w_{u,v}) disv=min(disv,disu+wu,v)。即我们尝试使用路径 s → u → v s\to u\to v suv 的长度更新 d i s v dis_v disv。记好这个操作,因为后面的 Bellman-Ford 和 SPFA 算法都要用到它!

Dijkstra 算法包含以下步骤:

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

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

Dijkstra 算法中,pop 操作执行 n n n 次,而 push 操作最多执行 m m m 次。因此队列中最多 m m m 个节点,时间复杂度为 O ( ( n + m ) log ⁡ m ) O((n+m) \log m) O((n+m)logm)

正确性证明

我们要证明在边权非负时 Dijkstra 算法是正确的。即我们要证明每次执行步骤 2 被取出的点 u u u 此时一定满足 d i s u = D u dis_u=D_u disu=Du。下面采用反证法证明。

对于 s s s D s = 0 D_s=0 Ds=0,被取出时 d i s s = 0 dis_s=0 diss=0,此时是正确的。

假设 u u u 为第一个在被取出时 d i s u > D u dis_u>D_u disu>Du 的节点。显然 u ≠ s u\neq s u=s,因此此时 S S S 一定非空,因为其中有 s s s

s → u s \to u su 的最短路形如 s → x → y → u s \to x \to y \to u sxyu,其中 x x x 是路径上最后一个在 u u u 被取出时属于 S S S 的节点, x x x 可以等于 s s s,而 y y y x x x 的后继, y ≠ u y\neq u y=u 且它还在 T T T 中。

下面我们来证明一定会存在一个这样的 y y y 点。如果不存在,则 u u u 的前驱 v v v 在加入 S S S 时一定有 d i s v = D v dis_v=D_v disv=Dv。此时 v v v 对它的出边 ( v , u ) (v,u) (v,u) 进行松弛时 d i s u dis_u disu 会被更新成正确的最短路 D u D_u Du。这与原假设矛盾。

而在 x x x 被取出 T T T 并加入 S S S d i s y dis_y disy 会被边 ( x , y ) (x,y) (x,y) 更新成 D y D_y Dy,获得正确的最短路。因此 y y y 被取出时一定有 D y = d i s y D_y=dis_y Dy=disy。由于边权非负,有 D y ≤ D u ≤ d i s u D_y\le D_u\le dis_u DyDudisu。由于 u u u 被取出了 T T T y y y 仍然在 T T T 中,因此 D y = d i s y ≥ d i s u D_y=dis_y\ge dis_u Dy=disydisu。故 D y = D u = d i s u D_y=D_u=dis_u Dy=Du=disu,与原假设矛盾。因此算法正确。

模板代码(洛谷 P4779

#include <bits/stdc++.h>
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

算法过程

Bellman-Ford 算法中,初始 d i s s = 0 dis_s=0 diss=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 + w u , v < d i s v dis_u+w_{u,v}<dis_v disu+wu,v<disv,则称这次操作为有效的。

注意到,对于任何有效松弛操作, d i s u dis_u disu 一定不能等于 + ∞ +\infty +。而只有上一次被有效松弛的点的出边才可能引起下一次有效松弛。因此用队列维护这些刚刚被有效松弛的点。

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

这样时间复杂度上限仍为 O ( n m ) O(nm) O(nm),但通常可以得到很大的优化,比 Bellman-Ford 算法快很多。

模板代码(洛谷 P3385

#include <bits/stdc++.h>
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+wu,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+ws,v1)+(hv1hv2+wv1,v2)++(hvnht+wvn,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 ws,v1++wvn,t+hsht
无论从 s s s t t t 走什么路径, h s − h t h_s-h_t hsht 永远为常数。因此新图上的最短路就是原图上的最短路。

接下来证明重新标注的图的边权非负。对于有向边 ( u , v ) (u,v) (u,v),显然有 h u + w u , v ≥ h v h_u+w_{u,v} \ge h_v hu+wu,vhv,因此 h u − h v + w u , v ≥ 0 h_u-h_v+w_{u,v}\ge 0 huhv+wu,v0,证毕。

模板代码(洛谷 P5905

#include <bits/stdc++.h>
#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;
}

Update 2025.12.6:更正 Dijkstra 时间复杂度分析,添加关于 Floyd 判负环的说明,优化语言表达。感谢 Otachizhoujiefu 的指正。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐