图的最短路问题
聊到图论,最短路是个绕不开的坎。别看它名字简单,里面的门道可不少。不管是面试还是实际应用(比如地图导航、网络路由),这玩意儿都极其重要。
很多时候我们看到一个算法题,如果没有思路,第一反应是啥?暴力枚举呗。那最短路的暴力解法是啥?把从起点到终点的所有可能路径都列出来,然后一个个算它们的权重和,最后取个最小值。
想法很简单,但你稍微画个图就知道,一个稍微复杂点的图,路径的数量就是指数级爆炸,这谁顶得住?所以,暴力解法咱们心里有数就行了,它只能作为思考的起点,根本没法写。
今天,咱们就从正经的解法开始,一步步把最短路问题给办了。我会给你整明白三种在不同场景下称王称霸的经典算法:Dijkstra、Bellman-Ford 和 Floyd-Warshall。
解法一:Dijkstra 算法 - 没有负权重边的世界,我就是王
Dijkstra 算法可以说是最短路算法里最经典、最常用的了。但是它有个前提:图里不能有负权重边。这个前提非常重要,后面我会解释为什么。
它的核心思想其实是一种贪心策略。想象一下,你站在起点,想去图里的其他所有点。
最开始,你只知道起点到自己的距离是0,到其他所有点的距离都是无穷大(因为还没出发嘛)。
然后,你环顾四周,看看所有与你直接相连的点,选一个离你最近的,比如说点 A。好了,那起点到 A 的最短距离就被你确定了,因为任何更远的路径,都不可能比直接走过去更短了(记住,没有负权重边,所以不存在绕个远路反而能“减负”的情况)。
现在你把点 A “解锁”了。接下来,你就要利用点 A 这个新的据点。从 A 出发,看看它能走到哪些点 B、C、D...。然后更新起点经过 A 到达这些点的距离。比如起点到 C 的距离原来是无穷大,但现在发现可以“起点 -> A -> C”,那就有了一条新路径,你就把这个距离记录下来。如果发现“起点 -> A -> B”比之前记录的直达 B 的路径更短,那当然也更新一下。
重复这个过程:在所有还没“解锁”的点里,再选一个当前已知距离最小的点,解锁它,然后用它来更新它的邻居。
这个过程就像水波纹一样,从起点开始,一圈一圈地向外扩展,每次都锁定一个当前看起来最近的点,最终确定所有点的最短路径。
Dijkstra 的实现细节
最朴素的实现,每次都遍历所有未锁定的点来找那个距离最小的,效率比较低。咱们玩算法的,肯定要追求极致。这里要用一个神器:小根堆(PriorityQueue)。
小根堆能帮我们干啥?它能以 O(logN) 的代价,快速找到那个“当前已知距离最小”的点。
整个流程就变成了:
准备一个
distanceMap,记录起点到各个点的距离,初始时只有起点是0,其他都是无穷大。准备一个小根堆,把起点放进去。
当堆不为空时,弹出堆顶元素(也就是当前已知距离最小的点,记为
cur)。如果
cur已经被处理过了,跳过(这是为了处理堆中可能存在的重复记录)。遍历
cur的所有邻居next。计算从起点经过cur到达next的距离newDist。如果
newDist比distanceMap中记录的到next的距离更短,就更新distanceMap,并把next和它的新距离newDist一起扔进小根堆。
这样一直循环,直到堆为空,distanceMap 里就是起点到所有点的最短路径了。
import java.util.*;
// 表示图的边
class Edge {
Node to;
int weight;
public Edge(Node to, int weight) {
this.to = to;
this.weight = weight;
}
}
// 表示图的节点
class Node {
int value;
List<Edge> edges;
public Node(int value) {
this.value = value;
this.edges = new ArrayList<>();
}
}
// 堆中存放的记录,方便比较
class NodeRecord {
Node node;
int distance;
public NodeRecord(Node node, int distance) {
this.node = node;
this.distance = distance;
}
}
public class DijkstraAlgorithm {
public static Map<Node, Integer> dijkstra(Node start) {
// 从start出发到所有点的距离表
Map<Node, Integer> distanceMap = new HashMap<>();
distanceMap.put(start, 0);
// 已经确定了最短距离的点
HashSet<Node> touchedNodes = new HashSet<>();
// 选一个当前距离最小的、未被碰过的点
Node minNode = getMinDistanceAndUntouchedNode(distanceMap, touchedNodes);
while (minNode != null) {
int distance = distanceMap.get(minNode);
for (Edge edge : minNode.edges) {
Node toNode = edge.to;
// 如果当前点的邻居没在距离表里,或者新的路径更短,就更新
if (!distanceMap.containsKey(toNode) || distanceMap.get(toNode) > distance + edge.weight) {
distanceMap.put(toNode, distance + edge.weight);
}
}
touchedNodes.add(minNode); // 锁定这个点
minNode = getMinDistanceAndUntouchedNode(distanceMap, touchedNodes); // 再选下一个
}
return distanceMap;
}
// 朴素的遍历查找,可以用堆来优化
private static Node getMinDistanceAndUntouchedNode(Map<Node, Integer> distanceMap, HashSet<Node> touchedNodes) {
Node minNode = null;
int minDistance = Integer.MAX_VALUE;
for (Map.Entry<Node, Integer> entry : distanceMap.entrySet()) {
Node node = entry.getKey();
int distance = entry.getValue();
if (!touchedNodes.contains(node) && distance < minDistance) {
minNode = node;
minDistance = distance;
}
}
return minNode;
}
// 堆优化的 Dijkstra
public static Map<Node, Integer> dijkstraWithHeap(Node start) {
Map<Node, Integer> distanceMap = new HashMap<>();
PriorityQueue<NodeRecord> minHeap = new PriorityQueue<>(Comparator.comparingInt(r -> r.distance));
minHeap.add(new NodeRecord(start, 0));
while (!minHeap.isEmpty()) {
NodeRecord record = minHeap.poll();
Node curNode = record.node;
int curDistance = record.distance;
// 如果这个点已经有更短的路径被确定了,就跳过
if (distanceMap.containsKey(curNode)) {
continue;
}
// 第一次从堆里出来,就是最短距离
distanceMap.put(curNode, curDistance);
for (Edge edge : curNode.edges) {
Node toNode = edge.to;
// 只处理尚未确定最短路径的点
if (!distanceMap.containsKey(toNode)) {
minHeap.add(new NodeRecord(toNode, curDistance + edge.weight));
}
}
}
return distanceMap;
}
}
Dijkstra 的贪心之所以对,就是因为没有负权边,保证了每一步锁定的点,其距离都是最终的、不会再被更新的最小值。一旦有了负权边,这个前提就崩了,可能绕个大圈子走一个负权边,总距离反而变小了。
解法二:Bellman-Ford 算法 - 管你有没有负权边,我都能搞定
当图里出现了负权重边,Dijkstra 就歇菜了。这时候,Bellman-Ford 算法就该登场了。它比 Dijkstra 慢,但更强大,能处理负权边,甚至还能检测出负权环。
啥是负权环?就是一个环路,走一圈下来总权重是负的。这意味着你可以无限在这个环里兜圈子,路径长度可以被刷到负无穷,最短路也就没意义了。
Bellman-Ford 的思想特别朴素,有点像动态规划:
初始化所有点到起点的距离为无穷大,起点为0。
进行 N-1 轮松弛(Relaxation)操作(N是图中点的数量)。
每一轮松弛,都遍历图里的所有边。对于每一条边
u -> v,看看distance[u] + weight(u,v)是否比distance[v]更小。如果更小,就更新distance[v]。
为啥是 N-1 轮?因为在一个不包含负权环的图里,任意两点间的最短路径最多包含 N-1 条边。第一轮松弛,能保证找到所有只需要1条边的最短路;第二轮,能找到所有最多需要2条边的最短路……以此类推,经过 N-1 轮,所有可能的最短路都被找到了。
那怎么检测负权环?很简单,在 N-1 轮松弛之后,再进行第 N 轮。如果在这一轮里,还有点的距离能被更新,那说明啥?说明图里一定存在负权环。因为所有不含环的最短路都已经找到了,还能继续变短,只能是因为陷入了一个能无限减小距离的负权环里。
import java.util.*;
public class BellmanFordAlgorithm {
public static Map<Node, Integer> bellmanFord(Node start, List<Node> allNodes) {
Map<Node, Integer> distanceMap = new HashMap<>();
for (Node node : allNodes) {
distanceMap.put(node, Integer.MAX_VALUE);
}
distanceMap.put(start, 0);
// N-1 轮松弛
for (int i = 1; i < allNodes.size(); i++) {
// 遍历所有边
for (Node fromNode : allNodes) {
if (distanceMap.get(fromNode) != Integer.MAX_VALUE) {
for (Edge edge : fromNode.edges) {
Node toNode = edge.to;
if (distanceMap.get(fromNode) + edge.weight < distanceMap.get(toNode)) {
distanceMap.put(toNode, distanceMap.get(fromNode) + edge.weight);
}
}
}
}
}
// 第 N 轮检查负权环
for (Node fromNode : allNodes) {
if (distanceMap.get(fromNode) != Integer.MAX_VALUE) {
for (Edge edge : fromNode.edges) {
Node toNode = edge.to;
if (distanceMap.get(fromNode) + edge.weight < distanceMap.get(toNode)) {
// 如果在第 N 轮还能松弛,说明有负权环
throw new RuntimeException("Graph contains a negative weight cycle!");
}
}
}
}
return distanceMap;
}
}
Bellman-Ford 简单粗暴,时间复杂度是 O(V*E),V是点数,E是边数。在边比较多的稠密图里,性能不如Dijkstra,但在有负权边的稀疏图里,它就是救星。
解法三:Floyd-Warshall 算法 - 我不关心起点,我全都要
前面两个算法都是单源最短路,就是算一个起点到其他所有点的最短距离。如果我现在想知道任意两个点之间的最短路怎么办?
最直接的想法是,把每个点都当成起点,跑 N 遍 Dijkstra 或者 Bellman-Ford。这当然可以,但有没有更优雅的办法?
有,那就是 Floyd-Warshall 算法。它的思想也是动态规划,而且代码极其简洁,就是三层循环。
Floyd-Warshall 的核心是:“我从点 i 到点 j,要不要经过点 k 呢?”
我们用一个二维数组
dp[i][j]来存点i到点j的最短距离。初始化
dp[i][j]:如果i和j之间有直连的边,dp[i][j]就是边的权重;如果i == j,dp[i][j]是0;否则就是无穷大。然后我们引入一个中间点
k,从 1 遍历到 N。对于每一对(i, j),我们都考虑一下,i到j的路径,如果把k作为中转站,会不会更短? 也就是比较dp[i][j]和dp[i][k] + dp[k][j]的大小,取小的那个更新dp[i][j]。
当 k 的循环结束时,dp 数组里就存了任意两点间的最短路径。
为啥这样是对的?当最外层循环到 k 的时候,dp[i][k] 和 dp[k][j] 已经考虑了所有编号小于 k 的点作为中转站的最优路径。所以,这一步实际上是在用编号为 1...k-1 的点作为“桥梁”的基础上,尝试再加一座新桥 k,看看能不能让路程更短。等 k 遍历完所有点,就等于考虑了所有可能的桥梁组合。
public class FloydWarshallAlgorithm {
public static int[][] floydWarshall(int[][] graph) {
int n = graph.length;
int[][] dist = new int[n][n];
// 初始化距离矩阵
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
dist[i][j] = graph[i][j];
}
}
// k 是中间点
for (int k = 0; k < n; k++) {
// i 是起点
for (int i = 0; i < n; i++) {
// j 是终点
for (int j = 0; j < n; j++) {
// 防止溢出
if (dist[i][k] != Integer.MAX_VALUE && dist[k][j] != Integer.MAX_VALUE) {
dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
}
return dist;
}
public static void main(String[] args) {
// 邻接矩阵表示图, MAX_VALUE 表示不连通
int INF = Integer.MAX_VALUE / 2; // 防止加法溢出
int[][] graph = {
{0, 5, INF, 10},
{INF, 0, 3, INF},
{INF, INF, 0, 1},
{INF, INF, INF, 0}
};
int[][] shortestPaths = floydWarshall(graph);
for (int i = 0; i < shortestPaths.length; i++) {
for (int j = 0; j < shortestPaths.length; j++) {
if (shortestPaths[i][j] == INF) {
System.out.print("INF ");
} else {
System.out.print(shortestPaths[i][j] + " ");
}
}
System.out.println();
}
}
}
Floyd-Warshall 的时间复杂度是 O(N³),和边的数量无关,所以它尤其适合求解稠密图的所有点对最短路问题。
总结一下
到这儿,最短路的三大金刚咱们就都认识了。怎么选?看场景:
| 算法 | 适用场景 | 能否处理负权边 | 时间复杂度 | 核心思想 |
| Dijkstra | 单源最短路,无负权边 | 否 | O(E log V) (堆优化) | 贪心,每次选最近的点 |
| Bellman-Ford | 单源最短路,可处理负权边,可检测负权环 | 是 | O(V * E) | 动态规划,松弛 N-1 轮 |
| Floyd-Warshall | 所有点对最短路,可处理负权边 | 是 | O(V³) | 动态规划,枚举中转点 |