前阵子帮一个团队看园区导览的小项目,刚开始图上只有几十个点位,最短路这块完全没人当一回事。等数据源里的监控点位一接进来,节点凭空多出六七倍,原来那套直接全源预计算的方案开始卡顿,这才有人跑来问我:Dijkstra 和 Floyd 到底该怎么选。说实话,这两个名字在技术群里被问了无数次,但真要在工程里给出一个让自己满意的答案,远不是背个模板那么简单。这篇就以最短路算法里最容易碰到的这两块内容为主线,把原理、实现、选型、坑点串一遍。适合正在学图论的初学者,也适合准备算法面试或者正在写路径规划 Demo 的开发者。
1. 先搞清楚两个算法各自适合什么场景
1.1 给最短路问题划定一个明确边界
在聊算法之前,先把问题本身说清楚。给定一张有向图,图里有节点和带权边,权重表示从一个节点走到另一个节点的代价,最短路问题就是找一条从起点到终点、边权之和最小的路径。工程里最常见的两种变形是单源最短路,也就是给定一个起点,求它到所有其他点的最短距离;另一种是全源最短路,要求图中任意两点之间的最短距离和对应路径。
这里有一个关键前提必须分清楚:讨论 Dijkstra 时,默认所有边权都是非负数,因为这是 Dijkstra 贪心策略成立的核心条件。Floyd 则稍微宽松一些,能处理没有负环的负权边。所谓负环,就是图中存在一个环,绕一圈的总权重还是负数,这种情况下最短路径没有下界,任何算法都无法给出有限的答案。
先约定记号:n 表示节点数,m 表示边数。Dijkstra 的堆优化复杂度通常写成 O((n+m)log n),Floyd 的复杂度则是稳定的 O(n^3)。也就是说,当 n 大到几百几千时,Floyd 和 Dijkstra 的差距会呈现数量级的差别,后面会专门用实例说明。
1.2 Dijkstra 为什么快:贪心锁点的逻辑
Dijkstra 的核心思路可以概括成一句话:每次从未确定的点里挑一个当前距离最小的点,把它“锁死”,然后用它去更新相邻点。
为什么敢“锁死”?我们用一个生活化的比喻来解释。假设你站在起点,每个点的当前距离就是“已知的到该点最短试探距离”。既然所有边权非负,那么从起点出发,任何一条绕路到达当前最小距离点 u 的路径,都必须先经过某个距离不小于 u 的点,绕路本身只会让路径更长。因此,当前距离最小的点 u,不可能再通过其他点绕出一条更短的路径,它此刻的距离就是真正的最终答案。
这就是贪心算法的典型特征:局部最优的选择能导出全局最优。Dijkstra 每次选择当前距离最小的未访问点并永久标记它,这个过程不断重复,直到所有可达点都被锁定。朴素实现的寻找最小点需要 O(n) 扫描,整个算法为 O(n^2);使用优先队列后,寻找最小点的时间降为 O(log n),总复杂度变为 O((n+m)log n)。
后面的完整实现部分会给出代码,但先记住这个“锁点”直觉,调试时遇到问题会更容易定位。
1.3 Floyd 为什么简单:三维 DP 的降维思维
Floyd 的代码量很少,通常十几行就能写完,但网上关于“k 为什么必须写在外层”的问题层出不穷。理解了它的原理,这些问题就不存在了。
Floyd 本质上是一个动态规划过程。定义 f[k][i][j] 表示“只允许使用编号小于等于 k 的节点作为中间节点时,i 到 j 的最短距离”。动态规划转移方程是:
f[k][i][j] = min( f[k-1][i][j], f[k-1][i][k] + f[k-1][k][j] )
含义很直接:从 i 到 j,要么不经过新加入的节点 k,路径还走原来的老路;要么经过 k,那就从 i 先到 k,再从 k 到 j。因为 k 的加入是逐步扩大的,这个转移天然保证了完整性。
实际写代码时,k 这一维可以滚动掉,二维矩阵直接原地更新,于是变成:
d[i][j] = min(d[i][j], d[i][k] + d[k][j])
这也解释了为什么 k 必须放在最外层。如果 k 放在内层,就相当于在某一个 i、j 的循环体内部同时使用不同“层级”的 k 值,状态的先后顺序被打乱,结果必然错误。
Floyd 能处理负权边,是因为每一轮 k 的迭代都是一次全局修正,负权边带来的影响会在后续轮次被矫正。但一旦存在负环,每绕一圈距离更小,最短路就没有了有限答案。
1.4 一张表做出工程选型
实际项目里,很少有人会把所有最短路算法都实现一遍,通常是先根据场景筛选。我把常见场景整理成下面这张表:
| 场景 | 边权条件 | 推荐算法 | 复杂度 |
|---|---|---|---|
| 单源最短路 | 非负权 | Dijkstra + 堆优化 | O((n+m)log n) |
| 单源最短路 | 存在负权但无负环 | Bellman-Ford 或 SPFA | O(nm),SPFA 平均更好 |
| 全源最短路 | n 较小(几百以内) | Floyd | O(n^3) |
| 全源最短路 | n 较大、图稀疏 | 跑 n 次 Dijkstra | O(n(n+m)log n) |
| 全源最短路 | 存在负权 | Johnson 算法 | O(nm + n(n+m)log n) |
看到这张表,很多读者会问:全源最短路为什么不用 Floyd 一把梭?答案是当 n 超过 500 或 1000 时,n^3 的增长非常夸张。假设 n=1000,Floyd 要做十亿次级别的松弛操作,而如果图稀疏、m 只有几千,跑 n 次堆优化的 Dijkstra 只要几百万次操作,差距是两个数量级以上。反过来,如果 n 很小比如几十,Floyd 写起来最省事,还天然记录了所有点对距离,工程上非常舒服。
2. 手写 Dijkstra:从朴素到堆优化,每一步都不白走
2.1 数据结构先定好:邻接表怎么选
Dijkstra 的实现细节里,图的存储方式是第一步。邻接矩阵的优点是随机访问任意两点的边权是 O(1),但空间是 O(n^2)。n 一旦到 10 万,邻接矩阵直接爆内存,就算 n 只有几千,在很多内存受限的场景也不划算。邻接表只存实际存在的边,空间 O(n+m),对堆优化版本来说更重要:我们每次从堆顶弹出节点 u,需要快速遍历 u 的所有出边,邻接表天然支持这种操作。
工程上我习惯用数组 + 结构体的方式,或者直接用 vector 存边,python 场景则用 list of dict。C++ 里常用这样一段:
struct Edge { int to; int w; }; vector<Edge> graph[MAXN];存无向图的时候每条边要加入两次,有向图只需要一次。输入数据如果可能给出重边,要保留最小的那条,因为多条同向边叠加后,只有权重最小的对最短路有贡献。
2.2 朴素 Dijkstra 完整实现
先给出朴素的 O(n^2) 版本。它代码简单、不容易写错,在稠密图里甚至常常比堆优化更快,因为稠密图里堆的 log n 常数也不容忽视。
const int MAXN = 1005; const int INF = 0x3f3f3f3f; int dist[MAXN]; bool used[MAXN]; vector<Edge> graph[MAXN]; void dijkstra_naive(int s, int n) { fill(dist, dist + n + 1, INF); memset(used, 0, sizeof(used)); dist[s] = 0; for (int i = 1; i <= n; i++) { int u = -1; int best = INF; // 找当前距离最小的未访问点 for (int v = 1; v <= n; v++) { if (!used[v] && dist[v] < best) { best = dist[v]; u = v; } } if (u == -1) break; // 剩下的点都不可达 used[u] = true; // 用 u 松弛邻居 for (auto &e : graph[u]) { if (dist[e.to] > dist[u] + e.w) { dist[e.to] = dist[u] + e.w; } } } }这里的 used 数组就是前文说的“锁点”。每次找最小点时扫描全部节点,所以复杂度是 O(n^2)。注意初始化时 i 从 1 到 n,节点编号按 1 开始;如果节点从 0 开始,相应地改成 0 到 n-1。
提示:如果图是稠密图,n 只有几百几千且 m 接近 n^2,朴素版往往比堆优化版还快。因为堆优化的常数开销在这种规模下可能超过 O(n^2) 的扫描成本。很多算法模板只教堆优化,容易让人误以为朴素版没意义,其实它非常适合稠密小图。
2.3 优先队列优化版本
当 n 在 10 万级别、图又稀疏的时候,朴素版每一次扫描都是灾难,必须用堆来维护当前距离最小的点。C++ 的 priority_queue 默认是大顶堆,所以要用 pair 或者自定义比较器。常见写法是把距离的相反数放进去,或者用 greater 指定小顶堆。下面是完整模板:
const int MAXN = 100005; const int INF = 0x3f3f3f3f; vector<Edge> graph[MAXN]; int dist[MAXN]; bool vis[MAXN]; void dijkstra_heap(int s, int n) { fill(dist, dist + n + 1, INF); memset(vis, 0, sizeof(vis)); dist[s] = 0; priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq; pq.push({0, s}); while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); // 跳过已经确定最短路的点 if (vis[u]) continue; vis[u] = true; // 如果不用 vis,也可以用 if (d != dist[u]) continue; for (auto &e : graph[u]) { if (dist[e.to] > dist[u] + e.w) { dist[e.to] = dist[u] + e.w; pq.push({dist[e.to], e.to}); } } } }先解释 pair 的排序逻辑。priority_queue 默认按 pair 的第一个元素排序,first 是距离,所以堆顶总是距离最小的那个。greater 只是把默认的大顶堆翻转成小顶堆。节点一旦被更新,我们就把新的距离和节点编号再次 push 进堆,因此堆里可能存在同一个节点的多个旧条目。每次弹出时,通过 vis 判断这个节点是否已经确定了最终最短路,是则跳过。这个“跳过旧条目”是关键细节,下面专门展开。
为什么 vis[u] continue 不会导致漏更新?因为当 u 第一次以当前最短距离从堆顶弹出时,贪心性质保证它已经不可能再被其他点更新,后续所有相同节点的条目都是冗余的。所以先弹出先锁定,后面重复弹出直接忽略。如果写成 if (d != dist[u]) continue,效果一样,因为 d 不是最新距离说明这条记录已经过期。
从复杂度角度看,每条边最多触发一次成功的松弛,每次成功松弛都会向堆里 push 一次,堆至多 O(m) 条记录,每次 pop 是 O(log m),总复杂度 O((n+m)log n)。
2.4 顺手把最短路径也打出来
实际项目中不能只返回距离,很多场景需要路径本身。Dijkstra 记录路径的思路很简单:更新 dist[e.to] 时,把 prev[e.to] 设置为 u。最终从终点反向追溯 prev 就能得到路径。打印时用一个栈或者递归逆序输出。示例代码如下:
int prev_node[MAXN]; void dijkstra_with_path(int s, int n) { // dist, vis 初始化同上 memset(prev_node, 0, sizeof(prev_node)); while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); if (vis[u]) continue; vis[u] = true; for (auto &e : graph[u]) { if (dist[e.to] > dist[u] + e.w) { dist[e.to] = dist[u] + e.w; prev_node[e.to] = u; pq.push({dist[e.to], e.to}); } } } } void print_path(int s, int t) { if (dist[t] == INF) { cout << "不可达" << endl; return; } vector<int> path; for (int v = t; v != 0; v = prev_node[v]) { path.push_back(v); } reverse(path.begin(), path.end()); for (int i = 0; i < path.size(); i++) { if (i) cout << " -> "; cout << path[i]; } cout << endl; }注意 prev 数组初始化为 0,表示没有前驱。起点 s 的 prev 保持 0,所以回溯到 0 时停止。这个方法对路径记录要求不高的小图完全够用。
3. Floyd 十行代码背后的几个坑
3.1 k 必须在外层,很多人栽在这里
Floyd 的代码极简单,写错了也不容易一眼发现,因为它们不会直接报错,而是给出一个偏大的错误结果。最典型的问题就是三重循环的嵌套顺序。正确顺序是 k 在外层,i 和 j 在内层。前面讲原理时说过,k 每一轮代表“允许使用新加入的节点作为中间节点”,这个顺序是状态转移过程的核心。
如果写反了,比如把 k 放在最内层,那么对于一对 i、j,会在同一个 i、j 循环里连续尝试所有 k。这种做法完全没有状态累计:当你第一次尝试 k=1 时,还没更新任何结果;下一次尝试 k=2 时,也没有把 k=1 的结果纳入进去。最终 bug 的表现通常是:距离没更新够,路径明显绕远,或者在某些不可达的节点之间给出了不合理的值。
我见过最有效的检查方法是用三重循环顺序的三层含义来帮助记忆:最外层 k 代表“决策中间节点的编号上限”,内层 i、j 则是在这个限制背景下枚举所有起点和终点。直接背 i、j、k 的顺序容易错,背语义就不会踩坑。
3.2 顺手把路径也记录下来
Floyd 的路径重建比 Dijkstra 稍微绕一点。常见的办法是开一个 next[i][j] 数组,表示从 i 到 j 的最短路径上,i 的下一个节点是谁。初始化时,对于每一条有向边 i->j,next[i][j] = j;没有边的则默认保持 j 或 0。
在松弛成功时更新 next:
if (d[i][k] + d[k][j] < d[i][j]) { d[i][j] = d[i][k] + d[k][j]; next[i][j] = next[i][k]; }这里如果想当然写成 next[i][j] = k,就错了。因为 i 到 k 的最短路径可能已经经过了其他中间节点,i 走向这个新路径上的第一个节点不一定是 k,而是 next[i][k]。只有记录 next[i][k],才能完整还原路径。
打印路径时,只需要从 next[i][j] 出发不断跳转:
void print_floyd_path(int u, int v) { if (d[u][v] == INF) { printf("不可达\n"); return; } printf("%d", u); while (u != v) { u = next[u][v]; printf(" -> %d", u); } printf("\n"); }3.3 初始化、重边、自环,三个习惯一次养成
Floyd 的初始化往往决定成败。首先把整个矩阵 d[i][j] 初始化为 INF,再把 d[i][i] 初始化为 0。接下来读入边。输入如果出现重边,要取最小值;自环即 i 到 i 的边,在非负权场景下不会对最短路有正面贡献,可以忽略,但如果存在负权自环,那就相当于负环问题,整个最短路都不成立,通常需要单独排查。
INF 的选择也要小心。很多人直接用 int 的 INT_MAX,然后判断 d[i][k] + d[k][j] < d[i][j],一旦 d[i][k] 和 d[k][j] 都是 INF,两者相加直接溢出变成负数,导致错误更新。我在工程里习惯用 0x3f3f3f3f,这个值约等于十亿,两个相加也不会溢出 32 位 int,同时还能继续满足“比任何真实路径都大”的需求。更好的习惯是松弛时显式判断:
if (d[i][k] != INF && d[k][j] != INF) { if (d[i][k] + d[k][j] < d[i][j]) { d[i][j] = d[i][k] + d[k][j]; } }这样写虽然多两行,但能避免所有溢出的心智负担。数据范围较大、边权可能达到 10^9 时,把 dist 数组的类型改成 long long,判断逻辑不变。
4. 真实案例复盘:一个导览项目里的最短路选型
4.1 一个再常见不过的场景
前文提到的园区导览项目,最初的模型很简单:几十个展览点位作为图节点,点与点之间的步行路线作为边,游客想去任意两个点位之间,系统需要给出最短步行路线和距离。当时节点少,Floyd 一次预计算全部点对,之后再查询都是 O(1) 查表,完全没问题。
后来团队把监控点位、商店、洗手间等大量新节点都并入了同一张图,节点数从 30 涨到 700 多,边数接近 2000,图仍然很稀疏。Floyd 的预计算开始变成瓶颈,在纯 Python 的调试脚本里,700^3 大约三亿多次操作,肉眼可见地卡顿。更重要的是,后续还希望支持动态修改某些边权,每次修改如果都重跑全源 Floyd,体验会非常差。
这个时候真正需要权衡的问题就变成了:查询频率高不高?图会不会频繁更新?如果只是偶尔算一次全量数据,Floyd 其实也能接受;但如果是频繁的单点查询,并且图规模不小,Dijkstra 明显是更稳的选择。
4.2 性能对比与实测印象
为了说清楚这个问题,我在项目里用随机生成的 600 个节点、1800 条边的稀疏图做过一个简单对比。理论复杂度如下:
| 算法 | 复杂度(n=600, m=1800) | 预估操作规模 |
|---|---|---|
| 朴素 Dijkstra 单源 | O(n^2+m) | 约 36 万次 |
| 堆优化 Dijkstra 单源 | O((n+m)log n) | 约 1.6 万次 |
| Floyd 全源 | O(n^3) | 约 2.16 亿次 |
可以看到,单源 Dijkstra 和全源 Floyd 差了好几个数量级。实际跑起来,C++ 编译优化后的结果更明显:堆优化 Dijkstra 完成一次单源计算在毫秒级,Floyd 跑完全源需要零点几秒;而纯 Python 的三重循环要慢得多,交互式查询时卡顿感非常强。所以最终核心模块用 C++ 重写,每个游客查询时按需跑一次 Dijkstra,只计算需要的那个终点,路径返回也顺手。
这里有个工程心得:如果系统需要频繁查询“任意两点”且图节点少于 200,Floyd 的预计算模式非常舒服,查询 O(1),代码简单;一旦节点上了 500,就要认真考虑改成“每次查询跑 Dijkstra”或者“按热点区域做预计算”。选型不是背复杂度公式,而是理解自己系统中的查询比例和更新频率。
4.3 落地时的几个代码细节
实际落地时,除了算法本身,还要处理几个工程细节。第一,图的存储从邻接矩阵换成了邻接表,因为 700 个节点的邻接矩阵是 49 万格,虽然内存上不算大,但遍历出边时邻接表快得多。第二,输出路径时统一封装成接口,前端只拿节点序列和总距离,不关心内部是 Dijkstra 还是查表,方便日后替换。第三,因为图数据来自多个信息源,重边几乎必然存在,读入阶段统一取最小值,否则 Dijkstra 会被无关重边干扰。
这里还要提醒一点:最短路算法只是路径服务的一环,真实系统里往往还涉及禁行路段、步行耗时估计、逗留时长等业务规则。我习惯在算法层之上做一层“路网服务”,把最短路计算和业务规则解耦,这样算法升级或替换时,上层完全不用改。
5. 常见问题与排查技巧实录
5.1 队列里全是旧数据,你被坑过一次就懂了
堆优化 Dijkstra 最常见的问题就是忘记处理堆里的“过期数据”。现象是:某些节点的 dist 偏大或偏小,程序在随机数据上偶尔正确,一旦图复杂一点就全乱。原因很多新手分析不出来。事实上,每次 dist[e.to] 被更新时,我们都会往堆里 push 一个全新的 pair,同一个节点可能在堆里同时存在多个不同距离的记录。
如果不对过期记录做任何判断,程序就可能把一条已经过期的旧距离当成当前距离来松弛邻居。举例来说,某个节点先被一条较长的路径更新,入堆;后来又发现更短的路径,再入堆。堆顶弹出的应该是更短的那条,但如果代码没有跳过旧记录,它可能用旧距离继续更新邻居,算出来的结果就不是最优。修复方式就是前面代码中的 vis 标记或 if (d != dist[u]) continue。
从工程角度,写 Dijkstra 时最好一开始就把跳过过期记录的代码写进去。这行代码不是可选项,而是正确性的必要条件。
5.2 负数权边的反直觉案例
有一个经典反例可以瞬间击碎对 Dijkstra 的盲目信任。假设起点是 s,存在 s->a 边权 5,s->b 边权 6,b->a 边权 -2。直观上,s 到 a 的最短距离是走 s->b->a,路程 4;但 Dijkstra 一开始会选择从 s 出发距离更小的 a,直接锁定 5,然后才处理 b。最终它输出的 s 到 a 的距离是 5,而正确答案是 4。
原因在于 Dijkstra 锁点依赖的是“绕路不会更短”的性质,而负权边彻底破坏了这一点:绕路虽然经过更远的点,但负权可能把总代价拉回更低。所以只要输入里有负权边,Dijkstra 就必须彻底换掉,改用 Bellman-Ford 或 SPFA。Floyd 可以处理单个负权边,但对负环无能为力。遇到负权,先想想问题本身是否合法,再进行算法选型。
5.3 越界与溢出的隐蔽 Bug
最短路代码里最隐蔽的错误不是逻辑混乱,而是 INF 加法溢出。当 INF 取 INT_MAX,且两个不可达节点的距离相加时,结果溢出成负数,程序反而认为它是一条更短路径。这种事在 Floyd 的朴素写法和 Dijkstra 的比较条件里都可能出现。
我推荐的防御姿势有两个。第一个是 INF 用 0x3f3f3f3f,保证 INF + INF 不溢出;第二个是写比较时拆开判断,像前面 Floyd 初始化那节写的一样,先判断不等于 INF,再做相加比较。如果数据范围很大,干脆把 dist 数组声明为 long long,用 long long 的 INF,比如 4e18,彻底告别整数溢出问题。很多比赛选手习惯性地开 long long,不是没有道理的。
5.4 怎么快速验证算法写对了
写完之后怎么确认实现正确?单靠眼睛看代码通常不够。我最常用的方法是构造小图手算。找一个五六个节点的样例,把 dist 数组按轮次手推一遍,跟程序输出对比。这个过程不麻烦,但能快速暴露初始化、循环顺序、路径输出等细节问题。
第二个方法是对拍。边权全部设为 1 的图,Dijkstra 的结果应该和 BFS 的层数完全一致,用这段代码对比很快。更通用的对拍是用 Floyd 验证 Dijkstra:随机生成一张小图,跑一遍 Dijkstra 得到单源最短路,再跑一遍 Floyd 得到全源最短路,比较特定起点的所有 dist 是否一致。如果两者对不上,说明其中某个实现有问题。由于 Floyd 结构简单,通常默认它是基准。这个“暴力对拍”的思路在工程里也常用,把新实现和线上基准跑同一批数据,用脚本自动比对输出,能省下大量排查时间。
6. 用不上的时候也要知道:进阶方向
6.1 有负权边的全源场景
如果一张图既有负权边又需要全源最短路,直接跑 n 次 Dijkstra 是不行的,因为 Dijkstra 根本不能处理负权。这时候可以了解一下 Johnson 算法,它的思路非常经典:先跑一遍 Bellman-Ford 给每个节点算一个“势能”,然后用势能重新调整所有边的权重,把所有边权变成非负数,最后再跑 n 次 Dijkstra。
调整权重的方式是对于每条边 u->v,把实际权重变成 w + h[u] - h[v]。这个变换的好处是最短路径不会因为变换而改变,因为路径上的势能项会前后抵消。工程中很少需要自己实现 Johnson,但理解它有助于建立完整的算法知识体系。面试中如果被问到“负权全源怎么处理”,能说出 Johnson 的思路是很加分的。
6.2 比 Dijkstra 更快:带上启发式
在路径规划场景中,Dijkstra 在一个很大的图上找单个目标点时,会把图里大量无关区域都扩展一遍。这个时候可以用 A* 算法,它在 Dijkstra 的基础上增加一个启发式估计 h(n),表示从当前节点 n 到目标点的估算代价。只要 h(n) 不过度估计真实代价,A* 就保证找到最优路径,而且扩展的节点数通常远小于 Dijkstra。
Dijkstra 相当于 h(n)=0 的特例,所以从 Dijkstra 迁移到 A* 的代码成本非常低,主要改动只是把优先队列里的排序键从当前距离变成当前距离加启发值。地图导航、游戏寻路里最常见的做法就是 A* 配合栅格地图,这也是最短路算法的经典应用延伸。如果做的是实时路径规划,A* 往往比单纯堆优化的 Dijkstra 实用得多。
6.3 当图变成了动态图
真实系统中几乎没有永远静态的边权。路况变化、场地临时封闭、某个节点暂停开放,都会让之前的计算结果失效。最直接的做法是重新跑一遍算法,但如果图很大而且更新频繁,重复计算成本太高。
这个方向上的常见策略包括局部更新、增量维护、或者把图拆成区域预处理。最短路动态更新的核心困难在于:一条边变化可能影响很多节点,但通常影响范围是局部的。工程上的一个简单思路是先用旧结果判断受影响的节点范围,再在局部重跑 Dijkstra。很多商业导航系统背后都有类似的增量计算逻辑,但实现复杂度明显高于静态算法。日常开发中如果遇到动态图,不要急着写复杂的增量逻辑,先评估更新频率,很多时候“重新跑一次”反而是最简单也最稳定的方案。
我个人在实际项目里的体会是:最短路算法看起来是图论里的基础题,但真正用好的关键,是知道每个算法的前提条件、复杂度数量级和最容易被忽视的细节。Dijkstra 的贪心锁点、Floyd 的三层递推,都值得亲手写一遍、亲手掉一次坑。如果你正在学习这块,我建议从一张六节点的小图开始,手动推一遍 dist 数组,再用代码跑一遍,最后把打印出来的路径逐条读一次。这个过程比背十个模板都有用。