物流串线场景下的全源最短路径算法------Johnson 算法原理与实现
一、问题背景:什么是物流串线
在物流调度系统中,串线(Route Crossing)指一个运单的货物从始发网点出发,经多个中转网点最终送达目的网点的全过程路径规划。一个典型的物流网络包含数十到数百个网点(枢纽),网点间通过干线运输相连,每条线路具有不同的运输成本(距离、时效、费用)。
核心需求
调度系统需要实时回答以下问题:
任意两个网点之间,成本最低的运输路线是什么?如果存在备选线路(中转),最优成本又是多少?
这本质上是一个 全源最短路径 (All-Pairs Shortest Path, APSP)问题------我们需要一次性计算出所有网点对之间的最短距离,然后供串线匹配、路径推荐、成本预估等下游模块查询。
二、物流网络建模
2.1 有向带权图
将物流网络建模为有向带权图 ( G = (V, E) ):
-
顶点 ( V ):物流网点(分拨中心、中转站、末端站点)
-
有向边 ( (u, v) ):从网点 ( u ) 到网点 ( v ) 的运输线路
-
边权 ( w(u, v) ):运输成本,可以是距离、时效或综合费用
┌──────┐ 5 ┌──────┐ │ 北京 │ ──────▶│ 上海 │ └──┬───┘ └──┬───┘ │ ┌─────────────┘ │8 │ -2 (返程补贴线路) ▼ ▼ ┌──────┐ 4 ┌──────┐ │ 武汉 │ ──────▶│ 广州 │ └──┬───┘ └──────┘ │ ┌───────────┘ │5 │1 ▼ ▼ ┌──────┐ │ 深圳 │ └──────┘
2.2 负权边的现实意义
物流网络中确实可能出现负权边,例如:
- 返程补贴:为降低空载率,部分线路给予运费折扣甚至补贴(表现为负成本)
- 拼车优惠:两个运单合并运输时,边际成本为负
- 时效折算:将提前到达的奖励折算为负成本
传统 Dijkstra 算法无法处理负权边(会陷入死循环或错误结果),这给算法选型带来了挑战。
三、算法选型:为什么选 Johnson
候选方案对比
| 算法 | 时间复杂度 | 负权边 | 稀疏图表现 | 适用场景 |
|---|---|---|---|---|
| Floyd-Warshall | ( O(V^3) ) | ✅ | 差(全矩阵运算) | 稠密图 ( E \approx V^2 ) |
| 重复 Dijkstra | ( O(V \cdot (E + V \log V)) ) | ❌ | 好 | 仅非负权图 |
| Bellman-Ford 重复 | ( O(V^2 E) ) | ✅ | 差 | 极小规模图 |
| Johnson | ( O(V E + V^2 \log V) ) | ✅ | 好 | 稀疏图 + 负权边 |
选择理由
物流网络是典型的稀疏图------每个网点通常只连接 3~8 个相邻网点,即 ( E \ll V^2 )。因此:
- Floyd-Warshall 的 ( O(V^3) ) 对于 500 个网点意味着 1.25 亿次运算,严重浪费
- Johnson 在稀疏图上的 ( O(VE + V^2 \log V) ) 约为 Floyd 的 ( 1/V ) 量级
- Johnson 兼有负权边处理能力和稀疏图性能优势,是物流场景的最优解
四、Johnson 算法原理
Johnson 算法的核心思想是 「势能重赋权」(Potential Re-weighting),将含负权边的图魔法般地转成非负权图,然后放心跑 Dijkstra。
4.1 直观理解
想象我们给每个网定义一个「海拔高度」( h(v) ):
- 从高处流向低处,重力做功 → 边权减少
- 但对任意路径,起点和终点的海拔差固定,不影响路径间的相对排序
数学上,定义势能函数 ( h: V \rightarrow \mathbb{R} ),对每条边重赋权:
w'(u, v) = w(u, v) + h(u) - h(v)
关键是:对任意 ( u \rightarrow v ) 的路径 ( P ),重赋权后的路径长度与原路径长度仅差一个常数 ( h(u) - h(v) ),因此最短路径保持不变!
4.2 如何构造 h(v)
势能函数需要满足 ( w'(u, v) \ge 0 ),即:
w(u, v) + h(u) - h(v) \\ge 0 \\quad \\Rightarrow \\quad h(v) \\le h(u) + w(u, v)
这正是三角不等式!也就是说,( h(v) ) 就是从某个源点出发的最短路径距离。因此:
- 添加虚拟源点 ( s ),向所有顶点连权为 0 的边
- 对扩展图运行 Bellman-Ford,以 ( s ) 为起点求得 ( h(v) = \delta(s, v) )
- 此时 ( h(v) \le h(u) + w(u, v) ) 自动成立 → ( w'(u, v) \ge 0 )
4.3 四步全景
Step 1: 添加虚拟源点 Step 2: Bellman-Ford求h(v)
┌───┐ h(A)=0, h(B)=-2, h(C)=-1 ...
│ s │──0──▶ A,B,C,D │
└───┘ ▼
Step 3: 重赋权
w'(u,v)=w(u,v)+h(u)-h(v) ≥ 0
│
▼
Step 4: 每个顶点跑Dijkstra
然后还原: d(u,v)=d'(u,v)-h(u)+h(v)
五、Java 实现详解
5.1 数据结构
java
static class Edge {
int to; // 目标节点
int weight; // 边权(允许为负)
}
// 邻接表表示稀疏图
List<List<Edge>> graph;
5.2 Bellman-Ford(带提前终止优化)
java
private static int[] bellmanFord(List<List<Edge>> graph, int s) {
int n = graph.size();
int[] dist = new int[n];
Arrays.fill(dist, INF);
dist[s] = 0;
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 : graph.get(u)) {
if (dist[u] + e.weight < dist[e.to]) {
dist[e.to] = dist[u] + e.weight;
updated = true;
}
}
}
if (!updated) break; // ⭐ 无松弛则提前退出
}
// 第 V 轮检测负权环
for (int u = 0; u < n; u++) {
if (dist[u] == INF) continue;
for (Edge e : graph.get(u)) {
if (dist[u] + e.weight < dist[e.to]) {
return null; // 存在负权环,算法终止
}
}
}
return dist;
}
提前终止优化:对于大多数实际物流网络(无负权环),Bellman-Ford 往往在 2~3 轮内收敛,加上此优化后实际开销远小于理论最坏 ( O(VE) )。
5.3 重赋权核心
java
// 计算势能
int[] h = bellmanFord(augmented, n);
// 重赋权:w'(u,v) = w(u,v) + h(u) - h(v) ≥ 0
List<List<Edge>> reweighted = new ArrayList<>();
for (int u = 0; u < n; u++) {
for (Edge e : graph.get(u)) {
int newWeight = e.weight + h[u] - h[e.to];
newEdges.add(new Edge(e.to, newWeight));
}
}
为什么非负:因为 ( h(v) ) 是 Bellman-Ford 的最短距离,满足三角不等式 ( h(v) \le h(u) + w(u,v) ),移项即得 ( h(u) + w(u,v) - h(v) \ge 0 )。
5.4 最短路径还原
java
// 对每个顶点运行 Dijkstra
for (int u = 0; u < n; u++) {
int[] d = dijkstra(reweighted, u); // 重赋权图上的最短路
for (int v = 0; v < n; v++) {
// 还原真实距离:d(u,v) = d'(u,v) - h(u) + h(v)
dist[u][v] = d[v] - h[u] + h[v];
}
}
还原公式推导:对任意路径 ( P: u \rightsquigarrow v ):
\\begin{aligned} w'§ \&= \\sum_{(x,y)\\in P} \[w(x,y) + h(x) - h(y)\] \\ \&= \\sum w(x,y) + h(u) - h(v) \\quad\\text{(中间项相互抵消)} \\ \&= w§ + h(u) - h(v) \\end{aligned}
所以 ( d'(u,v) = d(u,v) + h(u) - h(v) ),移项即得还原公式。
六、物流串线中的应用
6.1 离线预计算 + 在线查询
java
// 1. 系统启动时:构建物流网络图
List<List<Edge>> logisticsGraph = buildLogisticsGraph();
// 2. 运行 Johnson 算法,预计算全源最短路径距离矩阵
int[][] apsp = Johnson.johnson(logisticsGraph);
// 3. 串线匹配时:O(1) 查询
int cost = apsp[originHub][destHub]; // 任意两网点间最优成本
6.2 串线推荐流程
┌─────────────────────┐
│ APSP 距离矩阵 │
│ (Johnson预计算) │
└──────┬──────────────┘
│ O(1) 查询
▼
运单A: 北京→深圳 ──▶ 北京→深圳 最优成本 = C1
运单B: 上海→广州 ──▶ 上海→广州 最优成本 = C2
│
┌───────▼────────┐
│ 串线匹配引擎 │
│ 判断是否能合并 │
│ 运输节省成本 │
└───────┬────────┘
▼
推荐串线方案:
北京→上海(A货) + 上海→深圳(A货)
上海→广州(B货) + 广州→深圳(B货)
6.3 性能表现
以 500 个网点的物流网络为例(每个网点平均连接 5 条边):
| 阶段 | 运算量 | 实际耗时(估算) |
|---|---|---|
| Bellman-Ford 求势能 | ( 500 \times 2500 ) 次松弛 | ~5ms |
| V 次 Dijkstra | ( 500 \times (2500 + 500\log 500) ) | ~200ms |
| 总计 | --- | ~205ms |
对比 Floyd-Warshall 的 ( 500^3 = 1.25 ) 亿次 → 约 1200ms ,Johnson 快约 6 倍。网络规模越大,优势越明显。
七、工程优化要点
7.1 负权环检测
物流网络中不应存在负权环(否则意味着无限套利),Johnson 算法在 Bellman-Ford 阶段自动检测:
java
if (h == null) {
System.err.println("图中存在负权环!请检查线路数据");
return null;
}
7.2 整数边权 vs 浮点边权
本实现使用 int 类型表示边权,适合以分为单位 的成本计算,避免浮点精度问题。如需小数支持,可改为 double,同时在松弛判断中使用 dist[u] + w < dist[v] - EPS。
7.3 增量更新
网点/线路变化时,不需要完全重算 APSP 矩阵。可维护增量 Johnson:仅更新受影响的行列,将更新成本从 ( O(VE) ) 降低到 ( O(E + V\log V) )。
八、总结
Johnson 算法在物流串线场景下是一个「恰到好处」的选择:
- ✅ 处理负权边:适应返程补贴、拼车优惠等真实业务场景
- ✅ 稀疏图高效:利用邻接表 + 优先队列,时间复杂度优于 Floyd-Warshall
- ✅ 一次性预计算:APSP 矩阵构建后支持 ( O(1) ) 实时查询
- ✅ 负权环检测:自动发现数据异常,保障调度系统稳定性
java
/**
* Johnson's Algorithm ------ 全源最短路径算法
* <p>
* 支持含负权边的有向图,时间复杂度 O(V² log V + VE)
* <p>
* 核心步骤:
* 1. 新增虚拟源点 s,向所有顶点连权为 0 的边
* 2. 用 Bellman-Ford 从 s 求势能函数 h(v)
* 3. 重赋权:w'(u,v) = w(u,v) + h(u) - h(v),使所有边权非负
* 4. 对每个顶点运行 Dijkstra,再还原真实距离
*/
public class Johnson {
/* ========== 1. 数据结构定义 ========== */
static class Edge {
int to; // 目标节点
int weight; // 边权
Edge(int to, int weight) {
this.to = to;
this.weight = weight;
}
}
private static final int INF = Integer.MAX_VALUE;
/* ========== 2. Bellman-Ford 求势能 ========== */
/**
* Bellman-Ford 算法:从虚拟源点出发计算势能函数 h(v)
*
* @param graph 邻接表(含虚拟源点)
* @param s 虚拟源点编号
* @return 势能数组 h[],若存在负权环则返回 null
*/
private static int[] bellmanFord(List<List<Edge>> graph, int s) {
int n = graph.size();
int[] dist = new int[n];
Arrays.fill(dist, INF);
dist[s] = 0;
// 松弛 V-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 : graph.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 : graph.get(u)) {
if (dist[u] + e.weight < dist[e.to]) {
return null; // 存在负权环
}
}
}
return dist;
}
/* ========== 3. Dijkstra 单源最短路 ========== */
/**
* 在非负权图上运行 Dijkstra 算法
*
* @param graph 重赋权后的邻接表
* @param start 起点编号
* @return 最短距离数组
*/
private static int[] dijkstra(List<List<Edge>> graph, int start) {
int n = graph.size();
int[] dist = new int[n];
Arrays.fill(dist, INF);
dist[start] = 0;
PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[1]));
pq.offer(new int[]{start, 0});
boolean[] visited = new boolean[n];
while (!pq.isEmpty()) {
int[] cur = pq.poll();
int u = cur[0];
if (visited[u]) continue;
visited[u] = true;
for (Edge e : graph.get(u)) {
int v = e.to;
int w = e.weight;
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.offer(new int[]{v, dist[v]});
}
}
}
return dist;
}
/* ========== 4. Johnson 全源最短路 ========== */
/**
* Johnson's Algorithm:计算所有顶点对之间的最短路径
*
* @param graph 原始有向图邻接表(节点编号 0 ~ n-1,允许负权边)
* @return n×n 距离矩阵,dist[i][j] 为 i 到 j 的最短距离;
* 若图中存在负权环则返回 null
*/
public static int[][] johnson(List<List<Edge>> graph) {
int n = graph.size();
// ---- Step 1: 构建含虚拟源点 s = n 的新图 ----
List<List<Edge>> augmented = new ArrayList<>(n + 1);
for (int i = 0; i < n; i++) {
augmented.add(new ArrayList<>(graph.get(i)));
}
augmented.add(new ArrayList<>()); // 虚拟源点 s
for (int v = 0; v < n; v++) {
augmented.get(n).add(new Edge(v, 0)); // s → v,权为 0
}
// ---- Step 2: Bellman-Ford 求势能 h(v) ----
int[] h = bellmanFord(augmented, n);
if (h == null) {
System.err.println("图中存在负权环,Johnson 算法无法执行!");
return null;
}
// ---- Step 3: 重赋权,使所有边权非负 ----
List<List<Edge>> reweighted = new ArrayList<>(n);
for (int u = 0; u < n; u++) {
List<Edge> newEdges = new ArrayList<>();
for (Edge e : graph.get(u)) {
// w'(u,v) = w(u,v) + h(u) - h(v) >= 0
newEdges.add(new Edge(e.to, e.weight + h[u] - h[e.to]));
}
reweighted.add(newEdges);
}
// ---- Step 4: 对每个顶点运行 Dijkstra,并还原真实距离 ----
int[][] dist = new int[n][n];
for (int u = 0; u < n; u++) {
int[] d = dijkstra(reweighted, u);
for (int v = 0; v < n; v++) {
if (d[v] == INF) {
dist[u][v] = INF;
} else {
// 还原:d(u,v) = d'(u,v) - h(u) + h(v)
dist[u][v] = d[v] - h[u] + h[v];
}
}
}
return dist;
}
/* ========== 5. 有向图加边辅助方法 ========== */
/**
* 有向图加边
*/
public static void addEdge(List<List<Edge>> g, int u, int v, int w) {
g.get(u).add(new Edge(v, w));
}
/* ========== 6. 示例 & 测试 ========== */
public static void main(String[] args) {
int n = 4; // 节点数 0~3
List<List<Edge>> graph = new ArrayList<>(n);
for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
// 构建含负权边的有向图
addEdge(graph, 0, 1, 3);
addEdge(graph, 0, 2, 8);
addEdge(graph, 1, 2, -2);
addEdge(graph, 1, 3, 1);
addEdge(graph, 2, 0, 5);
addEdge(graph, 2, 3, 4);
addEdge(graph, 3, 0, -1);
int[][] dist = johnson(graph);
if (dist == null) return;
// 打印全源最短路径距离矩阵
System.out.println("全源最短路径距离矩阵:");
System.out.print(" ");
for (int j = 0; j < n; j++) System.out.printf("%6d", j);
System.out.println();
for (int i = 0; i < n; i++) {
System.out.printf("%2d: ", i);
for (int j = 0; j < n; j++) {
if (dist[i][j] == INF) {
System.out.print(" INF");
} else {
System.out.printf("%6d", dist[i][j]);
}
}
System.out.println();
}
}
}```
---
*本文所述算法实现基于 Apache 2.0 开源协议,欢迎在物流调度、路径规划等场景中引用和改进。*