引言
在图论中,全源最短路径(All-Pairs Shortest Paths, APSP)问题要求计算图中任意两点之间的最短距离。Floyd-Warshall算法以O(V³)的复杂度解决这一问题,但在稀疏图上效率不高。Dijkstra算法虽然高效,却无法处理负权边。
Johnson算法巧妙地结合了Bellman-Ford和Dijkstra算法,在稀疏图上实现了O(V²logV + VE)的优秀复杂度,同时能够正确处理负权边(只要不是负权环)。本文将用Java完整实现这一经典算法。
算法核心思想
Johnson算法的核心步骤分为三步:
- 添加虚拟源点:构造一个新图,添加一个虚拟源点s,从s到所有其他顶点添加权重为0的边。
- 运行Bellman-Ford:从虚拟源点s运行Bellman-Ford算法,计算到所有顶点的最短距离h(v)。如果检测到负权环,则算法终止。
- 重新赋权(Reweighting):对每条边(u, v),将其权重更新为 w'(u,v) = w(u,v) + h(u) – h(v)。这样所有边的权重变为非负。
- 运行V次Dijkstra:在新的非负权图上,对每个顶点运行Dijkstra算法,得到全源最短路径。
- 还原距离:将计算出的距离减去h(u)-h(v)的偏移量,得到真实最短路径。
重新赋权的数学原理:对于任意路径p = v₀→v₁→…→vₖ,有
[\sum_{i=1}^{k} w'(v_{i-1}, v_i) = \sum_{i=1}^{k} w(v_{i-1}, v_i) + h(v_0) – h(v_k)]
因此,新权重下的最短路径对应原权重下的最短路径。
Java完整实现
import java.util.*;
/**
* Johnson算法实现稀疏图全源最短路径
* 时间复杂度: O(V²logV + VE)
* 空间复杂度: O(V²)
*/
public class JohnsonAlgorithm {
// 表示无穷大
private static final int INF = Integer.MAX_VALUE / 2;
// 图的邻接表表示
static class Edge {
int to; // 目标顶点
int weight; // 边权重
Edge(int to, int weight) {
this.to = to;
this.weight = weight;
}
}
static class Graph {
int n; // 顶点数
List<List<Edge>> adj; // 邻接表
Graph(int n) {
this.n = n;
adj = new ArrayList<>();
for (int i = 0; i < n; i++) {
adj.add(new ArrayList<>());
}
}
void addEdge(int from, int to, int weight) {
adj.get(from).add(new Edge(to, weight));
}
}
/**
* Johnson算法主入口
* @param g 输入图
* @return 全源最短路径矩阵,如果存在负权环返回null
*/
public static int[][] johnson(Graph g) {
int n = g.n;
// Step 1: 添加虚拟源点,构建新图
// 新图有n+1个顶点,第n个为虚拟源点
Graph newG = new Graph(n + 1);
for (int u = 0; u < n; u++) {
for (Edge e : g.adj.get(u)) {
newG.addEdge(u, e.to, e.weight);
}
// 从虚拟源点到所有顶点添加权重为0的边
newG.addEdge(n, u, 0);
}
// Step 2: 运行Bellman-Ford计算h值
int[] h = new int[n + 1];
if (!bellmanFord(newG, n, h)) {
System.out.println("图中存在负权环,无法计算最短路径");
return null;
}
// Step 3: 重新赋权
Graph reweighted = new Graph(n);
for (int u = 0; u < n; u++) {
for (Edge e : g.adj.get(u)) {
// w'(u,v) = w(u,v) + h(u) - h(v)
int newWeight = e.weight + h[u] - h[e.to];
reweighted.addEdge(u, e.to, newWeight);
}
}
// Step 4: 对每个顶点运行Dijkstra算法
int[][] dist = new int[n][n];
for (int u = 0; u < n; u++) {
dijkstra(reweighted, u, dist[u]);
}
// Step 5: 还原真实距离
// dist[u][v] = dist'[u][v] - h[u] + h[v]
for (int u = 0; u < n; u++) {
for (int v = 0; v < n; v++) {
if (dist[u][v] < INF) {
dist[u][v] = dist[u][v] - h[u] + h[v];
}
}
}
return dist;
}
/**
* Bellman-Ford算法,检测负权环并计算最短距离
* @param g 图(含虚拟源点)
* @param source 源点
* @param dist 距离数组(输出)
* @return true表示无负权环,false表示存在负权环
*/
private static boolean bellmanFord(Graph g, int source, int[] dist) {
int n = g.n;
Arrays.fill(dist, INF);
dist[source] = 0;
// 松弛n-1次
for (int i = 0; i < n - 1; i++) {
boolean updated = false;
for (int u = 0; u < n; u++) {
if (dist[u] == INF) continue;
for (Edge e : g.adj.get(u)) {
if (dist[u] + e.weight < dist[e.to]) {
dist[e.to] = dist[u] + e.weight;
updated = true;
}
}
}
if (!updated) break; // 提前终止优化
}
// 检测负权环:再进行一次松弛
for (int u = 0; u < n; u++) {
if (dist[u] == INF) continue;
for (Edge e : g.adj.get(u)) {
if (dist[u] + e.weight < dist[e.to]) {
return false; // 存在负权环
}
}
}
return true;
}
/**
* Dijkstra算法(使用优先队列)
* @param g 图(边权重非负)
* @param source 源点
* @param dist 距离数组(输出)
*/
private static void dijkstra(Graph g, int source, int[] dist) {
int n = g.n;
Arrays.fill(dist, INF);
dist[source] = 0;
// 优先队列: (当前距离, 顶点)
PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
pq.offer(new int[]{0, source});
while (!pq.isEmpty()) {
int[] curr = pq.poll();
int d = curr[0], u = curr[1];
// 跳过过期条目
if (d > dist[u]) continue;
for (Edge e : g.adj.get(u)) {
if (dist[u] + e.weight < dist[e.to]) {
dist[e.to] = dist[u] + e.weight;
pq.offer(new int[]{dist[e.to], e.to});
}
}
}
}
/**
* 打印距离矩阵
*/
public static void printMatrix(int[][] dist) {
int n = dist.length;
System.out.println("全源最短路径矩阵:");
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (dist[i][j] == INF) {
System.out.print("INF\t");
} else {
System.out.print(dist[i][j] + "\t");
}
}
System.out.println();
}
}
public static void main(String[] args) {
// 构造测试图:5个顶点的稀疏图,含负权边
Graph g = new Graph(5);
g.addEdge(0, 1, 3);
g.addEdge(0, 2, 8);
g.addEdge(0, 4, -4);
g.addEdge(1, 3, 1);
g.addEdge(1, 4, 7);
g.addEdge(2, 1, 4);
g.addEdge(3, 0, 2);
g.addEdge(3, 2, -5);
g.addEdge(4, 3, 6);
int[][] dist = johnson(g);
if (dist != null) {
printMatrix(dist);
// 验证:0到2的最短路径应为0→4→3→2 = -4+6-5 = -3
System.out.println("\n0到2的最短距离: " + dist[0][2] + " (预期: -3)");
// 验证:1到4的最短路径应为1→3→0→4 = 1+2-4 = -1
System.out.println("1到4的最短距离: " + dist[1][4] + " (预期: -1)");
}
// 测试负权环检测
System.out.println("\n--- 负权环检测测试 ---");
Graph cycleGraph = new Graph(3);
cycleGraph.addEdge(0, 1, 1);
cycleGraph.addEdge(1, 2, -3);
cycleGraph.addEdge(2, 0, 1); // 0→1→2→0 = -1,负权环
int[][] cycleDist = johnson(cycleGraph);
System.out.println(cycleDist == null ? "正确检测到负权环" : "检测失败");
}
}
复杂度分析
| 阶段 | 时间复杂度 | 说明 |
|---|---|---|
| Bellman-Ford | O(VE) | 检测负权环并计算h值 |
| 重新赋权 | O(E) | 遍历所有边修改权重 |
| V次Dijkstra | O(V·(E + V)logV) | 使用二叉堆优先队列 |
| 总计 | O(V²logV + VE) | 稀疏图(E≈V)时为O(V²logV) |
与Floyd-Warshall的O(V³)相比,当图较为稀疏时(E << V²),Johnson算法具有显著优势。例如,对于一个有1000个顶点、5000条边的图,Johnson约需10⁷次操作,而Floyd-Warshall需要10⁹次。
应用场景
- 交通网络分析:城市道路网络通常是稀疏图,且可能因拥堵费出现负权边。
- 社交网络:计算任意两人之间的”距离”(如信任度、影响力传播)。
- 网络路由协议:某些动态路由场景需要计算全源最短路径。
总结
Johnson算法通过重新赋权技术,巧妙地消除了负权边的影响,使得Dijkstra算法得以在全源最短路径问题中应用。它是稀疏图上计算全源最短路径的最佳选择之一,体现了算法设计中”问题转化”的核心思想。