引言
青蛙过河是一个经典的图论问题变体:河中有若干石头,青蛙需要从起点跳到终点,每次跳跃有对应的体力消耗,某些石头之间可能存在”顺流”带来的负消耗。本文用Java实现SPFA(Shortest Path Faster Algorithm)算法,解决带负权边的最短路径问题,并分析其在动态图场景下的应用。
问题建模
将每块石头视为图中的一个节点,青蛙能跳跃的石头对构成有向边,边的权重为跳跃消耗的体力(逆流为正,顺流为负)。问题转化为:从起点到终点的最短路径,允许负权边但不含负权环。
import java.util.*;
/**
* 青蛙过河问题模型
* 使用SPFA算法求解带负权边的单源最短路径
*/
public class FrogRiverSPFA {
// 图的邻接表表示:节点 -> (邻居, 权重)
private final List<List<Edge>> graph;
private final int n; // 节点数
public FrogRiverSPFA(int n) {
this.n = n;
this.graph = new ArrayList<>();
for (int i = 0; i < n; i++) {
graph.add(new ArrayList<>());
}
}
/**
* 添加有向边
* @param from 起点(石头编号)
* @param to 终点(石头编号)
* @param weight 权重(体力消耗,可为负)
*/
public void addEdge(int from, int to, int weight) {
graph.get(from).add(new Edge(to, weight));
}
/**
* SPFA核心算法
* 使用队列优化Bellman-Ford,平均时间复杂度O(kE),k通常很小
* @param start 起点编号
* @return 到各点的最短距离数组
*/
public int[] spfa(int start) {
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[start] = 0;
// 记录节点入队次数,用于检测负权环
int[] count = new int[n];
boolean[] inQueue = new boolean[n];
Queue<Integer> queue = new LinkedList<>();
queue.offer(start);
inQueue[start] = true;
while (!queue.isEmpty()) {
int u = queue.poll();
inQueue[u] = false;
// 松弛操作:遍历所有出边
for (Edge e : graph.get(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) {
throw new IllegalStateException(
"检测到负权环,青蛙可能无限获利!"
);
}
}
}
}
}
return dist;
}
/**
* 获取最短路径的具体路线(通过前驱节点回溯)
*/
public List<Integer> getShortestPath(int start, int end) {
int[] dist = new int[n];
int[] prev = new int[n]; // 前驱节点记录
Arrays.fill(dist, Integer.MAX_VALUE);
Arrays.fill(prev, -1);
dist[start] = 0;
boolean[] inQueue = new boolean[n];
Queue<Integer> queue = new LinkedList<>();
queue.offer(start);
inQueue[start] = true;
while (!queue.isEmpty()) {
int u = queue.poll();
inQueue[u] = false;
for (Edge e : graph.get(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;
prev[v] = u; // 记录前驱,用于路径重建
if (!inQueue[v]) {
queue.offer(v);
inQueue[v] = true;
}
}
}
}
// 回溯构建路径
List<Integer> path = new ArrayList<>();
if (dist[end] == Integer.MAX_VALUE) return path; // 不可达
for (int at = end; at != -1; at = prev[at]) {
path.add(at);
}
Collections.reverse(path);
return path;
}
private static class Edge {
final int to;
final int weight;
Edge(int to, int weight) {
this.to = to;
this.weight = weight;
}
}
// 演示主程序
public static void main(String[] args) {
// 场景:5块石头编号0~4,0为起点,4为终点
FrogRiverSPFA solver = new FrogRiverSPFA(5);
// 添加跳跃边(含负权边,表示顺流助力)
solver.addEdge(0, 1, 3); // 0->1 消耗3
solver.addEdge(0, 2, 2); // 0->2 消耗2
solver.addEdge(1, 2, -1); // 1->2 顺流,消耗-1(体力回复)
solver.addEdge(1, 3, 4); // 1->3 消耗4
solver.addEdge(2, 3, 2); // 2->3 消耗2
solver.addEdge(2, 4, 5); // 2->4 消耗5
solver.addEdge(3, 4, -2); // 3->4 顺流大助力,消耗-2
int[] dist = solver.spfa(0);
System.out.println("从起点0到各石头的最小体力消耗:");
for (int i = 0; i < dist.length; i++) {
System.out.printf(" 到石头%d: %d%n", i, dist[i]);
}
List<Integer> path = solver.getShortestPath(0, 4);
System.out.println("\n最优过河路线: " + path);
System.out.println("总消耗: " + dist[4]);
}
}
SPFA的SLF优化
对于稠密图或特殊构造的数据,SPFA可能退化。引入SLF(Small Label First)优化:若待入队节点的距离小于队首,则插入队首而非队尾。
/**
* SLF优化的SPFA,使用双端队列
*/
public int[] spfaSLF(int start) {
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[start] = 0;
boolean[] inQueue = new boolean[n];
Deque<Integer> deque = new ArrayDeque<>();
deque.offerFirst(start);
inQueue[start] = true;
while (!deque.isEmpty()) {
int u = deque.pollFirst();
inQueue[u] = false;
for (Edge e : graph.get(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;
}
}
}
}
return dist;
}
复杂度分析
| 指标 | 标准SPFA | SLF优化SPFA |
|---|---|---|
| 最坏时间 | O(VE) | O(VE) |
| 平均时间 | O(kE),k≈2 | O(kE),k更小 |
| 空间 | O(V) | O(V) |
| 负权环检测 | 支持 | 支持 |
总结
SPFA算法通过队列优化Bellman-Ford的松弛过程,在稀疏图上效率极高。本文将其应用于青蛙过河场景,正确处理了负权边(顺流助力),并通过入队次数检测负权环。SLF双端队列优化进一步提升了实际运行效率。