最短路学习笔记
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 n−1 条边。
如果 s → t s\to t s→t 没有路径,一般认为最短路长度为 + ∞ +\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 s→u 的最短路长度。
- D u D_u Du: s → u s\to u s→u 的实际最短路长度。
0x04 Floyd
算法过程
采用由小子图逐渐扩展到全图的 DP 思想,在扩展过程中更新最短路长度。
设 f k , i , j f_{k,i,j} fk,i,j 表示经过由节点 1 ∼ k 1\sim k 1∼k 构成的子图, i → j i\to j i→j 的最短路长度。
初始化时,如果存在边 ( 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(fk−1,i,j,fk−1,i,k+fk−1,k,j)。最终 f n , i , j f_{n,i,j} fn,i,j 即为 i → j i \to j i→j 最短路长度。
发现 f k f_k fk 只依赖于 f k − 1 f_{k-1} fk−1,所以可以用滚动数组压掉 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 s→u→v 的长度更新 d i s v dis_v disv。记好这个操作,因为后面的 Bellman-Ford 和 SPFA 算法都要用到它!
Dijkstra 算法包含以下步骤:
- 初始化 d i s s = 0 dis_s=0 diss=0,其他点的 d i s dis dis 均为 + ∞ +\infty +∞。
- 从 T T T 集合中取出 d i s dis dis 最小的点,加入 S S S。
- 对刚刚加入 S S S 集合的点的所有出边执行松弛操作。
- 不断重复步骤 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 s→u 的最短路形如 s → x → y → u s \to x \to y \to u s→x→y→u,其中 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 Dy≤Du≤disu。由于 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=disy≥disu。故 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 n−1 轮。因此时间复杂度为 O ( n m ) O(nm) O(nm)。
如果从 s s s 出发能到达一个负环,那么在负环上的松弛操作会永远持续。因此如果 n − 1 n-1 n−1 轮松弛执行完毕仍存在可以松弛的边,则存在负环。
正确性证明
图上任意两点间最短路最多有 n − 1 n-1 n−1 条边(除非存在负环)。而每一轮松弛都会将每个点的最短路推进一条边。因此最多执行 n − 1 n-1 n−1 轮即可得到正确答案。
注:该算法由于时间复杂度过大,一般在 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} hu−hv+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
s→v1→v2→⋯→vn→t,它的长度表达式如下:
(
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})
(hs−hv1+ws,v1)+(hv1−hv2+wv1,v2)+⋯+(hvn−ht+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+hs−ht
无论从
s
s
s 到
t
t
t 走什么路径,
h
s
−
h
t
h_s-h_t
hs−ht 永远为常数。因此新图上的最短路就是原图上的最短路。
接下来证明重新标注的图的边权非负。对于有向边 ( u , v ) (u,v) (u,v),显然有 h u + w u , v ≥ h v h_u+w_{u,v} \ge h_v hu+wu,v≥hv,因此 h u − h v + w u , v ≥ 0 h_u-h_v+w_{u,v}\ge 0 hu−hv+wu,v≥0,证毕。
模板代码(洛谷 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 判负环的说明,优化语言表达。感谢 Otachi 和 zhoujiefu 的指正。
更多推荐



所有评论(0)