一.问题引入

给定一个包含 \(n\) 个结点和 \(m\) 条带权边的有向图,求所有点对间的最短路径长度。

在解决最短路问题时,负边权的情况是经常遇到的。

而这道题目要求求全源最短路径,我们会想到三个做法:

\(1.\) Spfa单源最短路径算法,时间复杂度为 \(O(nm)\)。求全源最短路径,枚举起点时间复杂度为 \(O(n^2m)\)

\(2.\) Floyd全源最短路径算法,时间复杂度为 \(O(n^3)\)

\(3.\) Dijkstra单源最短路径算法,时间复杂度为 \(O(n^2)\),经过堆优化可达到 \(O(n log m)\)。求全源最短路径,枚举起点时间复杂度为 \(O(n^2logm)\)

很明显,我们会选择第三种做法,这样的时间复杂度是最优的。

二.算法过程

Johnson提出了一种做法,就是按照模块一的第三种做法。

但是 Dijkstra 最短路径算法无法处理负边权,那怎么办呢?

下意识我们会将每个边权增加一个数字,但是这是错误的,因为这样会影响最短路径。

为了便于理解,这里给出oi-wiki的解释。

「学习笔记」Johnson 全源最短路_时间复杂度

如下图,\(1->2\) 的最短路径是 \(1->5->3->2\),最短路径长度为 \(-2\)

而当我们加上一个正整数 \(k\),如 \(5\),就会变成下图。

「学习笔记」Johnson 全源最短路_时间复杂度_02

这时, \(1->2\) 的最短路径却是 \(1->4->2\)

所以,这种做法会导致最短路径变化,所以是不可取的。

而 Johnson 算法提出了一种重新标注边权的方法:

我们建一个超级源点 \(0\),所有点与其连一条边权为 \(0\) 的边。

先采用 Spfa 算法将每个点与 超级源点 \(0\) 的最短路径长度算出来,这里记作 \(h_i\)

那么,我们在跑 \(n\) 遍 Dijkstra 前,修改边权。

设一条有向边 \(u->v\) 边权为 \(w\),那么将每条边的边权增加 \(h_u-h_v\),当然,最后统计答案的时候要将其减去。

接下来以每个点为源点跑 \(n\) 遍 Dijkstra 求答案即可。

最初采用 Spfa 判断负环,是否有解,并不影响时间复杂度。

而最后跑 \(n\) 遍 Dijkstra 的时间复杂度为 \(O(n^2logm)\),该算法就可以比较高效的求全源最短路径了。

三.证明正确性

关于 Johnson 全源最短路算法,比较巧妙的就是将每条边权调整为非负。

至于为什么加这个数字呢?

这里设一条有向边 \(u->v\) 边权为 \(w\),还有上文的 \(h_i\) 为每个点与超级源点 \(0\) 的最短路径长度。

那么,我们知道边权满足以下三角形不等式:

\(h_u + w ≥ h_v\)

将其变形为:

\(w - h_v + h_u≥ 0\)

那么这样,将 \(w\) 增加 \(h_u-h_v\),就能满足非负性。

注意,最后统计答案的时候需要将其减去。

四.模板代码

P5905 【模板】Johnson 全源最短路

这道题就是 Johnson 全源最短路算法的模板题了。

示范代码如下:

#include <cstdio>
#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>
#include <vector>
#include <functional>

using namespace std;

typedef long long ll;
typedef pair<int, int> pii;

#define int ll 

const int N = 16666;
const int inf = 1e9;

struct Edge {
	int nxt;
	int to;
	int w;
}e[N];

int head[N], edge_num = 0;

inline void add_edge (int x, int y, int z) {
	e[++ edge_num].to = y;
	e[edge_num].nxt = head[x];
	e[edge_num].w = z;
	head[x] = edge_num;
} 

int n, m, indeg[N], inque[N];

int d[N];

inline bool SPFA (int s) {//SPFA判断负环。 
	for (int i = 1; i <= n; i ++) {
		d[i] = inf;
	}
	memset (inque, false, sizeof (inque));
	
	queue<int> qwq;
	
	qwq.push(s);
	
	d[s] = 0, inque[s] = true;
	
	indeg[s] ++;
	
	while (!qwq.empty()) {
		int u = qwq.front();
		
		qwq.pop();
		
		inque[u] = false;
		
		for (int i = head[u]; i; i = e[i].nxt) {
			int v = e[i].to;
			
			if (d[v] > d[u] + e[i].w) {
				d[v] = d[u] + e[i].w;
				
				if (inque[v] == false) {
					qwq.push (v);
					
					inque[v] = true;
					
					indeg[v] ++;
						
					if (indeg[v] >= n + 1) {
						return true;
					}
				}
			}
		}
	}
	
	return false;
}

int dis[N];

bool vis[N];

inline void Dij (int s) {//求单源最短路径。 
	for (int i = 1; i <= n; i ++) {
		dis[i] = inf;
	}
	
	memset (vis, false, sizeof (vis));
	
	priority_queue<pii, vector<pii>, greater<pii> > q; 
	
	dis[s] = 0;
	
	q.push (make_pair(0, s));
	
	while (!q.empty()) {
		int u = q.top().second;
		
		q.pop();
		
		if (vis[u]) {
			continue;
		}
		
		vis[u] = true;
		
		for (int i = head[u]; i; i = e[i].nxt) {
			int v = e[i].to;
			
			if (dis[v] > dis[u] + e[i].w) {
				dis[v] = dis[u] + e[i].w;
				
				if (vis[v] == false) {
					q.push (make_pair(dis[v], v));
				}
			}
		}
	}
}

signed main() {
	scanf ("%lld%lld", &n, &m);
	for (int i = 1, x, y, z; i <= m; i ++) {
		scanf ("%lld%lld%lld", &x, &y, &z);
		
		add_edge (x, y, z);
	}
	
	for (int i = 1; i <= n; i ++) {
		add_edge (0, i, 0);
	}
	
	if (SPFA (0) == true) {
		//出现负环无解。 
		printf ("-1");
		
		return 0;
	}
	
	for (int i = 1; i <= n; i ++) {
		for (int j = head[i]; j; j = e[j].nxt) {
			int v = e[j].to;
			
			e[j].w += d[i] - d[v]; //使边权满足非负性。 
		}
	}
	
	for (int i = 1; i <= n; i ++) {
		Dij (i);
		
		ll ans = 0;
		
		for (int j = 1; j <= n; j ++) {
			if (dis[j] == inf) {
				ans += 1ll * inf * j;
			}
			
			else {
				ans += 1ll * j * (dis[j] - d[i] + d[j]);//要将其减去。 
			}
		}
		
		printf ("%lld\n", ans);
	} 
	
	return 0;
}

另外,该题还涉及到 \(2\) 道模板题目,如下:
P3385 【模板】负环

P4779 【模板】单源最短路径(标准版)

到此,您就学会了 Johnson 全源最短路算法。