做Java后端这几年,要说图论里哪个算法最让我觉得“一次学会、到处能用”,Dijkstra(迪杰斯特拉)算法绝对排第一。它解决的是非负权重图上的单源最短路径问题:给定一个起点,算出它到所有其他节点的最短距离。这个问题的应用范围非常广:地图导航、网络路由、物流配送、社交推荐里的好友距离,几乎都能归约到同一个模型。所以这篇文章不打算只贴一段代码,而是把思路、Java实现、工程里那些微妙的坑一起讲清楚。适合两种人看:一是刚学图的初学者,能把算法真正弄懂;二是写业务代码时遇到“最短距离”需求,需要快速落地的开发者。
1. 项目概述:Dijkstra算法到底在解决什么问题
1.1 最短路径问题的应用场景与抽象模型
整个问题可以用一张图来抽象。图中每个节点代表一个地理位置、一台路由器、一个订单配送点,或者一个用户;每条边代表两个节点之间存在一条可达路径,边上带的数值就是权重,可以理解为距离、时间、成本或跳数的等价代价。最短路径问题就是在这张带权图里,找到从起点出发到其他所有节点的权重总和最小的路径。
拿城市地图来说,节点是路口,边是道路,权重是道路长度。导航软件算“从A到B要多久”,本质上就是在做一次单源最短路径计算。不同点在于,现实中节点数量可能达到百万级别,边的数量更多,所以算法必须足够快,这也是Dijkstra比一些朴素穷举方案更受青睐的原因。
1.2 为什么选择Dijkstra而不是其他算法
很多人会问:图里找路径,用广度优先搜索不行吗?BFS确实能求最短路径,但它默认每条边权重相同,遇到带权图就失效了。比如一条路虽然经过的节点多,但总时间更短,BFS无法区分这种情况。Floyd算法可以求所有节点对之间的最短距离,但时间复杂度是O(V^3),当节点数过万时基本不可用。Bellman-Ford允许负权边,代价是时间更慢,平均性能远不如Dijkstra。
所以当时有个项目要计算几十个节点的配送路线,我优先考虑的就是Dijkstra。它有一个非常强的前提:所有边权重都不能为负。在这个前提下,它能用贪心策略以极小的代价找到全局最优解。对于绝大多数工程场景,距离、时间、成本都是非负数,这个限制几乎不构成约束。因此Dijkstra成为最短路径领域里使用频率最高的单源算法。
2. 核心思路拆解:迪杰斯特拉算法的贪心逻辑
2.1 从直觉到算法:手动找最短路径的过程
先看一个生活化的场景。你站在起点S,面前有A、B、C三个目的地,S到A是5公里,S到B是2公里,S到C是10公里。直觉告诉你,B一定比C更近,所以应先研究从B出发还能去哪,看有没有可能通过B“绕近路”到达A。如果从B再到A只要1公里,那么S->B->A总长度3公里,明显优于直连S->A的5公里。
Dijkstra的思路和你手动做这件事完全一致:每次从“还没确定最短距离”的节点里,挑一个当前已知距离最小的节点,把它标记为已确定,然后从这个节点出发,尝试更新它邻居的距离。数学上这叫松弛操作。整个过程就像不断从候选名单里“录取”最近的点,再用它去刷新其他候选点的距离。
2.2 三个关键数据结构:dist、visited、parent
写代码前需要三个基础变量。第一个是dist数组,记录起点到每个节点当前已知的最短距离,初始时起点为0,其余为正无穷。第二个是visited数组(或者叫确定集合),标记某个节点的最短距离是否已经确定,一旦确定就不再改变。第三个是parent数组,记录每个节点的前驱节点,方便最后还原整条路径。
这三个变量对应着算法执行的主线。dist是核心结果,visited保证每个节点只“确定”一次,parent是附带的路径信息。需要注意,在有向图中,从u出发到v的边并不意味着v到u也能走,所以初始化图时要严格区分方向。无向图则要把每条边在两个方向上都加一遍。
2.3 为什么贪心策略能保证正确
这里很多人一开始不理解:每次选距离最小的点,凭什么它已经是最优?反直觉的地方在于,当前最小不代表后续不会通过其他节点刷新出更小值。但Dijkstra依赖一个关键性质:所有边权重非负。假设当前候选点是u,dist[u]=10,如果未来存在一条通过别的节点w到u的路径,那么到w的距离必须小于等于10,而且w必须比u先被处理。这就形成了一个闭环矛盾:如果w比u先被取出,那么当时就应该把dist[u]更新得更小;如果w还没被取出,它的距离又不可能小于等于10。因此在非负边假设下,u被取出那一刻的dist[u]就是最终最短距离。
感性的理解方式是这样的:正权边意味着每走一步都在“增加代价”,你不可能通过绕路让已经确定的距离变得更短。就像你去超市,已知走直线要10分钟,哪怕中间有一家新开的店,也不可能让直线距离“倒贴”时间。正是这个性质让贪心选择在每一步都站得住脚。
2.4 为什么负权边会让算法失效
一旦出现负权边,上面的推导立刻崩塌。举个经典反例:S到A的权重是50,S到C的权重是20,C到A的权重是-30。算法先从S出发,确定C距离20,确定A距离50。但真实最短路径是S->C->A,总权重是20加负30等于-10,比50小得多。因为负权边,到达A后再通过绕路能“越走越短”,这与贪心的前提矛盾。
所以在业务系统里,如果数据源里的路径代价可能出现负数,千万不要直接用Dijkstra。要么检查数据有没有异常值,要么换用Bellman-Ford。我见过一个报价系统因为折扣价被当成负数权重,在Dijkstra里跑出了完全不符合业务逻辑的路径,排查了整整一个下午才定位到是负权问题。
3. Java代码实现:从邻接表到堆优化
3.1 图的存储选型:邻接矩阵还是邻接表
写Java实现前,第一个要决策的是图怎么存。邻接矩阵用二维数组存任意两点间的边,查询两个节点之间是否有边是O(1),但空间是O(V^2),节点一多就很浪费。邻接表用List []存放每个节点出发的边,空间只有O(V+E),遍历邻居非常自然,虽然查询两点间是否有边不是直接索引,但Dijkstra本身就需要遍历邻居,这个问题不存在。
实际工程里,超过一千个节点我基本都选邻接表,存储更省,遍历更快。只有节点数很少、并且你需要频繁判断“两个点是否相连”的场景,邻接矩阵才有优势。矩阵版还有个好处:代码更简单,适合初学理解。所以下面两种都给出完整实现。
3.2 小根堆优化版:完整的Java实现
堆优化版的核心变化是,把“每次遍历找最小距离节点”的操作,换成用Java的PriorityQueue来维护候选节点。用一个Node对象同时记录节点id和当前距离,PriorityQueue按距离从小到大排序,取出堆顶就是当前距离最小的节点。
import java.util.*; /** * Dijkstra算法 - 邻接表 + 小根堆优化 */ public class Dijkstra { // 有向边 static class Edge { int to; int weight; Edge(int to, int weight) { this.to = to; this.weight = weight; } } // 优先队列中的节点 static class Node implements Comparable<Node> { int id; int dist; Node(int id, int dist) { this.id = id; this.dist = dist; } @Override public int compareTo(Node other) { return Integer.compare(this.dist, other.dist); } } private final List<List<Edge>> graph; private final int n; public Dijkstra(int n) { this.n = n; graph = new ArrayList<>(n); for (int i = 0; i < n; i++) { graph.add(new ArrayList<>()); } } // 添加有向边 from -> to public void addEdge(int from, int to, int weight) { graph.get(from).add(new Edge(to, weight)); } // 如果是无向图,调用 addEdge(u, v, w); addEdge(v, u, w); public int[] dijkstra(int start) { int[] dist = new int[n]; Arrays.fill(dist, Integer.MAX_VALUE); dist[start] = 0; PriorityQueue<Node> pq = new PriorityQueue<>(); pq.offer(new Node(start, 0)); while (!pq.isEmpty()) { Node cur = pq.poll(); int u = cur.id; // 关键:跳过过时节点 if (cur.dist > dist[u]) { continue; } for (Edge edge : graph.get(u)) { int v = edge.to; int newDist = dist[u] + edge.weight; if (newDist < dist[v]) { dist[v] = newDist; pq.offer(new Node(v, newDist)); } } } return dist; } }这里有个细节值得反复强调:因为PriorityQueue不支持直接修改某个已有节点的距离,代码里的做法是每次松弛成功就new一个Node放进堆里。这样同一个节点可能多次入队,堆里同时存在多个“旧距离”和“新距离”的副本。取出堆顶时,如果发现cur.dist比dist[u]大,说明这是一个被更新过的旧节点,直接丢弃即可。
3.3 朴素版实现:适合稠密图的写法
如果不引入堆,直接用一个循环来反复挑选当前距离最小的未确定节点,就是最直观的朴素Dijkstra。它稳定,好写,而且在节点数少或图特别稠密的时候,性能并不比堆优化差,因为堆上的log操作也不是没有开销。
/** * Dijkstra算法 - 邻接矩阵朴素版 * graph[i][j] 表示 i 到 j 的权重,不可达用 Integer.MAX_VALUE */ public int[] dijkstraMatrix(int[][] graph, int start) { int n = graph.length; int[] dist = new int[n]; boolean[] visited = new boolean[n]; Arrays.fill(dist, Integer.MAX_VALUE); dist[start] = 0; for (int i = 0; i < n; i++) { // 1. 找到未确定节点中距离最小的 u int u = -1; int minDist = Integer.MAX_VALUE; for (int j = 0; j < n; j++) { if (!visited[j] && dist[j] < minDist) { minDist = dist[j]; u = j; } } // 剩下的节点都不可达 if (u == -1) { break; } // 2. 标记 u 已确定 visited[u] = true; // 3. 松弛 u 的所有邻居 for (int v = 0; v < n; v++) { if (!visited[v] && graph[u][v] != Integer.MAX_VALUE && dist[u] + graph[u][v] < dist[v]) { dist[v] = dist[u] + graph[u][v]; } } } return dist; }外层的for循环跑V轮,每一轮找最小点需要遍历V个节点,所以即使图里的边很少,它也会把整个矩阵扫一遍。这就是它适合稠密图的原因——反正边很多,矩阵本来就在那,扫邻接表反而看不出优势。
3.4 路径还原:给算法加上“指路”能力
只输出距离在很多场景不够用。比如“从A到B最短是12公里”,用户更想知道“怎么走”。这就要靠parent数组。每次在松弛邻居v成功时,把parent[v]记录为u。等所有距离计算完成后,从终点一路向前溯回到起点,就得到了完整路径。
public int[] dijkstraWithPath(int start, int[] parent) { int[] dist = new int[n]; Arrays.fill(dist, Integer.MAX_VALUE); Arrays.fill(parent, -1); dist[start] = 0; PriorityQueue<Node> pq = new PriorityQueue<>(); pq.offer(new Node(start, 0)); while (!pq.isEmpty()) { Node cur = pq.poll(); int u = cur.id; if (cur.dist > dist[u]) continue; for (Edge edge : graph.get(u)) { int v = edge.to; int newDist = dist[u] + edge.weight; if (newDist < dist[v]) { dist[v] = newDist; parent[v] = u; // 记录前驱 pq.offer(new Node(v, newDist)); } } } return dist; } // 从终点反向还原路径 public List<Integer> buildPath(int end, int[] parent) { List<Integer> path = new ArrayList<>(); for (int cur = end; cur != -1; cur = parent[cur]) { path.add(cur); } Collections.reverse(path); return path; }还原路径时有个容易忽略的点:如果某个节点不可达,parent数组里存的是-1,反向回溯会因为死循环而出问题。所以在buildPath里,循环终止条件写成cur != -1,并在开头判断终点是否可达。
3.5 测试用例:跑一遍看看结果
给一个简单的有向图来验证代码。节点0到节点1权重4,节点0到节点2权重2,节点2到节点1权重1,节点2到节点3权重5,节点1到节点3权重1。从0出发,肉眼算一下:0->2是2,0->2->1是3,0->2->3是7,0->2->1->3是4,所以dist[1]=3,dist[2]=2,dist[3]=4。
用上面的类写个main方法跑一遍,输出应该符合预期。这种“先手算、再对程序”的习惯建议保留,很多隐藏bug就是靠这种简单用例抓出来的。测试时还可以刻意把权重换大一点,比如用Integer.MAX_VALUE附近的数,看看溢出有没有被正确处理。
4. 常见问题与排查技巧实录
4.1 Integer.MAX_VALUE引发的整数溢出
这是Java里最容易踩的坑。dist初始值是Integer.MAX_VALUE,如果某条边的权重是正数,dist[u] + edge.weight会直接溢出成负数,导致松弛条件newDist < dist[v]错误成立,甚至把负数距离写进dist。结果就是算法莫名其妙跑出一个负的最短路径,看起来像负权图,实际上只是整数溢出了。
正确的防御性写法是在每次取到节点u时判断:如果dist[u]已经是Integer.MAX_VALUE,说明u根本不可达,直接continue,不要参与后面的加法运算。
if (dist[u] == Integer.MAX_VALUE) { continue; }这里的判断位置很讲究。如果放在取堆顶之后、遍历邻居之前,就能避免任何一次不必要的溢出风险。养成这个习惯后,很多诡异案例都可以在一开始就排除。
4.2 优先队列里的“过时节点”怎么处理
堆优化版最隐蔽的问题就是重复入队。同一个节点v因为距离被不断更新,可能往堆里塞了多个副本。假设v先以距离5入队,后来找到距离3的路径,又入队一次。队列里就会有(5, v)和(3, v)两个节点。如果不对旧副本做判断,程序可能先用旧的5去更新邻居,导致原本应被3覆盖的路径被污染。
解决办法就是上面那段代码:
if (cur.dist > dist[cur.id]) { continue; }这个判断几乎成了堆优化Dijkstra的标准写法。忘了写,结果大概率是正确但慢;在极端数据下也可能算错。我见过不少同学第一次写完这题后,把continue这行删掉,觉得“反正堆会帮我排序”。实测下来,节点多、边多时,旧节点会白白占据大量堆空间,性能下降非常明显。
4.3 无向图、重边、自环的工程处理
无向图的写法是把同一条边在两个方向都加上,这一点很简单,但经常漏。如果只加了一个方向,算法会少算很多可达路径,输出距离偏大,排查起来还得回头检查加边函数。重边的情况也不少见:同一对节点之间可能有多条不同权重的边,比如两条路都连接同一个路口,但长度不同。邻接表实现无需特殊处理,因为松弛条件是严格小于,短的边自然会覆盖长的边。邻接矩阵则需要自己取最小值存进去。
自环就是节点到自己的一条边,对Dijkstra几乎没影响。因为dist[u]到自己永远不满足dist[u] + weight < dist[u](正权情况下),可以不用管。但要注意,如果自环权重是负数,算法就会失效,这在业务上通常属于脏数据。
4.4 节点不可达怎么判断
当图不是强连通图时,有些节点从起点出发根本到不了。这些节点的dist会保持Integer.MAX_VALUE。在输出结果时,如果不加判断直接打印,用户看到的就是一个2147483647,非常不友好。建议在业务层统一转换一下,输出“不可达”或者-1。
还有一种情况:起点本身不可达自身以外的所有节点,外层循环第一次就找不到u,直接break。这在朴素版代码里已经处理了。在堆优化版里,堆会在处理完起点后变空,自然退出循环,不需要额外处理。不过仍需在后续逻辑里判断MAX_VALUE的节点。
5. 算法复杂度与工程选型建议
5.1 两种实现的时间复杂度对照
朴素版采用“循环找最小点”的策略,外层V次,找最小点需要扫描V个节点,整体复杂度是O(V^2)。堆优化版用优先队列取出最小点,每次出堆和入堆都是O(log V),总共V次出堆、E次入堆(严格说E次松弛最多导致E次入堆),复杂度是O((V+E)logV)。
| 实现方式 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 邻接矩阵朴素版 | O(V^2) | O(V^2) | 节点少、图稠密、代码简单 |
| 邻接表堆优化版 | O((V+E)logV) | O(V+E) | 节点多、图稀疏、性能优先 |
有一类特殊场景值得注意:当E接近V^2时,堆优化版的复杂度变成O(V^2 logV),反而比朴素版慢。所以写算法题时,如果题目给的是稠密图,比如V只有几百而边几乎全连,朴素的O(V^2)版本往往更快,因为堆操作带来的常数和log开销完全没有必要。
5.2 与其他最短路径算法的横向对比
选型时最怕的就是把算法用错场景。我把常见的几个算法放在一起对比:
| 算法 | 边权要求 | 复杂度 | 适用场景 |
|---|---|---|---|
| BFS | 无权图 | O(V+E) | 所有边权重相等的图 |
| Dijkstra | 非负权 | O((V+E)logV) | 单源、非负权、性能优先 |
| Bellman-Ford | 允许负权 | O(VE) | 检测负权环、边权可为负 |
| Floyd | 允许负权(不能有负环) | O(V^3) | 多源全最短路、节点少 |
BFS其实可以看作Dijkstra在权重全为1时的特例,因为它不用考虑边权重,直接用队列就能保证按层访问。Floyd代码很短,适合密集的小图。Bellman-Ford虽然慢,但能在V-1轮内完成松弛,并且能检测负权环,这些能力Dijkstra不具备。SPFA是Bellman-Ford的队列优化版,实际效果不稳定,图中出现负权边时可以作为考虑项,但工程上如果是严格非负图,依然首选Dijkstra。
5.3 实战中的选型建议
结合项目的实际情况,我一般会按这样的规则来选。节点数在一万以内,边比较稀疏,无脑用堆优化版。节点数在几百且是稠密图,直接用朴素版,代码简单还快。有负权边但确认没有负环,用Bellman-Ford。需要所有节点对之间的最短路,而且节点数在两百以内,用Floyd,直接三层for循环完事。
还有一个经验:写高并发接口时,算法的时间复杂度再低,也要先评估单次请求的耗时。如果图被多个请求共享且数据不变,可以考虑预计算或者反向Dijkstra,把“多终点查询”变成“单源查询”。如果图本身就频繁变化,预计算就没有意义,不如每次实时计算,但要把节点规模控制住。
6. 实战案例与扩展延伸
6.1 案例:某物流配送系统的路径规划
之前做一个物流配送系统的模拟模块,需求是根据仓库位置给多个配送点找最短配送路线。当时把仓库和配送点抽象成节点,道路抽象成带权边,权重用行驶时间代替距离。因为时间受到路况影响,不是一个静态值,我先把整个图简化成非负权图,再把实时路况因子作为权重的修正项,这样就能复用Dijkstra。
具体落地时用了邻接表加堆优化的实现,起点是仓库,跑一次Dijkstra得到所有配送点的最短时间。然后再对配送顺序做一个简单的贪心排序,先送时间最近的,再送次近的。虽然真正的最优配送顺序是旅行商问题,但在样本量不大的情况下,这种“最短路径+贪心顺序”的方案已经能显著优于人工经验排序。项目上线后,平均配送距离下降了大约百分之十几。
6.2 工程优化:从Dijkstra到A*、双向Dijkstra
Dijkstra是所有非负权最短路径算法的地基,它的两个常用扩展值得了解。
A*算法在Dijkstra的基础上加了一个启发式函数,估计算到目标节点的剩余距离。堆的排序键从dist[u]变成了dist[u] + h(u),其中h(u)是一个可采纳的估计值,比如在地图场景下可以用欧几里得距离。这样做的好处是优先扩展那些“看起来更接近终点”的节点,搜索空间大幅缩小。代价是h要设计好,如果不可采纳,算法可能不是最优的。
双向Dijkstra适合起点和终点都明确的场景:从起点正向跑,从终点反向跑,两边交替扩展,直到两个方向的搜索范围相遇,再把两半路径拼接起来。理论上的搜索空间大约缩小到原来的二分之一量级,在大型地图上收益非常可观。实际做导航类项目时,这几乎是标配优化。
6.3 在线评测与面试中的常见变形
在算法题里,Dijkstra经常以几种变形出现。第一种是裸的单源最短路,直接套堆优化模板。第二种是求“瓶颈路径”,把松弛条件从加权重改成取最大值,问的是路径上最大边权的最小值,思路还是Dijkstra但比较条件变了。第三种是二维网格上的最短路径,把每个格子当节点,四个方向移动,权重设为1,实际上就是BFS的特例,但也可以用Dijkstra理解。
面试的时候,很多候选人能背出模板,但被问到“旧节点为什么要跳过”就懵了。这恰恰是考察是否理解堆优化实现的关键点。建议大家把模板代码读懂之后,亲手实现三遍:第一遍照着写,第二遍不看代码默写,第三遍讲给自己听每一步在干什么。做到第三遍,面试和工程里遇到变形题都能稳得住。
我个人在实际项目里还有一个习惯:但凡核心路径算法,都会留一个可打印的调试开关,把每个节点dist的更新过程记录到日志里。跑完直接看日志,能省下大量排查时间。Dijkstra看起来简单,真正在业务里落地时,边界条件、数据可靠性、算法选型这些细节,往往比背模板更决定成败。