最短路问题

最短路问题

  • 松弛操作:设 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 n1 ,每次松弛会使得最短路边数至少增加一条,故整个算法最多执行 n − 1 n-1 n1 轮次循环。
  • 优化:并不是在任意一次循环中都需要对所有的边进行松弛操作,当在一次循环中只优化了少数几个节点时,只需要在下次循环中以这些节点为基础再进行松弛操作,依次类推。这就是 SPFA 算法,其本质为 经队列优化的 Bellman-Ford 算法
  • 判断负边负环:当进行 n − 1 n-1 n1 次循环后,如果再进行第 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)拓扑排序,动态规划

关键说明:

  1. Dijkstra:适合正权图的最短路径,效率高,但不能处理负权边。未优化时适用于 稠密图,优化后适用于 稀疏图
  2. Bellman-Ford:能处理负权边并检测负权环,但时间复杂度较高。
  3. SPFA (Shortest Path Faster Algorithm):是Bellman-Ford的优化版本,适合稀疏图。
  4. BFS:仅适用于无权图(或边权均为 1 1 1 的图)。
  5. DAG最短路径:利用拓扑排序,线性时间解决DAG的最短路径问题,但不能处理环(包括负权环)。

算法选择

  • 无负权边Dijkstra(效率最高)
  • 有负权边Bellman-FordSPFA
  • DAG(有向无环图)拓扑排序+DAG最短路径 O ( V + E ) O(V+E) O(V+E)最优)
  • 无权图BFS(最简单高效)

二、分层图最短路

题目描述:在最多允许进行 k k k 次特权操作的情况下,求起点 s t st st 到终点 e d ed ed 的最短路。

核心思路:

  1. 分层构建
    将原图复制为 k + 1 k+1 k+1 层(假设最多允许使用 k k k 次特殊操作),即扩充维度。第 i i i 层表示已使用 i i i 次特权后的状态。例如,若最多允许将边权置零 k k k 次,每使用一次特权,则进入下一层。

  2. 层间连接

  • 同一层内:边的权值 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])
  1. 算法选择
    在分层后的图上,使用 DijkstraSPFA 计算从起点 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 算法

  • 思想

    1. 引入超级源点与所有结点相连,用 SPFA 计算势能数组 h [ u ] h[u] h[u] ,定义势能数组为超级源点到所有节点的最短距离,并初始化为 0 0 0 ,运行过程中检测是否存在负环。 对于负边权,势能数组满足 h [ u ] + w ≥ h [ v ] h[u]+w\ge h[v] h[u]+wh[v] ,变形可得 h [ u ] − h [ v ] + w ≥ 0 h[u]-h[v]+w\ge0 h[u]h[v]+w0 ,故新边权 w ′ = h [ u ] − h [ v ] + w ≥ 0 w'=h[u]-h[v]+w\ge0 w=h[u]h[v]+w0

    2. 由于满足了所有边权非负,Dijkstra 算法也就有了可行性,对每个结点操作该算法。

    3. 每次用 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>∈pathw<u,v>=<u,v>∈path(w<u,v>+h[u]+h[v])=<u,v>∈pathw<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>∈pathw<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 nDijkstra动态规划,三重循环枚举中转点 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)=wx 。此时,对于图上任意一点 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;
            }
        }
    }
}

题目链接

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值