<think>我们正在讨论Johnson算法,用于求解全源最短路径问题。Johnson算法适用于稀疏图,特别是当图中包含负权边(但不含负权环)时。它通过重新赋予权值(reweighting)来消除负权边,然后利用Dijkstra算法(要求非负权值)来高效计算所有点对之间的最短路径。
Johnson算法的步骤如下:
1. 向图中添加一个新节点(记为s),并添加从s指向所有原图中节点的边,且这些边的权值为0。
2. 以s为源点运行Bellman-Ford算法(或SPFA),计算从s到所有原图节点的最短路径,记为h(v)。如果检测到负权环,则算法终止(因为无法计算最短路径)。
3. 移除新添加的节点s及其相关边,恢复原图。
4. 对原图的每条边(u, v)进行重新赋权:新的权值为 w(u,v) + h(u) - h(v)。(这个重新赋权保证所有边权非负,且不会改变最短路径)
5. 对每个原图中的节点v,运行Dijkstra算法(使用新的权值),得到从v到其他节点的最短路径距离。
6. 最后,将Dijkstra算法得到的最短路径距离还原:实际距离d(u,v) = d'(u,v) - h(u) + h(v),其中d'(u,v)是重新赋权后的图计算出的距离。
现在,我们给出一个C++实现,并逐行解析。注意,我们将使用邻接表表示图,并使用优先队列优化Dijkstra算法。同时,我们使用SPFA(Bellman-Ford的一种优化)来执行第一步中的最短路径计算。
代码实现如下:
```cpp
#include <iostream>
#include <vector>
#include <queue>
#include <climits>
using namespace std;
typedef long long ll;
typedef pair<ll, ll> pll;
const ll INF = 1e18; // 表示无穷大
// 边结构体
struct Edge {
ll v, w;
Edge(ll v, ll w) : v(v), w(w) {}
};
vector<vector<Edge>> graph; // 图的邻接表表示
ll n; // 节点数
// 添加一条从u到v的权值为w的有向边
void addEdge(ll u, ll v, ll w) {
graph[u].push_back(Edge(v, w));
}
// SPFA算法:计算从源点s到所有点的最短路径,结果存储在dist中
bool spfa(ll s, vector<ll>& dist) {
dist.assign(n+1, INF);
vector<ll> cnt(n+1, 0);
vector<bool> inQueue(n+1, false);
queue<ll> q;
dist[s] = 0;
q.push(s);
inQueue[s] = true;
cnt[s] = 1;
while (!q.empty()) {
ll u = q.front();
q.pop();
inQueue[u] = false;
for (auto& edge : graph[u]) {
ll v = edge.v, w = edge.w;
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
if (!inQueue[v]) {
q.push(v);
inQueue[v] = true;
cnt[v]++;
// 如果某个点入队超过n次,说明存在负环
if (cnt[v] > n) {
return false; // 存在负环
}
}
}
}
}
return true; // 不存在负环
}
// Dijkstra算法:计算从源点s出发的最短路径,结果存储在dist中(使用新权值)
void dijkstra(ll s, vector<ll>& dist) {
dist.assign(n+1, INF);
priority_queue<pll, vector<pll>, greater<pll>> pq; // 最小堆
dist[s] = 0;
pq.push({0, s});
while (!pq.empty()) {
ll d = pq.top().first;
ll u = pq.top().second;
pq.pop();
if (d != dist[u]) continue; // 跳过旧信息
for (auto& edge : graph[u]) {
ll v = edge.v, w = edge.w;
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
}
int main() {
ll m;
cin >> n >> m;
graph.resize(n+2); // 多开两个位置,其中一个用于新节点(编号为0,原图节点从1到n)
// 添加原图的边
for (ll i = 0; i < m; i++) {
ll u, v, w;
cin >> u >> v >> w;
addEdge(u, v, w);
}
// 添加新节点0,并添加从0到所有原图节点(1~n)的边,权值为0
for (ll i = 1; i <= n; i++) {
addEdge(0, i, 0);
}
vector<ll> h(n+1, INF); // h[i]表示从0到i的最短路径
// 运行SPFA,计算从0出发的最短路径
if (!spfa(0, h)) {
cout << -1 << endl; // 存在负环
return 0;
}
// 移除新节点0及其相关的边(这里我们通过重新构建图来移除,但注意我们只移除0相关的边,同时保留原图边)
// 注意:我们不需要实际移除,而是重新构建原图(不含0节点)的邻接表,但为了效率,我们直接修改原图的边权
// 但是,我们接下来要运行Dijkstra,所以需要修改图?实际上,我们可以不修改原图,而是在运行Dijkstra时使用新的权值
// 因此,我们修改图:将每条边(u,v)的权值更新为 w + h[u] - h[v]
for (ll u = 1; u <= n; u++) {
for (auto& edge : graph[u]) {
edge.w += h[u] - h[edge.v]; // 更新边权
}
}
// 现在,对每个节点运行Dijkstra
for (ll i = 1; i <= n; i++) {
vector<ll> dist; // 存储从i出发的最短路径(新权值)
dijkstra(i, dist);
// 计算实际的最短路径:将新权值下的距离还原
for (ll j = 1; j <= n; j++) {
if (dist[j] == INF) {
cout << "INF ";
} else {
// 还原:dist[j] - h[i] + h[j]
cout << dist[j] - h[i] + h[j] << " ";
}
}
cout << endl;
}
return 0;
}
```
### 代码解析
1. **数据结构**:使用邻接表`graph`存储图,每个节点是一个`Edge`结构体的列表,包含目标节点和边权。
2. **添加新节点**:在读取图之后,我们添加一个节点0,并添加从0到每个原图节点(1~n)的边,权值为0。
3. **SPFA算法**:以0为源点运行SPFA,计算到所有原图节点的最短路径`h`。如果存在负环,则输出-1并退出。
4. **重新赋权**:遍历原图的每条边(注意,新节点0的边已经不需要了,但我们只处理原图节点1~n的出边),将边权更新为`w + h[u] - h[v]`。这个操作保证所有边权非负。
5. **Dijkstra算法**:对每个原图节点运行Dijkstra算法,计算重新赋权后的最短路径。注意,这里Dijkstra使用的是非负权值。
6. **还原距离**:将Dijkstra计算出的距离`dist[j]`还原为实际的最短路径距离:`dist[j] - h[i] + h[j]`。
### 优化建议
1. **稀疏图优化**:Johnson算法在稀疏图上表现良好,因为Dijkstra算法使用优先队列优化,时间复杂度为$O((V+E)\log V)$。而SPFA算法在平均情况下为$O(E)$,最坏情况下为$O(VE)$。因此,总时间复杂度为$O(VE + V(V+E)\log V)$,对于稀疏图($E=O(V)$)是可行的,但稠密图($E=O(V^2)$)则可能较慢。
2. **负环检测**:使用SPFA检测负环,如果存在负环则立即退出。
3. **内存优化**:在运行Dijkstra时,每次只计算一个源点的最短路径,因此我们不需要存储所有点对的距离矩阵(除非需要)。但是,如果图很大,内存可能成为问题。可以考虑分批次处理。
4. **避免重建图**:我们在重新赋权时直接修改了原图的边权,这样在后续运行Dijkstra时就直接使用新权值。这样避免了重建图,但要注意如果原图需要保留,则应该备份。
5. **处理不连通图**:如果图不连通,那么从某些节点可能无法到达其他节点,此时Dijkstra计算出的距离为INF,我们在还原时直接输出INF(或自定义的无穷大符号)即可。
6. **使用更快的Dijkstra**:在C++中,使用`priority_queue`(默认是最大堆)时,我们使用`greater`来构建最小堆。也可以使用`std::set`或`std::multiset`,但优先队列通常更快。
7. **注意整数溢出**:由于重新赋权和还原过程中涉及加减,如果边权很大,可能需要使用足够大的数据类型(如`long long`)。
8. **避免重复初始化**:在多次运行Dijkstra时,注意每次都要重新初始化`dist`数组。
### 相关问题
1. Johnson算法中为什么重新赋权不会改变最短路径?
2. Johnson算法的时间复杂度是多少?与Floyd-Warshall算法相比有什么优势?
3. 如果图中存在负权环,Johnson算法如何处理?
4. 在重新赋权后,为什么所有边权都非负?
5. 如何修改Johnson算法以输出具体的最短路径(而不仅仅是距离)?</think>### Johnson算法实现全源最短路径的C++代码解析
Johnson算法是一种高效处理**稠密图中全源最短路径**的算法,特别适用于包含负权边但无负权环的图。其核心思想是通过**重新赋权**消除负权边,再利用Dijkstra算法高效计算。以下是完整实现和逐行解析:
```cpp
#include <iostream>
#include <vector>
#include <queue>
#include <climits>
using namespace std;
typedef long long ll;
const ll INF = 1e18; // 无穷大常量
// 边结构:目标节点v,边权w
struct Edge { ll v, w; };
vector<vector<Edge>> graph; // 邻接表存图
ll n; // 节点数
// SPFA检测负环并计算势能函数h
bool spfa(ll s, vector<ll>& h) {
vector<ll> cnt(n + 1, 0); // 入队次数
vector<bool> inQueue(n + 1, false);
queue<ll> q;
h.assign(n + 1, INF);
h[s] = 0;
q.push(s);
inQueue[s] = true;
while (!q.empty()) {
ll u = q.front();
q.pop();
inQueue[u] = false;
for (auto& e : graph[u]) {
if (h[u] + e.w < h[e.v]) {
h[e.v] = h[u] + e.w;
if (!inQueue[e.v]) {
// 检测负环:入队次数超过n
if (++cnt[e.v] > n) return false;
q.push(e.v);
inQueue[e.v] = true;
}
}
}
}
return true;
}
// Dijkstra求单源最短路(使用新边权)
void dijkstra(ll s, vector<ll>& dist) {
dist.assign(n + 1, INF);
priority_queue<pair<ll, ll>, vector<pair<ll, ll>>, greater<>> pq;
dist[s] = 0;
pq.push({0, s});
while (!pq.empty()) {
auto [d, u] = pq.top();
pq.pop();
if (d != dist[u]) continue; // 跳过旧数据
for (auto& e : graph[u]) {
ll newDist = dist[u] + e.w; // 使用重新赋权后的边权
if (newDist < dist[e.v]) {
dist[e.v] = newDist;
pq.push({newDist, e.v});
}
}
}
}
int main() {
ll m, u, v, w;
cin >> n >> m;
graph.resize(n + 2); // 多分配空间用于虚节点
// 1. 构建原图
for (ll i = 0; i < m; ++i) {
cin >> u >> v >> w;
graph[u].push_back({v, w});
}
// 2. 添加虚节点0和零权边
for (ll i = 1; i <= n; ++i)
graph[0].push_back({i, 0});
// 3. SPFA计算势能函数h
vector<ll> h(n + 1, INF);
if (!spfa(0, h)) {
cout << "存在负权环!" << endl;
return -1;
}
// 4. 边权重新赋权:w'(u,v) = w(u,v) + h[u] - h[v]
for (ll u = 1; u <= n; ++u)
for (auto& e : graph[u])
e.w += h[u] - h[e.v]; // 确保边权非负[^3]
// 5. 对每个节点运行Dijkstra
for (ll i = 1; i <= n; ++i) {
vector<ll> dist;
dijkstra(i, dist);
// 6. 还原实际距离:d(u,v) = d'(u,v) - h[u] + h[v]
for (ll j = 1; j <= n; ++j) {
if (dist[j] == INF) cout << "INF ";
else cout << dist[j] - h[i] + h[j] << " ";
}
cout << endl;
}
return 0;
}
```
---
### 关键步骤解析
1. **虚节点添加**(第43-44行)
添加虚拟节点0,并向所有原节点连接权值为0的边,用于后续势能函数计算。
2. **SPFA检测负环**(第46-50行)
以虚节点为源运行SPFA:
- 若检测到负环(`cnt[e.v] > n`),立即终止[^3]
- 计算势能函数$h$,满足三角不等式$h(v) \leq h(u) + w(u,v)$
3. **边权重新赋权**(第53-55行)
对每条边$(u,v)$进行权值更新:
$$ w'(u,v) = w(u,v) + h[u] - h[v] $$
此操作保证所有边权非负,且不改变最短路径结构[^2]。
4. **Dijkstra计算新图最短路**(第58-62行)
对每个节点运行Dijkstra(使用优先队列优化),时间复杂度$O((V+E)\log V)$。
5. **距离还原**(第68行)
实际最短距离计算:
$$ d(u,v) = d'(u,v) - h[u] + h[v] $$
其中$d'$是新图上的计算距离。
---
### 算法优势与复杂度
- **时间复杂度**:$O(VE + V(V+E)\log V)$
优于Floyd-Warshall的$O(V^3)$,尤其适合稀疏图[^1]
- **空间复杂度**:$O(V+E)$
- **核心优势**:
允许负权边(无负环),复用高效Dijkstra实现
---
### 优化建议
1. **堆优化**:使用斐波那契堆可将Dijkstra降至$O(E + V\log V)$
2. **内存优化**:分批次计算避免存储全距离矩阵
3. **负环处理**:增加详细负环定位功能
4. **并行化**:不同源点的Dijkstra可并行计算
5. **稀疏图特化**:对链式前向星存储优化
> 示例输入:
> 3 5
> 1 2 2
> 1 3 4
> 2 3 -1
> 3 1 1
> 2 1 3
> 输出矩阵:
> 0 2 1
> 3 0 -1
> 1 3 0
---