mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
1754 字
4 分钟
Dijkstra 单源最短路径
2026-06-15 13:22:19

一、问题定义#

给定一张带权有向图(或无向图)和一个源点 ss,求从 ss 到其余每个顶点的最短路径长度,这就是单源最短路径问题。

适用前提

Dijkstra 算法要求图中所有边的权值非负w0w \ge 0)。若存在负权边,需改用 Bellman-Ford 或 SPFA 算法。


二、基本思想#

Dijkstra 算法采用贪心策略,结构上与 Prim 最小生成树 高度相似。

将所有顶点分为两组:

  • 已敲定集合 SS:已经确定了最短路径的顶点。
  • 未敲定集合 QQ:尚未确定最短路径的顶点。

用数组 d[]d[] 记录源点 ss 到每个顶点的当前已知最短距离估计值,初始时:

d[v]={0,v=s+,vsd[v] = \begin{cases} 0, & v = s \\ +\infty, & v \ne s \end{cases}

算法的每一轮做两件事:

  1. 选择顶点:从未敲定集合 QQ 中取出 dd 值最小的顶点 uu,将它移入已敲定集合 SS——此时 d[u]d[u] 即为 ssuu 的真实最短距离。
  2. 松弛:对于 uu 的每一条出边 (u,v,w)(u, v, w),若 d[u]+w<d[v]d[u] + w < d[v],则更新 d[v]=d[u]+wd[v] = d[u] + w

重复上述过程,直到所有顶点都被敲定为止。

局部最优推导全局最优

关键在于非负权值。当 uu 被选出时,它的估计距离最小,其他未确认顶点 xx 都满足 d[x]d[u]d[x] \ge d[u]。由于边权非负,绕到 xx 再回到 uu 只会更远,不可能得到比 d[u]d[u] 更短的路径。因此 d[u]d[u] 可以被安全敲定。

算法整体流程如下:

算法流程


三、算法执行步骤图解#

以如下 6 顶点带权有向图为例(顶点:A,B,C,D,E,FA, B, C, D, E, F,源点为 AA):

示例图

权值
A→B4
A→C2
B→C1
B→D5
C→B1
C→D8
C→E10
D→E2
D→F6
E→F3

初始状态:d=[0, , , , , ]d = [0,\ \infty,\ \infty,\ \infty,\ \infty,\ \infty]S=S = \emptyset

初始状态


第 1 轮:选出 AAd=0d=0#

松弛出边 AB(4)A \to B(4)AC(2)A \to C(2)d[B]=4d[B]=4d[C]=2d[C]=2

第 1 轮


第 2 轮:选出 CCd=2d=2#

松弛 CB(1)C \to B(1)d[B]d[B]44 降至 33;松弛 CD(8)C \to D(8)CE(10)C \to E(10)d[D]=10d[D]=10d[E]=12d[E]=12

第 2 轮


第 3 轮:选出 BBd=3d=3#

BC(1)B \to C(1)CC 已敲定,跳过。BD(5)B \to D(5)d[D]d[D]1010 降至 88

第 3 轮


第 4 轮:选出 DDd=8d=8#

松弛 DE(2)D \to E(2)d[E]d[E]1212 降至 1010;松弛 DF(6)D \to F(6)d[F]=14d[F]=14

第 4 轮


第 5 轮:选出 EEd=10d=10#

松弛 EF(3)E \to F(3)d[F]d[F]1414 降至 1313

第 5 轮


第 6 轮:选出 FFd=13d=13#

FF 无出边,不再松弛。所有顶点敲定,算法结束。

第 6 轮

最终结果#

源点 AA 到各顶点的最短距离:

d[A]=0,d[C]=2,d[B]=3,d[D]=8,d[E]=10,d[F]=13d[A]=0,\quad d[C]=2,\quad d[B]=3,\quad d[D]=8,\quad d[E]=10,\quad d[F]=13

最短路径树由 5 条边构成(绿色加粗):ACBDEFA \to C \to B \to D \to E \to F

最短路径树


四、两种实现方式#

4.1 朴素实现(邻接矩阵,O(V2)O(V^2)#

适合稠密图(边数 EV2E \approx V^2)。每轮线性扫描所有顶点,找出 dd 值最小的未敲定顶点。

import java.util.Arrays;
public class DijkstraMatrix {
private static final int INF = Integer.MAX_VALUE / 2;
/**
* 朴素 Dijkstra(邻接矩阵版)。
* @param graph graph[u][v] 表示 u→v 的边权,INF 表示无边
* @param src 源点编号
* @return 源点到各顶点的最短距离数组
*/
public static int[] dijkstra(int[][] graph, int src) {
int n = graph.length;
int[] d = new int[n];
boolean[] settled = new boolean[n];
Arrays.fill(d, INF);
d[src] = 0;
for (int iter = 0; iter < n; iter++) {
// 1. 从未敲定顶点中选出 d 值最小的顶点 u
int u = -1;
for (int v = 0; v < n; v++) {
if (!settled[v] && (u == -1 || d[v] < d[u])) {
u = v;
}
}
if (d[u] == INF) break; // 剩余顶点不可达,提前退出
// 2. 敲定 u,松弛所有出边
settled[u] = true;
for (int v = 0; v < n; v++) {
if (!settled[v] && graph[u][v] < INF) {
d[v] = Math.min(d[v], d[u] + graph[u][v]);
}
}
}
return d;
}
public static void main(String[] args) {
// 顶点编号:A=0, B=1, C=2, D=3, E=4, F=5
int n = 6;
int[][] g = new int[n][n];
for (int[] row : g) Arrays.fill(row, INF);
for (int i = 0; i < n; i++) g[i][i] = 0;
g[0][1] = 4; // A→B
g[0][2] = 2; // A→C
g[1][2] = 1; // B→C
g[1][3] = 5; // B→D
g[2][1] = 1; // C→B
g[2][3] = 8; // C→D
g[2][4] = 10; // C→E
g[3][4] = 2; // D→E
g[3][5] = 6; // D→F
g[4][5] = 3; // E→F
int[] dist = dijkstra(g, 0);
String[] name = {"A", "B", "C", "D", "E", "F"};
System.out.println("从 A 出发的最短距离:");
for (int i = 0; i < n; i++) {
System.out.println(" A → " + name[i] + " = " + dist[i]);
}
}
}

运行输出:

从 A 出发的最短距离:
A → A = 0
A → B = 3
A → C = 2
A → D = 8
A → E = 10
A → F = 13

4.2 堆优化实现(邻接表 + 优先队列,O(ElogV)O(E \log V)#

适合稀疏图(边数 EV2E \ll V^2)。用最小堆维护 dd 值最小的候选顶点,每次松弛后将新的 (d[v],v)(d[v], v) 压入堆,避免线性扫描。

懒删除策略

堆中可能存有同一顶点的多条过期记录。弹出时检查该记录的距离是否等于当前 d[v]d[v];若不等,说明已过期,跳过即可。

import java.util.*;
public class DijkstraHeap {
private static final int INF = Integer.MAX_VALUE / 2;
/**
* 堆优化 Dijkstra(邻接表版)。
* @param adj adj[u] 存储所有从 u 出发的边:int[]{v, weight}
* @param n 顶点总数
* @param src 源点编号
* @return 源点到各顶点的最短距离数组
*/
public static int[] dijkstra(List<int[]>[] adj, int n, int src) {
int[] d = new int[n];
Arrays.fill(d, INF);
d[src] = 0;
// 最小堆:按距离升序,存储 {dist, vertex}
PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
pq.offer(new int[]{0, src});
while (!pq.isEmpty()) {
int[] top = pq.poll();
int dist = top[0];
int u = top[1];
// 过期记录:跳过
if (dist > d[u]) continue;
// 松弛所有出边
for (int[] edge : adj[u]) {
int v = edge[0];
int w = edge[1];
if (d[u] + w < d[v]) {
d[v] = d[u] + w;
pq.offer(new int[]{d[v], v});
}
}
}
return d;
}
@SuppressWarnings("unchecked")
public static void main(String[] args) {
int n = 6;
List<int[]>[] adj = new ArrayList[n];
for (int i = 0; i < n; i++) adj[i] = new ArrayList<>();
// 顶点编号:A=0, B=1, C=2, D=3, E=4, F=5
adj[0].add(new int[]{1, 4}); // A→B
adj[0].add(new int[]{2, 2}); // A→C
adj[1].add(new int[]{2, 1}); // B→C
adj[1].add(new int[]{3, 5}); // B→D
adj[2].add(new int[]{1, 1}); // C→B
adj[2].add(new int[]{3, 8}); // C→D
adj[2].add(new int[]{4, 10}); // C→E
adj[3].add(new int[]{4, 2}); // D→E
adj[3].add(new int[]{5, 6}); // D→F
adj[4].add(new int[]{5, 3}); // E→F
int[] dist = dijkstra(adj, n, 0);
String[] name = {"A", "B", "C", "D", "E", "F"};
System.out.println("从 A 出发的最短距离:");
for (int i = 0; i < n; i++) {
System.out.println(" A → " + name[i] + " = " + dist[i]);
}
}
}

4.3 如何记录最短路径本身#

上面的代码只输出了最短距离。若需要还原完整的最短路径,增加一个 prev[] 数组,在每次松弛成功时记录前驱顶点即可。

int[] prev = new int[n];
Arrays.fill(prev, -1);
// 松弛时同步更新前驱
if (d[u] + w < d[v]) {
d[v] = d[u] + w;
prev[v] = u; // 记录:到达 v 的前一跳是 u
pq.offer(new int[]{d[v], v});
}

还原路径(逆序追溯):

public static List<Integer> getPath(int[] prev, int target) {
List<Integer> path = new ArrayList<>();
for (int v = target; v != -1; v = prev[v]) {
path.add(v);
}
Collections.reverse(path);
return path;
}

五、复杂度分析#

设顶点数为 VV,边数为 EE

实现方式时间复杂度空间复杂度适用场景
朴素实现(邻接矩阵)O(V2)O(V^2)O(V2)O(V^2)稠密图
堆优化(邻接表 + 优先队列)O(ElogV)O(E \log V)O(V+E)O(V + E)稀疏图

朴素实现分析

外层循环执行 VV 轮,每轮做两件事:

  1. 线性扫描所有未敲定顶点,找出 dd 值最小者,耗时 O(V)O(V)
  2. 遍历 uu 对应的邻接矩阵行,松弛所有出边,耗时 O(V)O(V)

因此:

T=V×(O(V)+O(V))=V×O(V)=O(V2)T = V \times (O(V) + O(V)) = V \times O(V) = O(V^2)

稠密图中 EV2E \approx V^2,此时 O(V2)=O(E)O(V^2) = O(E)。相比堆优化版的 O(ElogV)O(E \log V),矩阵版少一个 logV\log V 因子,且常数更小,实际常常更快。

堆优化分析

每条边最多触发一次松弛,每次松弛将新记录压入优先队列,耗时 O(logV)O(\log V),因此松弛操作的总代价为:

Trelax=O(ElogV)T_{\text{relax}} = O(E \log V)

堆中可能存有同一顶点的多条过期记录,但总记录数不超过 EE。每次弹出时若发现记录已过期则直接跳过,跳过操作的总次数不超过 EE,单次弹出耗时 O(logE)O(\log E)

由于图中边数满足 EV2E \le V^2,可得:

EV2    logElog(V2)=2logVE \le V^2 \implies \log E \le \log(V^2) = 2\log V

从而在渐进意义下:

O(logE)=O(logV)O(\log E) = O(\log V)

因此跳过操作的总代价为 O(ElogV)O(E \log V),与松弛操作同阶。综上,堆优化版的总时间复杂度为 O(ElogV)O(E \log V)

与 Prim 算法的对比

Dijkstra 与堆优化 Prim 的代码骨架几乎一致,区别在于:

  • Prim:松弛时比较的是边权 w(u,v)w(u, v),维护的是顶点与已有树的最近距离。
  • Dijkstra:松弛时比较的是路径总长 d[u]+w(u,v)d[u] + w(u, v),维护的是顶点与源点的最短距离。
手算技巧

手算 Dijkstra 时,画一张距离表逐轮更新即可:每轮圈出当前 dd 值最小的未敲定顶点,沿其出边更新邻居。顶点一旦敲定,最短距离就不会再变。若剩余未敲定顶点的 dd 值均为 \infty,说明它们从源点不可达,可以停笔。


六、Dijkstra 与 Bellman-Ford 对比#

Dijkstra 并非在所有图上都适用,当图中含负权边时,需要使用 Bellman-Ford 算法。

维度DijkstraBellman-Ford
负权边不支持(需非负权值)支持
负权环检测不支持支持
时间复杂度O(ElogV)O(E \log V)(堆优化)O(VE)O(V \cdot E)
适用场景非负权图,追求速度含负权边,或需要检测负环
算法策略贪心,顶点逐一敲定动态规划,反复松弛所有边

七、总结#

Dijkstra 算法可以概括为:

维护一个距离估计数组 d[]d[],每次从未敲定的顶点中贪心地选出距源点最近的那个,将其敲定,然后沿其出边松弛邻居的距离估计值,如此往复直到所有顶点敲定为止。

要点梳理:

  • 正确性依据:边权非负保证了贪心选择的局部最优即全局最优,已敲定顶点的距离不会再被缩短。
  • 两种实现:稠密图用邻接矩阵朴素实现(O(V2)O(V^2));稀疏图用邻接表加最小堆(O(ElogV)O(E \log V)),两者适用场景不同。
  • 路径还原:增加 prev[] 数组记录前驱,松弛时同步更新,最后逆序追溯即可。
  • 局限性:不支持负权边,遇到负权图需改用 Bellman-Ford 或 SPFA。

口诀记忆:

初始源点距离零,其余顶点无穷大;每轮选出最近点,沿边松弛更新它。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Dijkstra 单源最短路径
https://dawn114514.site/posts/algorithm/dijkstrasssp/
作者
黎明
发布于
2026-06-15 13:22:19
许可协议
MIT

部分信息可能已经过时

目录