第十一章:图论part10
今天大家会感受到 Bellman_ford 算法系列在不同场景下的应用。
建议依然是:一刷的时候,能理解 原理,知道Bellman_ford 解决不同场景的问题 ,照着代码随想录能抄下来代码就好,就算达标。
二刷的时候自己尝试独立去写,三刷的时候 才能有一定深度理解各个最短路算法。
Bellman_ford 队列优化算法(又名SPFA)
思路
- Bellman_ford 算法每次松弛 都是对所有边进行松弛。但真正有效的松弛,是基于已经计算过的节点在做的松弛。
本图中,对所有边进行松弛,真正有效的松弛,只有松弛 边(节点1->节点2) 和 边(节点1->节点3) 。而松弛 边(节点4->节点6) ,边(节点5->节点3)等等 都是无效的操作,因为 节点4 和 节点 5 都是没有被计算过的节点。
只需要对 上一次松弛的时候更新过的节点作为出发节点所连接的边 进行松弛就够了。
基于以上思路,用队列来记录(其实用栈也行,对元素顺序没有要求)上次松弛的时候更新过的节点。
1. 模拟过程
初始化:从起点开始,
minDist[start]
设置为 0,表示起点到自身的距离为 0,其他节点初始化为INT_MAX
,表示无限远。将起点节点加入队列。-
松弛操作:
- 从队列中取出一个节点
node
,松弛所有从node
出发的边。 - 如果通过
node
到达某个节点to
的路径更短,则更新minDist[to]
,并将to
节点加入队列。 - 重复以上步骤,直到队列为空。
- 从队列中取出一个节点
终止条件:当所有节点的最短路径都已经确定,且队列为空时,算法结束。
2. Java 代码实现
以下是实现 SPFA(队列优化版 Bellman-Ford)算法的 Java 代码:
import java.util.*;
class Edge {
int to, val;
Edge(int to, int val) {
this.to = to;
this.val = val;
}
}
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(); // 节点数量
int m = sc.nextInt(); // 边数量
// 邻接表表示图
List<List<Edge>> graph = new ArrayList<>(n + 1);
for (int i = 0; i <= n; i++) {
graph.add(new ArrayList<>());
}
// 记录是否在队列中的数组
boolean[] isInQueue = new boolean[n + 1];
// 读取所有边并存储在邻接表中
for (int i = 0; i < m; i++) {
int p1 = sc.nextInt();
int p2 = sc.nextInt();
int val = sc.nextInt();
graph.get(p1).add(new Edge(p2, val));
}
int start = 1; // 起点
int end = n; // 终点
// 最短路径数组,初始化为最大值
int[] minDist = new int[n + 1];
Arrays.fill(minDist, Integer.MAX_VALUE);
minDist[start] = 0;
// 队列初始化,并将起点加入队列
Queue<Integer> queue = new LinkedList<>();
queue.offer(start);
isInQueue[start] = true;
// SPFA主循环
while (!queue.isEmpty()) {
int node = queue.poll();
isInQueue[node] = false;
// 遍历所有从当前节点出发的边,尝试松弛
for (Edge edge : graph.get(node)) {
int to = edge.to;
int value = edge.val;
// 如果通过node到to的距离更短,更新minDist[to]
if (minDist[to] > minDist[node] + value) {
minDist[to] = minDist[node] + value;
// 如果to不在队列中,则将其加入队列
if (!isInQueue[to]) {
queue.offer(to);
isInQueue[to] = true;
}
}
}
}
// 输出结果
if (minDist[end] == Integer.MAX_VALUE) {
System.out.println("unconnected"); // 不能到达终点
} else {
System.out.println(minDist[end]); // 到达终点最短路径
}
sc.close();
}
}
3. 模拟过程举例
以下是基于上述代码的模拟过程,以题目中的示例输入为例:
示例输入:
5 6
1 2 1
5 3 1
2 5 2
2 4 -3
4 6 4
1 3 5
步骤解析:
-
初始化:
-
minDist[1] = 0
(起点到自身的距离为 0),其他节点minDist
为Integer.MAX_VALUE
。 - 将起点节点
1
加入队列。
-
-
第一轮松弛:
- 从队列中取出节点
1
,松弛边1 -> 2
和1 -> 3
:- 更新
minDist[2] = 1
,将节点2
加入队列。 - 更新
minDist[3] = 5
,将节点3
加入队列。
- 更新
- 从队列中取出节点
-
第二轮松弛:
- 从队列中取出节点
2
,松弛边2 -> 5
和2 -> 4
:- 更新
minDist[5] = 3
,将节点5
加入队列。 - 更新
minDist[4] = -2
,将节点4
加入队列。
- 更新
- 从队列中取出节点
-
第三轮松弛:
- 从队列中取出节点
3
,但没有新的边可以松弛,继续下一个节点。
- 从队列中取出节点
-
第四轮松弛:
- 从队列中取出节点
4
,松弛边4 -> 6
:- 更新
minDist[6] = 2
,将节点6
加入队列。
- 更新
- 从队列中取出节点
-
第五轮松弛:
- 从队列中取出节点
5
,松弛边5 -> 3
和5 -> 6
:- 更新
minDist[3] = 4
,节点3
已在队列中,不用再加入。 - 更新
minDist[6] = 1
。
- 更新
- 从队列中取出节点
-
第六轮松弛:
- 从队列中取出节点
6
,没有新的边可以松弛。 - 最后,队列为空,算法结束。
- 从队列中取出节点
最终结果:
- 输出最短路径
minDist[6]
,即1
。
4. 效率分析
-
理论时间复杂度:SPFA 的时间复杂度依赖于图的稠密度,最坏情况下为
O(N * E)
,但一般情况下时间复杂度为O(K * N)
,其中K
取决于图的稠密度。 - 实践性能:SPFA 的实际运行时间还受制于队列操作的效率,特别是在稠密图中,队列操作的频繁调用可能导致时间消耗增加。
5. 拓展
- 正权回路:SPFA 在正权回路中表现稳定,不会陷入死循环,最终总会收敛到最短路径结果。
- 负权回路:如果图中存在负权回路,需要特别处理,否则可能导致无限循环。
bellman_ford之判断负权回路
思路
- 本题是要我们判断负权回路,也就是图中出现环且环上的边总权值为负数。如果在这样的图中求最短路的话, 就会在这个环里无限循环 (也是负数+负数 只会越来越小),无法求出最短路径。所以对于在有负权值的图中求最短路,都需要先看看这个图里有没有负权回路。
- 解决本题的核心思路,就是在 kama94.城市间货物运输I 的基础上,再多松弛一次,看minDist数组 是否发生变化。
代码实现
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(); // 节点数量
int m = sc.nextInt(); // 边的数量
List<int[]> grid = new ArrayList<>();
// 读取所有边的信息
for (int i = 0; i < m; i++) {
int p1 = sc.nextInt();
int p2 = sc.nextInt();
int val = sc.nextInt();
// p1 指向 p2,权值为 val
grid.add(new int[]{p1, p2, val});
}
int start = 1; // 起点
int end = n; // 终点
int[] minDist = new int[n + 1];
Arrays.fill(minDist, Integer.MAX_VALUE);
minDist[start] = 0;
boolean flag = false;
// 进行 n 次松弛操作,最后一次用于检测负权回路
for (int i = 1; i <= n; i++) {
for (int[] side : grid) {
int from = side[0];
int to = side[1];
int price = side[2];
if (i < n) {
if (minDist[from] != Integer.MAX_VALUE && minDist[to] > minDist[from] + price) {
minDist[to] = minDist[from] + price;
}
} else { // 第 n 次松弛,用于检测负权回路
if (minDist[from] != Integer.MAX_VALUE && minDist[to] > minDist[from] + price) {
flag = true;
}
}
}
}
if (flag) {
System.out.println("circle"); // 存在负权回路
} else if (minDist[end] == Integer.MAX_VALUE) {
System.out.println("unconnected"); // 终点无法到达
} else {
System.out.println(minDist[end]); // 输出最短路径
}
sc.close();
}
}
代码解释
-
输入处理:
-
n
为节点数量,m
为边的数量。 - 使用
List<int[]>
来存储边的信息,其中int[]
数组表示一条边,包含三个元素[p1, p2, val]
,表示从节点p1
到节点p2
的边,权值为val
。
-
-
初始化:
-
minDist
数组用于存储从起点到各个节点的最短距离,初始化为Integer.MAX_VALUE
(相当于 C++ 中的INT_MAX
),表示无穷大。 -
minDist[start]
设置为 0,表示起点到自身的距离为 0。
-
-
松弛操作:
- 进行
n
次松弛操作,最后一次(即第n
次)松弛用于检测负权回路。 - 每次松弛遍历所有边,如果通过当前边能够找到更短的路径,则更新
minDist
数组。
- 进行
-
检测负权回路:
- 如果在第
n
次松弛操作中,某个节点的最短距离仍然可以更新,说明存在负权回路。
- 如果在第
-
结果输出:
- 如果存在负权回路,输出
"circle"
。 - 如果
minDist[end]
仍然是Integer.MAX_VALUE
,说明终点无法到达,输出"unconnected"
。 - 否则输出起点到终点的最短路径值。
- 如果存在负权回路,输出
时间复杂度和空间复杂度
-
时间复杂度:
O(N * E)
,其中N
为节点数量,E
为图中边的数量。 -
空间复杂度:
O(N)
,主要用于存储minDist
数组。
拓展: 使用 队列优化版的bellman_ford
代码实现
import java.util.*;
class Edge {
int to, val;
Edge(int to, int val) {
this.to = to;
this.val = val;
}
}
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(); // 节点数量
int m = sc.nextInt(); // 边的数量
List<List<Edge>> grid = new ArrayList<>(n + 1); // 邻接表
for (int i = 0; i <= n; i++) {
grid.add(new ArrayList<>());
}
// 将所有边保存起来
for (int i = 0; i < m; i++) {
int p1 = sc.nextInt();
int p2 = sc.nextInt();
int val = sc.nextInt();
// p1 指向 p2,权值为 val
grid.get(p1).add(new Edge(p2, val));
}
int start = 1; // 起点
int end = n; // 终点
int[] minDist = new int[n + 1];
Arrays.fill(minDist, Integer.MAX_VALUE);
minDist[start] = 0;
Queue<Integer> que = new LinkedList<>();
que.offer(start); // 队列里放入起点
int[] count = new int[n + 1]; // 记录节点加入队列的次数
count[start]++;
boolean flag = false;
while (!que.isEmpty()) {
int node = que.poll();
for (Edge edge : grid.get(node)) {
int from = node;
int to = edge.to;
int value = edge.val;
if (minDist[to] > minDist[from] + value) { // 开始松弛
minDist[to] = minDist[from] + value;
que.offer(to);
count[to]++;
if (count[to] == n) { // 如果加入队列次数超过 n-1 次,就说明该图存在负权回路
flag = true;
que.clear(); // 清空队列,跳出循环
break;
}
}
}
}
if (flag) {
System.out.println("circle"); // 存在负权回路
} else if (minDist[end] == Integer.MAX_VALUE) {
System.out.println("unconnected"); // 终点无法到达
} else {
System.out.println(minDist[end]); // 输出最短路径
}
sc.close();
}
}
代码解析
-
输入处理:
-
n
为节点数量,m
为边的数量。 - 使用
List<List<Edge>>
来构建图的邻接表,每个节点对应一个List<Edge>
来存储与其相连的边。
-
-
初始化:
-
minDist
数组用于存储从起点到各个节点的最短距离,初始化为Integer.MAX_VALUE
(相当于 C++ 中的INT_MAX
),表示无穷大。 -
count
数组用于记录每个节点进入队列的次数,初始化为0
。
-
-
SPFA 主循环:
- 将起点节点
start
加入队列,并开始循环松弛操作。 - 每次从队列中取出一个节点
node
,松弛所有与其相连的边。 - 如果通过当前边能够找到更短的路径,则更新
minDist
数组,并将终点to
节点加入队列。 - 如果某个节点进入队列的次数超过
n-1
次,则说明图中存在负权回路,设置flag = true
并终止循环。
- 将起点节点
-
结果输出:
- 如果存在负权回路,输出
"circle"
。 - 如果
minDist[end]
仍然是Integer.MAX_VALUE
,说明终点无法到达,输出"unconnected"
。 - 否则输出起点到终点的最短路径值。
- 如果存在负权回路,输出
时间复杂度和空间复杂度
-
时间复杂度:理论上最坏情况下,SPFA 的时间复杂度为
O(N * E)
,其中N
为节点数量,E
为边的数量。 -
空间复杂度:
O(N)
,主要用于存储minDist
和count
数组,以及队列的空间。
bellman_ford之单源有限最短路
思路
注意题目中描述是 最多经过 k 个城市的条件下,而不是一定经过k个城市,也可以经过的城市数量比k小,但要最短的路径。
-
本题是最多经过 k 个城市, 那么是 k + 1条边相连的节点。 如图:
图中,节点1 最多已经经过2个节点 到达节点4,中间是有3 条边。
所以本题就是求:起点最多经过k + 1 条边到达终点的最短距离。
对所有边松弛一次,相当于计算 起点到达 与起点一条边相连的节点 的最短距离,那么对所有边松弛 k + 1次,就是求 起点到达 与起点k + 1条边相连的节点的 最短距离。
代码实现
- 注意:每次计算 minDist 时候,要基于 对所有边上一次松弛的 minDist 数值才行,所以我们要记录上一次松弛的minDist。
版本1(过不了)
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(); // 节点数量
int m = sc.nextInt(); // 边的数量
List<int[]> grid = new ArrayList<>();
// 读取所有边的信息
for (int i = 0; i < m; i++) {
int p1 = sc.nextInt();
int p2 = sc.nextInt();
int val = sc.nextInt();
// p1 指向 p2,权值为 val
grid.add(new int[]{p1, p2, val});
}
int src = sc.nextInt(); // 起点
int dst = sc.nextInt(); // 终点
int k = sc.nextInt(); // 松弛次数
int[] minDist = new int[n + 1];
Arrays.fill(minDist, Integer.MAX_VALUE);
minDist[src] = 0;
for (int i = 1; i <= k + 1; i++) { // 对所有边松弛 k + 1 次
for (int[] side : grid) {
int from = side[0];
int to = side[1];
int price = side[2];
if (minDist[from] != Integer.MAX_VALUE && minDist[to] > minDist[from] + price) {
minDist[to] = minDist[from] + price;
}
}
// 打印 minDist 数组
for (int j = 1; j <= n; j++) {
if (minDist[j] == Integer.MAX_VALUE) {
System.out.print("INF ");
} else {
System.out.print(minDist[j] + " ");
}
}
System.out.println();
}
if (minDist[dst] == Integer.MAX_VALUE) {
System.out.println("unreachable"); // 不能到达终点
} else {
System.out.println(minDist[dst]); // 输出到达终点的最短路径
}
sc.close();
}
}
版本2
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt(); // 节点数量
int m = sc.nextInt(); // 边的数量
List<int[]> grid = new ArrayList<>();
// 读取所有边的信息
for (int i = 0; i < m; i++) {
int p1 = sc.nextInt();
int p2 = sc.nextInt();
int val = sc.nextInt();
grid.add(new int[]{p1, p2, val});
}
int src = sc.nextInt(); // 起点
int dst = sc.nextInt(); // 终点
int k = sc.nextInt(); // 松弛次数
int[] minDist = new int[n + 1];
Arrays.fill(minDist, Integer.MAX_VALUE);
minDist[src] = 0;
int[] minDistCopy = new int[n + 1]; // 用来记录上一次遍历的结果
for (int i = 1; i <= k + 1; i++) {
System.arraycopy(minDist, 0, minDistCopy, 0, n + 1); // 复制上一次计算的结果
for (int[] side : grid) {
int from = side[0];
int to = side[1];
int price = side[2];
// 使用 minDistCopy 来计算 minDist
if (minDistCopy[from] != Integer.MAX_VALUE && minDist[to] > minDistCopy[from] + price) {
minDist[to] = minDistCopy[from] + price;
}
}
}
if (minDist[dst] == Integer.MAX_VALUE) {
System.out.println("unreachable"); // 不能到达终点
} else {
System.out.println(minDist[dst]); // 输出到达终点的最短路径
}
sc.close();
}
}
拓展2:本题本质
本题的两个因素:
- 本题可以有负权回路,说明只要多做松弛,结果是会变的。
- 本题要求最多经过k个节点,对松弛次数是有限制的。
所以本题 为什么计算minDist 一定要基于上次 的 minDist 数值
拓展3:SPFA
拓展4:能否用dijkstra
看不动了,二刷再来