【图论】SPFA 算法

SPFA(Shortest Path Faster Algorithm,最短路径快速算法)是 Bellman-Ford 算法的一种高效优化版本 。在实践中通常比原始的 Ford 算法快得多,尤其在随机图上表现优异。SPFA 可以处理负权边 ,并能检测负权环

一、 算法目标

与 Dijkstra 算法相同,SPFA 也用于解决单源最短路径 问题。给定一个带权图 G=(V,E)G = (V, E)G=(V,E) 和一个源点 sss,SPFA 计算从 sss 到图中所有其他顶点的最短路径长度。其核心优势在于能够处理负权边 ,并检测图中是否存在负权环

二、 核心思想:队列优化 + 边松弛

SPFA 的核心思想源于 Bellman-Ford 算法 。Bellman-Ford 算法通过对所有边进行 V−1V-1V−1 轮松弛操作来逐步逼近最短路径。SPFA 对此进行了关键优化:

  • 关键观察 :并非所有边在每一轮都需要被松弛。只有那些最短距离在上一轮被更新过的顶点所发出的边,才有可能在本轮松弛中产生更优的路径。
  • 优化策略 :使用一个队列(Queue) 来维护"可能引起其他顶点距离更新的顶点 "。初始时,只有源点 sss 被加入队列。然后,不断从队首取出顶点 uuu,对其所有出边进行松弛。如果在松弛过程中,某个顶点 vvv 的最短距离被更新了,并且 vvv 当前不在队列中,就将 vvv 加入队尾。这个过程持续到队列为空。

三、 算法步骤

1. 初始化

  • 创建距离数组 dist[],大小为 ∣V∣|V|∣V∣。
    • 对于所有顶点 vvv:
      • dist[v] = \infty
    • dist[s] = 0
    • 创建一个空队列 q
    • 创建一个布尔数组 inQueue[],用于标记顶点是否在队列中,初始为 false
    • 创建前驱数组 predecessor[],用于路径重建,初始为 -1
    • 将源点 sss 加入队列 q,并设置 inQueue[s] = true

2. 主循环(队列不为空)

  • 从队首取出一个顶点 uuu,并设置 inQueue[u] = false
    • 遍历所有从 uuu 出发的边 (u,v)(u, v)(u,v),权重为 www:
      a. 松弛操作
      if (dist[u] + w < dist[v])
      b. 更新距离:dist[v] = dist[u] + w
      c. 更新前驱:predecessor[v] = u
      d. 入队检查 :如果 vvv 不在队列中 (!inQueue[v]),则将 vvv 加入队尾,并设置 inQueue[v] = true

3. 负权环检测

  • 方法一(计数法) :维护一个数组 count[],记录每个顶点进入队列的次数。如果某个顶点 vvv 的 count[v] >= V(即进入队列次数超过顶点总数),则说明存在负权环(因为最短路径最多包含 V−1V-1V−1 条边,一个顶点不可能在最短路径中出现 VVV 次或以上)。
  • 方法二(距离更新法) :在主循环结束后,再对所有边进行一次松弛操作。如果还能找到一条边 (u,v)(u, v)(u,v) 使得 dist[u] + w < dist[v] 成立,则说明存在负权环(因为理论上经过 V−1V-1V−1 轮后,所有最短路径都应已确定,不应再有更新)。

四、 算法复杂度

  • 时间复杂度
    • 最坏情况 :O(VE)O(VE)O(VE)。这与原始的 Bellman-Ford 算法相同。最坏情况发生在图的结构导致每个顶点都需要被多次处理,例如存在一个长链或特定的负权边配置。
    • 平均情况 / 实践表现 :远优于 O(VE)O(VE)O(VE),在许多随机图上接近 O(E)O(E)O(E)。这是 SPFA 被称为"快速"算法的原因。
  • 空间复杂度 :O(V+E)O(V + E)O(V+E)。主要存储图(邻接表)、距离数组、队列等。

五、 优缺点

  • 优点
    • 可以处理负权边。
    • 能够检测负权环。
    • 实现相对简单。
    • 在随机图和稀疏图上平均性能很好。
    • 代码短小,易于在竞赛中编写。
  • 缺点
    • 最坏时间复杂度高 :O(VE)O(VE)O(VE),在精心构造的对抗性数据(如网格图、菊花图)下可能被卡到最坏情况,导致超时。这是其在正式算法竞赛中逐渐被边缘化的主要原因(常被针对性数据卡掉)。
    • 性能不稳定,依赖于图的具体结构。
    • 对于没有负权边的图,Dijkstra 算法通常更优。

六、与其他算法的比较

  • vs Bellman-Ford:SPFA 是 Bellman-Ford 的优化,平均情况下快得多,但最坏情况复杂度相同。两者都能处理负权边和检测负权环。
  • vs Dijkstra :Dijkstra 更快(O((V+E)log⁡V)O((V+E)\log V)O((V+E)logV)),但要求边权非负。SPFA 可以处理负权边,但最坏情况更慢且不稳定。
  • vs Floyd-Warshall:SPFA 是单源算法,Floyd 是全源算法。如果只需要单源最短路径且存在负权边,SPFA 是合适的选择。

七、 C++ 实现

下面是一个完整的 C++ 实现,包含距离计算、路径重建和基于计数法的负权环检测。

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
#include <climits>
#include <algorithm>
#include <stack>

using namespace std;

// 常量:表示无穷大(不可达)
const int INF = INT_MAX / 2; // 防止加法溢出

// 定义图的邻接表表示
using Graph = vector<vector<pair<int, int>>>; // (邻居顶点, 边权)

class SPFA {
private:
    int n; // 顶点数量
    Graph adj; // 邻接表
    vector<int> dist; // 距离数组
    vector<int> predecessor; // 前驱数组
    vector<int> count; // 记录每个顶点入队次数,用于负权环检测
    vector<bool> inQueue; // 标记顶点是否在队列中
    bool computed;
    bool hasNegativeCycle;

public:
    // 构造函数
    SPFA(int vertices) : n(vertices), computed(false), hasNegativeCycle(false) {
        adj.resize(n);
    }

    // 添加有向边
    void addEdge(int from, int to, int weight) {
        adj[from].push_back({to, weight});
        // 对于无向图,添加反向边
        // adj[to].push_back({from, weight});
    }

    // 执行 SPFA 算法,从源点 source 开始
    void computeShortestPaths(int source) {
        // 初始化
        dist.assign(n, INF);
        predecessor.assign(n, -1);
        count.assign(n, 0);
        inQueue.assign(n, false);
        computed = true;
        hasNegativeCycle = false;

        dist[source] = 0;
        queue<int> q;
        q.push(source);
        inQueue[source] = true;
        count[source]++;

        while (!q.empty()) {
            int u = q.front();
            q.pop();
            inQueue[u] = false; // 取出后标记为不在队列

            // 遍历所有从 u 出发的边
            for (auto &[v, w] : adj[u]) { // C++17 结构化绑定
                // 尝试通过 u 松弛到 v 的路径
                if (dist[u] + w < dist[v]) {
                    dist[v] = dist[u] + w;
                    predecessor[v] = u;

                    // 如果 v 不在队列中,则加入队列
                    if (!inQueue[v]) {
                        q.push(v);
                        inQueue[v] = true;
                        count[v]++; // 入队次数加一

                        // 负权环检测:如果某个顶点入队次数 >= n
                        if (count[v] >= n) {
                            hasNegativeCycle = true;
                            // 通常在此处可以跳出循环,但为了演示,我们继续标记
                            // break; // 可选:发现负权环后立即终止
                        }
                    }
                }
            }
        }

        // 另一种负权环检测(在循环结束后):
        // if (!hasNegativeCycle) {
        //     for (int u = 0; u < n; ++u) {
        //         for (auto &[v, w] : adj[u]) {
        //             if (dist[u] != INF && dist[u] + w < dist[v]) {
        //                 hasNegativeCycle = true;
        //                 break;
        //             }
        //         }
        //         if (hasNegativeCycle) break;
        //     }
        // }
    }

    // 查询从源点到顶点 v 的最短距离
    int getDistance(int v) const {
        if (!computed) {
            cout << "请先调用 computeShortestPaths()!" << endl;
            return INF;
        }
        if (hasNegativeCycle) {
            cout << "图中存在负权环,结果无效!" << endl;
            return INF;
        }
        return dist[v];
    }

    // 获取从源点到顶点 v 的最短路径(顶点序列)
    vector<int> getPath(int v) const {
        if (!computed) {
            cout << "请先调用 computeShortestPaths()!" << endl;
            return {};
        }
        if (hasNegativeCycle) {
            cout << "图中存在负权环,路径无效!" << endl;
            return {};
        }
        if (dist[v] == INF) {
            cout << "从源点到 " << v << " 不可达。" << endl;
            return {};
        }

        vector<int> path;
        for (int current = v; current != -1; current = predecessor[current]) {
            path.push_back(current);
        }
        reverse(path.begin(), path.end());
        return path;
    }

    // 打印从源点到所有顶点的最短距离和路径
    void printAllResults(int source) const {
        if (!computed) {
            cout << "请先调用 computeShortestPaths()!" << endl;
            return;
        }

        if (hasNegativeCycle) {
            cout << "图中存在负权环!无法计算有效最短路径。" << endl;
            return;
        }

        cout << "从源点 " << source << " 出发的最短路径结果:" << endl;
        for (int v = 0; v < n; ++v) {
            cout << "到顶点 " << v << ": ";
            if (dist[v] == INF) {
                cout << "不可达" << endl;
            } else {
                cout << "距离 = " << dist[v] << ", 路径: ";
                auto path = getPath(v);
                for (size_t i = 0; i < path.size(); ++i) {
                    cout << path[i];
                    if (i < path.size() - 1) cout << " -> ";
                }
                cout << endl;
            }
        }
    }

    // 检查是否存在负权环
    bool hasNegativeCycleDetected() const {
        return hasNegativeCycle;
    }
};

测试函数

cpp 复制代码
// 测试函数
int main() {
    // 示例 1: 正权图
    cout << "=== 示例 1: 正权图 ===" << endl;
    SPFA spfa1(5);
    spfa1.addEdge(0, 1, 4);
    spfa1.addEdge(0, 2, 2);
    spfa1.addEdge(1, 2, 1);
    spfa1.addEdge(1, 3, 5);
    spfa1.addEdge(2, 3, 8);
    spfa1.addEdge(2, 4, 10);
    spfa1.addEdge(3, 4, 2);

    spfa1.computeShortestPaths(0);
    spfa1.printAllResults(0);

    cout << "\n" << endl;

    // 示例 2: 包含负权边但无负权环
    cout << "=== 示例 2: 负权边图(无负权环)===" << endl;
    SPFA spfa2(3);
    spfa2.addEdge(0, 1, 4);
    spfa2.addEdge(0, 2, 3);
    spfa2.addEdge(1, 2, -2); // 负权边

    spfa2.computeShortestPaths(0);
    spfa2.printAllResults(0);

    cout << "\n" << endl;

    // 示例 3: 包含负权环
    cout << "=== 示例 3: 负权环图 ===" << endl;
    SPFA spfa3(4);
    spfa3.addEdge(0, 1, 1);
    spfa3.addEdge(1, 2, -3);
    spfa3.addEdge(2, 1, 1); // 形成环 1->2->1,权重和为 -3+1 = -2 < 0
    spfa3.addEdge(2, 3, 1);

    spfa3.computeShortestPaths(0);
    if (spfa3.hasNegativeCycleDetected()) {
        cout << "检测到负权环!" << endl;
    } else {
        spfa3.printAllResults(0);
    }

    return 0;
}

代码说明

  1. inQueue[] 数组:这是 SPFA 的关键。它避免了同一个顶点在队列中重复出现,提高了效率。
  2. count[] 数组 :用于基于计数法的负权环检测。当 count[v] >= n 时,判定存在负权环。
  3. 队列操作 :使用标准库的 queue<int>。顶点 uuu 出队时,必须将其 inQueue[u] 设为 false
  4. 松弛与入队 :只有在距离被更新 顶点不在队列中时,才将其加入队列。
  5. 负权环处理:一旦检测到负权环,后续的查询函数会返回错误信息,因为最短路径在这种情况下无意义(可以无限绕环使路径变短)。

八、 总结

SPFA 算法是 Bellman-Ford 算法的队列优化版本,通过只处理"活跃"顶点显著提升了平均性能。它能够处理负权边并检测负权环,实现简单,在非对抗性数据下表现良好。然而,其最坏情况 O(VE)O(VE)O(VE) 的时间复杂度和不稳定性使其在对性能要求极高或存在针对性数据的场景中需谨慎使用。