最短路问题
- 松弛操作:设 d i s t [ k ] dist[k] dist[k] 数组表示源点 s r c src src 到节点 k k k 的最短距离,对于边 ( u , v ) (u,v) (u,v) 且权值为 w ( u , v ) w(u,v) w(u,v) ,若 d i s t [ v ] > d i s t [ u ] + w ( u , v ) dist[v]\gt dist[u]+w(u,v) dist[v]>dist[u]+w(u,v) ,那么从 s r c src src 到点 v v v 有一条新的最短路径,更新其长度为 d i s t [ u ] + w ( u , v ) dist[u]+w(u,v) dist[u]+w(u,v) ,我们称用节点 u u u 更新两个节点 s r c src src 与 v v v 间的最短距离为一次 松弛操作。
一、单源最短路
1. Dijkstra 算法
- 思想:每次找到一个未访问节点 u u u ,且目前 d i s t [ u ] dist[u] dist[u] 最小,保证每个点只被用于扩展一次,即只用一次这个点去 松弛 别的点。遍历当前节点 u u u 的所有出边 ( u , v ) (u, v) (u,v) ,若 d i s t [ v ] > d i s t [ u ] + w ( u , v ) dist[v]\gt dist[u]+w(u, v) dist[v]>dist[u]+w(u,v) ,则进行松弛操作,重复这些操作直到所有点被标记为止。
- 优化:用小根堆维护当前需要扩展的节点,按权值进行排序,这样每次堆顶都能取出最优值进行扩展。
vector<vector<pair<int, int>> g(N); // 图
vector<int> dist(N, INT_MAX); // 最短路数组
int n; // 节点个数
void Dijkstra(int src) {
dist[src] = 0;
// 小根堆
priority_queue<pair<int, int>, vector<pair<int, int>, greater<pair<int, int>>> pq;
pq.emplace(0, src);
while(pq.size()) {
auto [d, u] = pq.top(); pq.pop();
if(d > dist[u]) continue; // 代表已经访问扩展过该节点
for(auto [v, w] : g[u]) {
if(w + d >= dist[v]) continue;
dist[v] = w + d;
pq.emplace(dist[v], v);
}
}
}
2. Bellman-Ford 与 SPFA 算法
- 思想:每进行一次循环,就对图上所有边都尝试一次循环,当在一次循环中没有进行成功的松弛操作时,算法停止。由于最短路的长度最长为 n − 1 n-1 n−1 ,每次松弛会使得最短路边数至少增加一条,故整个算法最多执行 n − 1 n-1 n−1 轮次循环。
- 优化:并不是在任意一次循环中都需要对所有的边进行松弛操作,当在一次循环中只优化了少数几个节点时,只需要在下次循环中以这些节点为基础再进行松弛操作,依次类推。这就是 SPFA 算法,其本质为 经队列优化的 Bellman-Ford 算法 。
- 判断负边负环:当进行 n − 1 n-1 n−1 次循环后,如果再进行第 n n n 次循环,并且依然进行了成功的松弛操作,则说明从 s r c src src 出发,能够抵达一个负环。需要注意的是,以 s r c src src 为起点执行该算法时,如果没有给出存在负环的结果,只能说明从 s r c src src 出发不能抵达一个负环,而不能说明图上不存在负环。正确做法应该是建立一个 超级源点 ,向图上每个节点连一条权值为 0 0 0 的边,然后以该点为起点执行该算法。
vector<tuple<int, int, int>> edges; // 边集数组 [u, v, w]
vector<int> dist(N, INT_MAX), cnt(N, 0); // 最短路数组 SPFA中判断最短路长度
vector<bool> inq(N, false); // 是否在队列中
int n; // 节点个数
void Bellman_Ford(int src) {
dist[src] = 0;
bool flag = false; // 判断有无松弛操作发生
for(int i = 1; i <= n; i++) {
flag = false;
for(auto [u, v, w] : edges) {
if(dist[u] == INT_MAX || dist[u] + w >= dist[v]) continue;
dist[v] = dist[u] + w;
flag = true;
}
if(!flag) break; // 无松弛操作就退出
}
// 如果第 n 轮循环成功进行了松弛操作就说明 src 点可以抵达一个负环
if(flag) cout << "存在负环" << endl;
}
void SPFA(int src) {
dist[src] = 0, inq[src] = true;
queue<int> q;
q.push(src);
while(q.size()) {
int u = q.front(); q.pop();
inq[u] = false;
for(auto [v, w] : adj[u]) {
if(dist[v] <= w + dist[u]) continue;
dist[v] = w + dist[u];
cnt[v] = cnt[u] + 1;
if(cnt[v] >= n) {
cout << "存在负环" << endl;
return;
}
if(inq[v]) continue;
inq[v] = true;
q.push(v);
}
}
}
3. 算法比较
算法对比表: V V V 表示节点数, E E E 表示边数。
算法 | 时间复杂度 | 能否处理负权边 | 能否检测负权环 | 适用图类型 | 实现方式 |
---|---|---|---|---|---|
Dijkstra | O ( ( V + E ) l o g V ) O((V+E)logV) O((V+E)logV)(优先队列优化), O ( V 2 ) O(V²) O(V2)(未优化) | ❌ 不能 | ❌ 不能 | 无负权边 的加权有向/无向图 | 贪心算法,优先队列 |
Bellman-Ford | O ( V E ) O(VE) O(VE) | ✔️ 能 | ✔️ 能 | 一般加权有向图(允许负权边) | 动态规划,松弛操作 |
SPFA (Bellman-Ford 的队列优化) | 平均 O ( E ) O(E) O(E),最坏 O ( V E ) O(VE) O(VE) (退化为 Bellman-Ford ) | ✔️ 能 | ✔️ 能 | 稀疏图且含负权边 | 队列优化的 Bellman-Ford |
BFS (无权图) | O ( V + E ) O(V+E) O(V+E) | ❌ 无权图 | ❌ 无权图 | 无权图(所有边权重相同) | 广度优先搜索 |
DAG最短路径 (拓扑排序) | O ( V + E ) O(V+E) O(V+E) | ✔️ 能(但不能有环) | ❌ 不能(DAG无环) | 有向无环图(DAG) | 拓扑排序,动态规划 |
关键说明:
- Dijkstra:适合正权图的最短路径,效率高,但不能处理负权边。未优化时适用于 稠密图,优化后适用于 稀疏图 。
- Bellman-Ford:能处理负权边并检测负权环,但时间复杂度较高。
- SPFA (Shortest Path Faster Algorithm):是Bellman-Ford的优化版本,适合稀疏图。
- BFS:仅适用于无权图(或边权均为 1 1 1 的图)。
- DAG最短路径:利用拓扑排序,线性时间解决DAG的最短路径问题,但不能处理环(包括负权环)。
算法选择:
- 无负权边 → Dijkstra(效率最高)
- 有负权边 → Bellman-Ford 或 SPFA
- DAG(有向无环图) → 拓扑排序+DAG最短路径( O ( V + E ) O(V+E) O(V+E)最优)
- 无权图 → BFS(最简单高效)
二、分层图最短路
题目描述:在最多允许进行 k k k 次特权操作的情况下,求起点 s t st st 到终点 e d ed ed 的最短路。
核心思路:
-
分层构建:
将原图复制为 k + 1 k+1 k+1 层(假设最多允许使用 k k k 次特殊操作),即扩充维度。第 i i i 层表示已使用 i i i 次特权后的状态。例如,若最多允许将边权置零 k k k 次,每使用一次特权,则进入下一层。 -
层间连接:
- 同一层内:边的权值 w ( u , v ) w(u,v) w(u,v)保持不变,按原图处理,即 d i s t [ v ] [ l a y ] = m i n ( d i s t [ v ] [ l a y ] , d i s t [ u ] [ l a y ] + w ) dist[v][lay]=min(dist[v][lay],\ dist[u][lay]+w) dist[v][lay]=min(dist[v][lay], dist[u][lay]+w) 。
- 跨层转移:若允许使用特权,则从当前层的节点 u i u_i ui 向下一层的节点 v i + 1 v_{i+1} vi+1 添加一条边,权值根据问题设定调整(如变为 0 0 0 ),即 d i s t [ v ] [ l a y + 1 ] = m i n ( d i s t [ v ] [ l a y + 1 ] , d i s t [ u ] [ l a y ] ) dist[v][lay+1]=min(dist[v][lay+1],dist[u][lay]) dist[v][lay+1]=min(dist[v][lay+1],dist[u][lay]) 。
- 算法选择:
在分层后的图上,使用 Dijkstra 或 SPFA 计算从起点 s t st st 到所有层终点 e d ed ed 的最短路径,最终取最小值。
vector<vector<pair<int, int>>> g(N);
int dist[N][K]; // dist[i][j] 表示使用了 j 次特权后到达节点 i 的最短距离
int n, k; // 节点数 特权次数限制
void lay_dijkstra(int st, int ed) {
for (int i = 0; i < n; ++i) {
for (int j = 0; j <= k; ++j) {
dist[i][j] = INT_MAX;
}
}
dist[st][0] = 0;
// 距离 结点 层
priority_queue<tuple<int, int, int>, vector<tuple<int, int, int>>, greater<tuple<int, int, int>>> pq;
pq.emplace(0, st, 0);
while(pq.size()) {
auto [d, u, lay] = pq.top(); pq.pop();
// 提前结束循环
if(u == ed) {
cout << d << endl;
return;
}
// 处理过更优解
if(d > dist[u][lay]) continue;
for(auto [v, w] : g[u]) {
// 不使用特权进行同层移动
if(dist[v][lay] > d + w) {
dist[v][lay] = d + w;
pq.emplace(d + w, v, lay);
}
// 使用特权转移到下一层
if(lay < k && dist[v][lay + 1] > d) {
dist[v][lay + 1] = d;
pq.emplace(d, v, lay + 1);
}
}
}
int ans = INT_MAX;
for(int i = 0; i <= k; i++) {
ans = min(ans, dist[ed][i]);
}
cout << ans << endl;
}
- 如果题目的路径权值定义为 路径上边权最大的权值 ,那么还可以用二分答案法结合双端队列 B F S BFS BFS 解决。由于此时答案具有单调性,花费越多,其合法的操作方案一定包含了花费更少的操作方案,问题转化为:当花费少于等于 l i m i t limit limit 的代价时,能否只进行少于等于 k k k 次特权操作 。
- 对于转化后的问题,当路径上的边权大于 l i m i t limit limit 时,需要进行一次特权操作。那么将边权大于 l i m i t limit limit 的看作 1 1 1 ;小于等于 l i m i t limit limit 的看作 0 0 0 。然后求 s t st st 到 e d ed ed 的最短路(最少特权操作次数)即可,如果 d i s t [ e d ] ≤ k dist[ed]\le k dist[ed]≤k ,那么说明此时满足条件。对于这种边权只有 0 , 1 0,1 0,1 的特殊最短路问题,可以用双端队列模拟优先队列。将 0 0 0 置于队头;将 1 1 1 置于队尾。
- 代码如下,需要注意最后对答案进行一次 c h e c k check check 来判断是否有解。
vector<vector<pair<int, int>>> g(N);
int dist[N]; // 最短路数组 & 最少特权操作次数
int n, k; // 节点数 特权次数限制
void solve(int st, int ed) {
// 二分答案
auto check = [&](int limit) -> bool {
deque<int> q; // 双端队列用于进行 bfs
memset(dist, 0x3f3f3f3f, sizeof(dist));
dist[st] = 0;
q.push_back(st);
while(q.size()) {
int u = q.front(); q.pop_front();
if(u == ed) break;
for(auto [v, w] : g[u]) {
w = w > limit; // 边权判断
if(w + dist[u] >= dist[v]) continue;
dist[v] = w + dist[u];
if(w) q.push_back(v);
else q.push_front(v);
}
}
return dist[ed] <= k;
};
int l = -1, r = 5e7;
while((l + 1) ^ r) {
int mid = (r + l) >> 1;
(check(mid) ? r : l) = mid;
}
cout << (check(r) ? r : -1) << endl;
}
三、全源最短路
1. Floyd 算法
- 思想:用每一个节点点去 松弛 别的点。遍历当前节点 k k k 的所有入边 ( u , k ) (u, k) (u,k) ,再遍历 k k k 的所有出边 ( k , v ) (k,v) (k,v) ,若 d i s t [ u ] [ v ] > d i s t [ u ] [ k ] + d i s t [ k ] [ v ] dist[u][v]\gt dist[u][k]+dist[k][v] dist[u][v]>dist[u][k]+dist[k][v] ,则进行松弛操作,最外层循环遍历完所有节点为止。
- 注意:由于算法复杂度为 O ( n 3 ) O(n^3) O(n3) ,常用于 n < 1 0 3 n\lt10^3 n<103 的用邻接矩阵存储的图。
const int INF = 0x3f3f3f3f;
int dist[N][N];
void Floyd(int n) {
for(int k = 0; k < n; k++) {
for(int i = 0; i < n; i++) {
if(dist[i][k] >= INF) continue;
for(int j = 0; j < n; j++)
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
2. Johnson 算法
-
思想:
-
引入超级源点与所有结点相连,用 SPFA 计算势能数组 h [ u ] h[u] h[u] ,定义势能数组为超级源点到所有节点的最短距离,并初始化为 0 0 0 ,运行过程中检测是否存在负环。 对于负边权,势能数组满足 h [ u ] + w ≥ h [ v ] h[u]+w\ge h[v] h[u]+w≥h[v] ,变形可得 h [ u ] − h [ v ] + w ≥ 0 h[u]-h[v]+w\ge0 h[u]−h[v]+w≥0 ,故新边权 w ′ = h [ u ] − h [ v ] + w ≥ 0 w'=h[u]-h[v]+w\ge0 w′=h[u]−h[v]+w≥0 。
-
由于满足了所有边权非负,Dijkstra 算法也就有了可行性,对每个结点操作该算法。
-
每次用 d i s dis dis 数组记录当前运行 Dijkstra 遍历到的单个源点 i i i 的最短路径,并进行边权还原操作。推导如下:
d i s [ j ] = ∑ < u , v > ∈ p a t h w ′ < u , v > = ∑ < u , v > ∈ p a t h ( w < u , v > + h [ u ] + h [ v ] ) = ∑ < u , v > ∈ p a t h w < u , v > + h [ i ] − h [ j ] \begin{aligned} dis[j]&=\sum_{<u,v>\in path}w'<u,v>\\ &=\sum_{<u,v>\in path}(w<u,v>+h[u]+h[v])\\ &=\sum_{<u,v>\in path} w<u,v>+h[i]-h[j] \end{aligned} dis[j]=<u,v>∈path∑w′<u,v>=<u,v>∈path∑(w<u,v>+h[u]+h[v])=<u,v>∈path∑w<u,v>+h[i]−h[j]
其中 ∑ < u , v > ∈ p a t h w < u , v > = d i s t [ i ] [ j ] = d i s [ j ] − h [ i ] + [ j ] \sum\limits_{<u,v>\in path}w<u,v>=dist[i][j]=dis[j]-h[i]+[j] <u,v>∈path∑w<u,v>=dist[i][j]=dis[j]−h[i]+[j] ,即还原边权。
-
int n, m; // 节点数 有向边数
vector<vector<pii>> g(N);
bool inq[N];
int cnt[N], h[N], dis[N], dist[N][N];
bool SPFA(int src) {
queue<int> q;
q.push(src);
memset(h, 63, sizeof(h));
inq[src] = true, h[src] = 0;
while(q.size()) {
int u = q.front(); q.pop();
inq[u] = false;
for(auto [v, w] : g[u]) {
if(h[v] <= w + h[u]) continue;
h[v] = w + h[u];
cnt[v] = cnt[u] + 1;
// 由于引入了一个源点导致判断条件变为 > n
if(cnt[v] > n) return false;
if(inq[v]) continue;
inq[v] = true;
q.push(v);
}
}
return true;
}
void Dijkstra(int src) {
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;
fill(dis, dis + 1 + n, INT_MAX);
dis[src] = 0;
pq.emplace(0, src);
while(pq.size()) {
auto [d, u] = pq.top(); pq.pop();
if(d > dis[u]) continue;
for(auto [v, w] : g[u]) {
if(d + w >= dis[v]) continue;
dis[v] = d + w;
pq.emplace(d + w, v);
}
}
}
void Johnson() {
// 引入超级源点
for(int i = 1; i <= n; i++) g[0].emplace_back(i, 0);
// 判断负环
if(!SPFA(0)) {
cout << -1 << endl;
return;
}
// 调整边权为非负值
for(int u = 1; u <= n; u++) {
for(auto &[v, w] : g[u]) {
w += h[u] - h[v];
}
}
// 计算最短路
for(int i = 1; i <= n; i++) {
Dijkstra(i);
for(int j = 1; j <= n; j++) {
dist[i][j] = h[j] - h[i] + dis[j];
}
}
return 0;
}
3. 算法比较
算法对比表:
对比项 | Johnson算法 | Floyd算法 |
---|---|---|
适用场景 | 稀疏图(边数较少) | 稠密图(边数较多) |
时间复杂度 | O ( V E l o g E + V E ) O(VElogE+VE) O(VElogE+VE)( Dijkstra 优化版) | O ( V 3 ) O(V^3) O(V3) |
空间复杂度 | O ( V + E ) O(V+E) O(V+E)(邻接表存储) | O ( V 2 ) O(V^2) O(V2)(邻接矩阵存储) |
负权边支持 | ✅ 支持(需无负环) | ✅ 支持(需无负环) |
核心思想 | 1. 引入虚拟节点,SPFA/Bellman-Ford 计算势能 2. 调整边权为非负 3. 跑 n n n 次 Dijkstra | 动态规划,三重循环枚举中转点 k k k ,更新最短路径 |
四、图的直径
图的绝对中心:图的绝对中心可以存在于一条边或某个节点上,该中心到所有点的最短距离的最大值最小。
图的直径:根据 图的绝对中心 的定义可知,由于对称性,到绝对中心距离最远的节点至少有两个。任取两个到中心距离最远的节点,这两个节点的最短距离即为图的直径。
思路:令 d ( i , j ) d(i,j) d(i,j) 表示顶点 i , j i,j i,j 间的最短路径长, r k ( i , j ) rk(i,j) rk(i,j) 表示点 i i i 的所有可达节点中第 j j j 小节点。
- 当图的绝对中心在边上时:枚举每一条边 w = ( u , v ) w=(u,v) w=(u,v) ,假设图的绝对中心 c c c 在这条边上。取 d ( u , c ) = x d(u,c)=x d(u,c)=x ,那么 d ( c , v ) = w − x d(c,v)=w-x d(c,v)=w−x 。此时,对于图上任意一点 i i i ,有 d ( i , c ) = m i n ( d ( i , u ) + d ( u , c ) , d ( i , v ) + d ( v , c ) ) d(i,c)=min(d(i,u)+d(u,c),\ d(i,v)+d(v,c)) d(i,c)=min(d(i,u)+d(u,c), d(i,v)+d(v,c)) 。从距离 u u u 最远的节点开始更新,也就是从 r k ( k , n ) rk(k,n) rk(k,n) 开始向前遍历 r k ( u , i ) rk(u,i) rk(u,i) 。当 d ( v , r k ( u , i ) ) > m a x j = i + 1 n d ( v , r k ( u , j ) ) d(v,rk(u,i))\gt max^{n}_{j=i+1}d(v,rk(u,j)) d(v,rk(u,i))>maxj=i+1nd(v,rk(u,j)) 时,图的绝对中心可能会发生改变,此时有 a n s = m i n ( a n s , d ( u , r k ( u , i ) ) + m a x j = i + 1 n d ( v , r k ( u , j ) ) + w ( u , v ) ) ans=min(ans,d(u,rk(u,i))+max^{n}_{j=i+1}d(v,rk(u,j))+w(u,v)) ans=min(ans,d(u,rk(u,i))+maxj=i+1nd(v,rk(u,j))+w(u,v)) 。
- 当图的绝对中心在节点上时:遍历所有节点有 a n s = m i n ( a n s , d ( i , r k ( i , n ) ) × 2 ) ans = min(ans,d(i,rk(i,n))\times2) ans=min(ans,d(i,rk(i,n))×2) 。
int n; // 节点数
int rk[N][N], val[N];
vector<tuple<int, int, int>> edges(m + 1);
void solve() {
Floyd(n); // 求全源最短路
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++) {
rk[i][j] = j;
val[j] = dist[i][j];
}
sort(val + 1, val + 1 + n, [](int a, int b) {return val[a] < val[b];});
}
int ans = INT_MAX;
// 绝对中心在节点上时
for(int i = 1; i <= n; i++) ans = min(ans, d[i][rk[i][n]] * 2);
// 绝对中心在边上时
for(int i = 1; i <= m; i++) {
auto [u, v, w] = edges[i];
for(int p = n, j = n - 1; j >= 1; j--) {
if(d[v][rk[u][i]] > d[v][rk[u][p]]) {
ans = min(ans, d[u][rk[u][j]] + d[v][rk[u][p]] + w);
p = j;
}
}
}
}