每日算法 — 使用java实现SPFA算法:队列优化的最短路径与负环检测

引言:从Bellman-Ford到SPFA

在图论中,单源最短路径问题是最经典的问题之一。给定一个带权有向图和一个源点,如何求出从该源点到所有其他顶点的最短路径?

  • Dijkstra算法使用优先队列,时间复杂度为 $O((V+E)\log V)$,但无法处理负权边。
  • Bellman-Ford算法通过 $V-1$ 轮全边松弛,可以处理负权边并检测负环,时间复杂度为 $O(VE)$,但对稀疏图效率较低。

SPFA(Shortest Path Faster Algorithm) 由段凡丁于1994年提出,是Bellman-Ford算法的队列优化版本。其核心洞察是:只有在上一次松弛中被成功更新的顶点,才可能在下一轮松弛中继续影响其邻居。因此,SPFA使用一个队列维护”待松弛”的顶点,避免了对所有边进行无意义的遍历。在平均情况下,SPFA的时间复杂度接近 $O(E)$,远优于Bellman-Ford的最坏 $O(VE)$。

本文将用 Java 完整实现 SPFA 算法,讲解其核心思想、负环检测机制,以及 SLF/LLL 两种常用优化策略。

核心思想:队列优化与惰性松弛

Bellman-Ford的浪费

Bellman-Ford 每轮都遍历全部 $E$ 条边进行松弛。假设顶点 $u$ 的距离估计值 $dist[u]$ 在本轮未发生变化,那么从 $u$ 出发的边 $(u,v)$ 的松弛操作 $dist[v] = \min(dist[v], dist[u] + w(u,v))$ 必然不会成功——因为 $dist[u]$ 没变,$dist[v]$ 也不会因此变小。

SPFA的改进

SPFA 维护一个队列 $Q$,初始时仅将源点入队。每次从队首取出顶点 $u$,遍历其所有出边 $(u,v)$:

  1. 若 $dist[u] + w(u,v) < dist[v]$,则更新 $dist[v]$。
  2. 如果 $v$ 不在队列中,将 $v$ 入队。

这样,只有真正可能引发更新的顶点才会被处理。对于随机稀疏图,SPFA的平均性能非常优秀。

负环检测

若图中存在从源点可达的负环,则最短路径不存在(可以无限绕环使路径长度趋于 $-\infty$)。SPFA 检测负环的经典方法是计数法

  • 记录每个顶点入队的次数。
  • 若某顶点入队次数超过 $V$ 次,则说明存在负环。

原理:在无负环的图中,每个顶点的最短路径最多由 $V-1$ 条边组成,因此每个顶点最多被成功松弛 $V-1$ 次。超过 $V$ 次意味着绕了环,且路径仍在缩短,即存在负环。

Java 完整实现

import java.util.*;

/**
 * SPFA 算法完整实现
 * 包含:基础最短路径、负环检测(计数法)、SLF与LLL优化
 * 时间复杂度:平均 O(E),最坏 O(VE)
 */
public class SPFA {

    /**
     * 图的边结构
     */
    static class Edge {
        int to;      // 目标顶点
        int weight;  // 边权
        Edge(int to, int weight) {
            this.to = to;
            this.weight = weight;
        }
    }

    private final int n;                    // 顶点数
    private final List<Edge>[] graph;       // 邻接表
    private final int[] dist;               // 距离数组
    private final int[] count;              // 入队次数(用于负环检测)
    private final boolean[] inQueue;        // 是否在队列中

    /**
     * 构造SPFA求解器
     * @param n 顶点数(编号从0到n-1)
     */
    @SuppressWarnings("unchecked")
    public SPFA(int n) {
        this.n = n;
        this.graph = new ArrayList[n];
        for (int i = 0; i < n; i++) {
            graph[i] = new ArrayList<>();
        }
        this.dist = new int[n];
        this.count = new int[n];
        this.inQueue = new boolean[n];
    }

    /**
     * 添加有向边
     * @param from 起点
     * @param to 终点
     * @param weight 边权(可为负数)
     */
    public void addEdge(int from, int to, int weight) {
        graph[from].add(new Edge(to, weight));
    }

    /**
     * 基础SPFA:求单源最短路径
     * @param source 源点
     * @return 若存在从源点可达的负环,返回null;否则返回距离数组
     */
    public int[] shortestPath(int source) {
        // 初始化
        Arrays.fill(dist, Integer.MAX_VALUE);
        Arrays.fill(count, 0);
        Arrays.fill(inQueue, false);
        dist[source] = 0;

        Queue<Integer> queue = new LinkedList<>();
        queue.offer(source);
        inQueue[source] = true;
        count[source] = 1;

        while (!queue.isEmpty()) {
            int u = queue.poll();
            inQueue[u] = false;

            // 遍历u的所有出边
            for (Edge e : graph[u]) {
                int v = e.to;
                int w = e.weight;

                // 松弛操作:若通过u到v更短,则更新
                if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) {
                    dist[v] = dist[u] + w;

                    // 如果v不在队列中,入队
                    if (!inQueue[v]) {
                        queue.offer(v);
                        inQueue[v] = true;
                        count[v]++;

                        // 负环检测:入队次数超过n次
                        if (count[v] > n) {
                            return null; // 存在负环
                        }
                    }
                }
            }
        }
        return dist;
    }

    /**
     * SPFA + SLF优化(Small Label First)
     * 思想:若待入队顶点的距离比队首小,则插入队首而非队尾
     * 效果:让距离较小的顶点优先处理,加速收敛
     * @param source 源点
     * @return 若存在负环返回null,否则返回距离数组
     */
    public int[] shortestPathSLF(int source) {
        Arrays.fill(dist, Integer.MAX_VALUE);
        Arrays.fill(count, 0);
        Arrays.fill(inQueue, false);
        dist[source] = 0;

        Deque<Integer> deque = new LinkedList<>();
        deque.offerLast(source);
        inQueue[source] = true;
        count[source] = 1;

        while (!deque.isEmpty()) {
            int u = deque.pollFirst();
            inQueue[u] = false;

            for (Edge e : graph[u]) {
                int v = e.to;
                int w = e.weight;

                if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) {
                    dist[v] = dist[u] + w;

                    if (!inQueue[v]) {
                        // SLF优化:与队首比较,决定插入位置
                        if (!deque.isEmpty() && dist[v] < dist[deque.peekFirst()]) {
                            deque.offerFirst(v);
                        } else {
                            deque.offerLast(v);
                        }
                        inQueue[v] = true;
                        count[v]++;
                        if (count[v] > n) {
                            return null;
                        }
                    }
                }
            }
        }
        return dist;
    }

    /**
     * SPFA + SLF + LLL优化(Large Label Last)
     * SLF:小距离优先(插入队首)
     * LLL:大距离后移(若队首距离大于平均值,移到队尾)
     * @param source 源点
     * @return 若存在负环返回null,否则返回距离数组
     */
    public int[] shortestPathSLF_LLL(int source) {
        Arrays.fill(dist, Integer.MAX_VALUE);
        Arrays.fill(count, 0);
        Arrays.fill(inQueue, false);
        dist[source] = 0;

        Deque<Integer> deque = new LinkedList<>();
        deque.offerLast(source);
        inQueue[source] = true;
        count[source] = 1;

        long sumDist = 0;  // 队列中顶点距离之和,用于LLL的均值计算
        int queueSize = 1;
        sumDist += dist[source];

        while (!deque.isEmpty()) {
            // LLL优化:若队首距离大于平均值,移到队尾
            while (!deque.isEmpty() &&
                   dist[deque.peekFirst()] * queueSize > sumDist) {
                int moved = deque.pollFirst();
                deque.offerLast(moved);
            }

            int u = deque.pollFirst();
            inQueue[u] = false;
            sumDist -= dist[u];
            queueSize--;

            for (Edge e : graph[u]) {
                int v = e.to;
                int w = e.weight;

                if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) {
                    dist[v] = dist[u] + w;

                    if (!inQueue[v]) {
                        if (!deque.isEmpty() && dist[v] < dist[deque.peekFirst()]) {
                            deque.offerFirst(v);
                        } else {
                            deque.offerLast(v);
                        }
                        inQueue[v] = true;
                        count[v]++;
                        sumDist += dist[v];
                        queueSize++;
                        if (count[v] > n) {
                            return null;
                        }
                    }
                }
            }
        }
        return dist;
    }

    /**
     * 获取具体的最短路径(前驱数组回溯)
     * @param source 源点
     * @return Pair<距离数组, 前驱数组>;若存在负环返回null
     */
    public Result shortestPathWithPredecessor(int source) {
        Arrays.fill(dist, Integer.MAX_VALUE);
        Arrays.fill(count, 0);
        Arrays.fill(inQueue, false);
        int[] pre = new int[n];
        Arrays.fill(pre, -1);
        dist[source] = 0;

        Queue<Integer> queue = new LinkedList<>();
        queue.offer(source);
        inQueue[source] = true;
        count[source] = 1;

        while (!queue.isEmpty()) {
            int u = queue.poll();
            inQueue[u] = false;

            for (Edge e : graph[u]) {
                int v = e.to;
                int w = e.weight;

                if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) {
                    dist[v] = dist[u] + w;
                    pre[v] = u;  // 记录前驱

                    if (!inQueue[v]) {
                        queue.offer(v);
                        inQueue[v] = true;
                        count[v]++;
                        if (count[v] > n) {
                            return null;
                        }
                    }
                }
            }
        }
        return new Result(dist.clone(), pre);
    }

    static class Result {
        int[] dist;
        int[] pre;
        Result(int[] dist, int[] pre) {
            this.dist = dist;
            this.pre = pre;
        }

        /**
         * 重建从source到target的最短路径
         */
        List<Integer> getPath(int source, int target) {
            List<Integer> path = new ArrayList<>();
            if (pre[target] == -1 && target != source) {
                return path; // 不可达
            }
            int cur = target;
            while (cur != -1) {
                path.add(cur);
                cur = pre[cur];
            }
            Collections.reverse(path);
            return path;
        }
    }

    // ==================== 主程序与测试 ====================

    public static void main(String[] args) {
        System.out.println("===== 示例1:基础SPFA(含负权边) =====");
        SPFA spfa1 = new SPFA(5);
        spfa1.addEdge(0, 1, 6);
        spfa1.addEdge(0, 2, 7);
        spfa1.addEdge(1, 2, 8);
        spfa1.addEdge(1, 3, 5);
        spfa1.addEdge(1, 4, -4);  // 负权边
        spfa1.addEdge(2, 3, -3);  // 负权边
        spfa1.addEdge(2, 4, 9);
        spfa1.addEdge(3, 1, -2);  // 负权边
        spfa1.addEdge(4, 0, 2);
        spfa1.addEdge(4, 3, 7);

        int[] dist1 = spfa1.shortestPath(0);
        System.out.println("从顶点0出发的最短距离:");
        for (int i = 0; i < 5; i++) {
            System.out.printf("  到顶点%d: %d%n", i, dist1[i]);
        }
        // 期望:0->0, 1->2(0->2->3->1), 2->7, 3->4, 4->-2

        System.out.println("\n===== 示例2:负环检测 =====");
        SPFA spfa2 = new SPFA(3);
        spfa2.addEdge(0, 1, 1);
        spfa2.addEdge(1, 2, -3);
        spfa2.addEdge(2, 0, 1);  // 0->1->2->0 的环权值为 -1,是负环

        int[] dist2 = spfa2.shortestPath(0);
        if (dist2 == null) {
            System.out.println("检测到负环!最短路径不存在。");
        } else {
            System.out.println("无负环,距离:" + Arrays.toString(dist2));
        }

        System.out.println("\n===== 示例3:路径重建 =====");
        SPFA spfa3 = new SPFA(5);
        spfa3.addEdge(0, 1, 10);
        spfa3.addEdge(0, 2, 3);
        spfa3.addEdge(1, 3, 2);
        spfa3.addEdge(2, 1, 4);
        spfa3.addEdge(2, 3, 8);
        spfa3.addEdge(2, 4, 2);
        spfa3.addEdge(3, 4, 5);
        spfa3.addEdge(4, 3, 7);

        Result res = spfa3.shortestPathWithPredecessor(0);
        System.out.println("距离数组:" + Arrays.toString(res.dist));
        for (int i = 0; i < 5; i++) {
            List<Integer> path = res.getPath(0, i);
            System.out.printf("  到顶点%d的路径: %s (长度=%d)%n", i, path, res.dist[i]);
        }

        System.out.println("\n===== 示例4:三种版本性能对比 =====");
        int scale = 1000;
        int edges = 5000;
        SPFA spfaBase = new SPFA(scale);
        SPFA spfaSLF = new SPFA(scale);
        SPFA spfaLLL = new SPFA(scale);

        Random rand = new Random(42);
        for (int i = 0; i < edges; i++) {
            int u = rand.nextInt(scale);
            int v = rand.nextInt(scale);
            int w = rand.nextInt(200) - 50; // -50 ~ 149
            if (u != v) {
                spfaBase.addEdge(u, v, w);
                spfaSLF.addEdge(u, v, w);
                spfaLLL.addEdge(u, v, w);
            }
        }

        long t1 = System.nanoTime();
        spfaBase.shortestPath(0);
        long t2 = System.nanoTime();
        spfaSLF.shortestPathSLF(0);
        long t3 = System.nanoTime();
        spfaLLL.shortestPathSLF_LLL(0);
        long t4 = System.nanoTime();

        System.out.printf("基础SPFA: %.3f ms%n", (t2 - t1) / 1_000_000.0);
        System.out.printf("SPFA+SLF: %.3f ms%n", (t3 - t2) / 1_000_000.0);
        System.out.printf("SPFA+SLF+LLL: %.3f ms%n", (t4 - t3) / 1_000_000.0);
    }
}

复杂度分析

版本 平均时间复杂度 最坏时间复杂度 空间复杂度 说明
基础SPFA $O(E)$ $O(VE)$ $O(V)$ 适合随机稀疏图
SPFA + SLF $O(E)$ $O(VE)$ $O(V)$ 小距离优先,常数优化明显
SPFA + SLF + LLL $O(E)$ $O(VE)$ $O(V)$ 双重启发,最坏情况略优
Bellman-Ford $O(VE)$ $O(VE)$ $O(V)$ 无条件遍历全边
Dijkstra $O((V+E)\log V)$ $O((V+E)\log V)$ $O(V)$ 不支持负权边

注意:SPFA 的最坏复杂度与 Bellman-Ford 相同。在特定构造的数据(如网格图、菊花图)上,SPFA 可能退化。对于保证无负边且需要严格复杂度上界的场景,Dijkstra 仍是首选;若需要处理负边且无负环,SPFA 在实践中的表现通常优于 Bellman-Ford。

SLF与LLL优化详解

SLF(Small Label First)

当顶点 $v$ 需要入队时,比较 $dist[v]$ 与队首顶点的距离:
– 若 $dist[v]$ 更小,插入队首;否则插入队尾。

直觉:距离小的顶点更有可能引发更多有效的松弛,优先处理它们可以加速整个算法的收敛。

LLL(Large Label Last)

在取出队首顶点之前,若队首顶点的距离大于队列中所有顶点距离的平均值,则将其移到队尾,重复此过程直到条件不满足。

直觉:避免处理”过时的”大距离顶点——它们可能已经被更优路径更新,但仍在队列前端阻塞更有希望的顶点。

经典应用场景

差分约束系统

SPFA 是求解差分约束系统的标准工具。给定一组形如 $x_i – x_j \leq c_k$ 的不等式,可以将其转化为图中边 $j \to i$(权值 $c_k$),然后添加超级源点向所有变量连权值为0的边,运行 SPFA 即可求得一组可行解。若检测到负环,则约束系统无解。

网络流费用计算

在最小费用最大流算法中,每次增广都需要在残量网络上求一次单源最短路径。由于残量网络中存在反向边(负权),SPFA 常被用于此步骤。若结合势函数(Johnson技巧),也可将问题转化为非负权图后用 Dijkstra 求解。

总结

通过本文你掌握了:

  • SPFA 的核心思想:利用队列实现惰性松弛,避免 Bellman-Ford 的全边盲目遍历
  • 完整的 Java 实现,包含基础版本、SLF 优化、LLL 优化及路径重建
  • 负环检测的计数法原理与代码实现
  • 时间复杂度分析与各算法的适用场景对比
  • SPFA 在差分约束系统和网络流中的典型应用

SPFA 的优雅之处在于它用极简单的数据结构(一个普通队列)实现了对 Bellman-Ford 的大幅优化。理解它”只做必要工作”的设计哲学,对培养算法直觉大有裨益。