news 2026/10/13 5:13:37

边反转的最小路径总成本:两次Dijkstra枚举建模解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
边反转的最小路径总成本:两次Dijkstra枚举建模解析

LeetCode 3650 这道中等题,光看标题就很有意思:“边反转的最小路径总成本”。我第一反应是,这不就是个最短路问题吗,跑一遍 Dijkstra 不就行了?真动手才发现,难点根本不在于那套模板,而是怎么把“反转一条边”这个操作拆进最短路的建模里,拆对了,代码半小时内能写完;拆不对,样例过完,一提交就漏一堆情况。

这道题大体上是这么回事:一张有向带权图,从起点到终点找一条路径,你可以选择把某条边的方向反转一次,反转要花额外的成本,最后要的是整条路径的“经过边权之和 + 反转成本”的最小值。如果你已经熟练掌握了单源最短路,又想让自己的图论水平往“更灵活的路”上走一步,这道题是个很好的练手素材。我尽量从建模、代码、踩坑三个维度把它讲透,尤其是那个“两次 Dijkstra 预处理 + 枚举关键边”的套路,类似的题换件马甲还能再见到它。

1. 题意建模与手工推演

1.1 把“反转一条边”翻译成路径形状

做题第一步不是看代码,而是先把题面的规则固定下来,不然写着写着就会混。我这里整理一下这道题需要接受的设定:

给定 n 个节点,编号从 0 到 n-1,以及 m 条有向边,每条边用一个三元组(u, v, w)表示:从 u 到 v 有一条单向边,通过它的成本是 w。另外给一个统一的整数reverseCost,表示反转一条边方向的花费。允许的操作是:把某条边u -> v变成v -> u,方向反了之后,这条边的通过成本仍然按 w 算,但反转操作要多付reverseCost。路径总成本等于经过边的 w 之和,再加上,如果这一路上用了反转操作,把对应的 reverseCost 也累加进去。问从 0 到 n-1 的最小总成本是多少,无法到达时返回 -1。

很多第一次看这类题的人会想当然:那我建图的时候,给每条边顺便加一条反向边,权重是w + reverseCost,然后跑最短路不就行了?先别急着这么干,这是最常见的建模误区。因为如果直接加反向边,相当于所有边都天然允许双向走了,题目里“只能反转一条边”的约束就完全丢了。更麻烦的是,一个路径可能在原方向走一次、反方向再走一次,这跟你只反转一条边的规则对不上。

换个角度看,路径经过一条被反转的边时,形状其实是固定的。假设原边是u -> v,你把它反转成v -> u之后,一条合法的完整路径就会长成:

起点 -> ... -> v ->(走反转后的边)-> u -> ... -> 终点

也就是说,这条反转边像一座桥,让你从正向图里能够到达的 v,跳到一个反向图里能够通往终点的 u。整个路径被这条边切成了三段:前面一段在正向图里从起点摸到 v,中间一段是反转边,后面一段在正向图里从 u 到终点。后面所有推导都以这个路径形状为基础。

1.2 一个简单样例,体会“反转有意义”的条件

光说设定有点干,我拿一个自己手搓的样例来跑一遍。假设 n=4,边是下面三条:

  • 0 -> 1,权重 1
  • 1 -> 2,权重 1
  • 3 -> 2,权重 1
  • 3 -> 0,权重 1

再给reverseCost = 1,起点 0,终点 3。

先看不动任何边的情况:从 0 出发只能到 1,然后从 1 到 2,到了 2 之后,2 没有任何出边,终点 3 完全摸不到。所以不反转是无解的。

现在尝试反转一条边。观察边3 -> 2,原始方向是 3 指向 2,反转后变成2 -> 3,正好把我们缺的最后一步补上:0 -> 1 花 1,1 -> 2 花 1,走反转边 2 -> 3 花1 + 1 = 2。总成本是1 + 1 + 2 = 4。

这个样例能让你直观感受到反转的价值:它本质上是在现有的有向骨架里,允许你挑一条边“倒着走一次”,从而打通原本不通的路线。注意走反转边时,成本是w + reverseCost,不是单独付一个反转费,很多初学者这里会算错,把 w 忘了,或者把 reverseCost 乘了边数。

1.3 为什么不能朴素地“加一条反向边”

再深入解释一下 1.1 里说的误区。如果简单地给每条边都加一条反向的候选边,然后把权重设成w + reverseCost,接着在这样一张“双向化”的图上跑最短路,得到的结果会偏小。

原因在于:这条反向候选边在 Dijkstra 的眼里,和原边没有任何约束关系。路径可以先走u -> v,再走v -> u,把一个本来零成本的往返当作正常子路径,这在真实的“只反转一次”规则下是不允许的。因为一旦你反转了一条边,它的原方向就没了,当前图的结构是确定的,而不是两个方向同时存在。更极端的情况是,路径里可能先后用到三条不同边的反向边,这在朴素模型里也都会被允许,从而算出低于真实答案的结果。

所以,单独把“反转”当操作,而不是当静态路由,才是这题的题眼。

2. 两次 Dijkstra 的建模核心

2.1 预处理正向最短路和反向最短路

既然路径形状被切成了三段,那么自然的想法就是分别预处理三段中“最便宜”的走法。

第一段是“从起点 0 到某个节点 v,在原图上走”,这恰好是单源最短路问题。在原图上从 0 出发跑 Dijkstra,得到数组distS,其中distS[x]表示原图中从 0 到 x 的最小成本。

第二段反转边我们枚举处理,不放进最短路里。

第三段是“从某个节点 u 到终点 n-1,在原图上走”。这看起来还要跑一次多源或多终点的最短路,但关键在于:在一个有向图上,“所有点到终点的最短路”可以等价成“在反向图上从终点出发的单源最短路”。构造一个反向图:原图每条边u -> v变成v -> u,权重不变。然后在反向图上从 n-1 出发跑 Dijkstra,得到distT[u],它就是原图中从 u 到 n-1 的最短成本。

这里为什么用反向图,我多说一句。原图里你想知道 x 到终点 t 的距离,如果直接对每个 x 各跑一次,复杂度没法看。反向图把边的方向全换了,于是从 t 出发能经过反向边访问的节点,恰好正是原图里能到达 t 的那些节点。距离值也原封不动地保留,因为在反向图上走一条反向边v -> u的代价,就是原图中从 u 到 v 的代价。

2.2 枚举反转边的拼接公式

有了distS和distT,现在对每条原边u -> v(权重 w),假设反转它。反转之后边的方向变成v -> u,通过成本是w + reverseCost,那么一个“完整用到这次反转”的合法路径就是:

0 -> ... -> v花distS[v]

v -> u走反转边,花w + reverseCost

u -> ... -> n-1花distT[u]

三段加起来,总成本:

distS[v] + w + reverseCost + distT[u]

对所有边都算一遍这个值,再和不反转的答案distS[n-1]取最小值,就是最终答案。

注意下标的对应关系:原边是u -> v,反转后从 v 出发到 u,所以distS 看的是 v,distT 看的是 u。这句话我重复三遍都不嫌多,这种下标写反导致的 bug 在编码时极其隐蔽,后面我会专门讲怎么用样例自检。

2.3 为什么这样枚举不会漏解

这一步是很多人写代码时不踏实的地方:我没法证明枚举所有边之后答案就是全局最小,就先写了。其实证明不复杂,用反证法想一想。

假设最优路径 P 确实用到了某条被反转的边 e,原方向为u -> v,反转后从 v 走到 u。路径就能表示成三段:从 0 到 v 的某条原图路径 A,边 e 的反转走法,从 u 到 n-1 的某条原图路径 B。

先看第一段 A。在 A 当中,所有边都是原图中的正常边,所以 A 是一条从 0 到 v 的合法路径,它的成本不可能小于distS[v],因为distS[v]就是所有这样的路径里最小的一个。如果 A 的成本严格大于distS[v],那我可以把 A 换成最短路那条,整条路径只会更短,与“P 是最优路径”矛盾。所以 A 的成本必须恰好等于distS[v],也就是说,最优路径的第一段完全可以用我们预处理的 distS 替代。同理,第三段也必须恰好等于distT[u]。

于是,任何最优解在用到某条反转边 e 时,它的成本必然等于枚举公式里 e 对应的那个总和。枚举所有边时一定会扫到它。用到的反转边只有一条,而没有反转的最优解就被distS[n-1]兜住了。两种可能性覆盖完整,解不丢。

2.4 复杂度分析与适用前提

两次 Dijkstra,每次O(m log n),枚举 m 条边是O(m),整体是O(m log n),空间O(n + m)。

这个复杂度对大图也很友好,顶点和边到十万级都能轻松扛住。适用前提是边权非负。Dijkstra 的贪心正确性建立在非负权边上,如果题目里出现负权重或者负的反转成本,那这套解法就不能用了,得换 Bellman-Ford 或 SPFA,并另行考虑负环,但常规题一般不会这么出。

3. 完整参考实现与细节说明

3.1 Python 参考实现

我先把一份可以直接跑的 Python 实现放出来,再逐段讲关键地方。

import heapq from typing import List class Solution: def minimumTotalCost( self, n: int, edges: List[List[int]], reverseCost: int ) -> int: # 正向图和反向图 graph = [[] for _ in range(n)] rgraph = [[] for _ in range(n)] for u, v, w in edges: graph[u].append((v, w)) rgraph[v].append((u, w)) INF = 10 ** 18 def dijkstra(g, src): dist = [INF] * n dist[src] = 0 pq = [(0, src)] while pq: d, x = heapq.heappop(pq) if d != dist[x]: continue for y, w in g[x]: nd = d + w if nd < dist[y]: dist[y] = nd heapq.heappush(pq, (nd, y)) return dist # 原图从 0 出发的最短路 distS = dijkstra(graph, 0) # 反向图从 n - 1 出发的最短路 distT = dijkstra(rgraph, n - 1) # 不反转任何边 ans = distS[n - 1] # 枚举每条边反转 for u, v, w in edges: if distS[v] == INF or distT[u] == INF: continue cur = distS[v] + w + reverseCost + distT[u] if cur < ans: ans = cur return -1 if ans >= INF else ans

这份代码有几个地方我希望你重视。

第一,dijkstra函数里那个if d != dist[x]: continue。堆里可能留着旧的、大的距离记录,当它被弹出时,dist[x] 已经被更新成了更小的值,所以d != dist[x]时直接跳过,避免重复访问,这也是堆优化 Dijkstra 的标准写法。

第二,回答里 n=1 的边界。如果起点和终点是同一个节点,distS[n-1] = distS[0] = 0,答案就是 0,这个逻辑自动成立。题目如果允许路径长度为零,那没问题;如果不允许,题目一般会给出相应约束,代码层面不用额外判断。

第三,INF选10 ** 18。边权和反转成本叠起来完全可能超过 int32 的范围,Python 大整数无所谓,但如果你转写 C++,一定要用long long并且把 INF 设成4e18级别,避免加法溢出后把 INF 误判成可达值。

3.2 C++ 核心片段参考

C++ 写法的思路完全一致,这里给一个核心的 Dijkstra 函数和最终枚举部分,方便需要性能的读者参考。

const long long INF = 4e18; vector<long long> dijkstra(int n, vector<vector<pair<int, long long>>>& g, int src) { vector<long long> dist(n, INF); priority_queue<pair<long long, int>, vector<pair<long long, int>>, greater<>> pq; dist[src] = 0; pq.push({0, src}); while (!pq.empty()) { auto [d, x] = pq.top(); pq.pop(); if (d != dist[x]) continue; for (auto [y, w] : g[x]) { if (dist[y] > d + w) { dist[y] = d + w; pq.push({dist[y], y}); } } } return dist; } long long ans = distS[n - 1]; for (auto& e : edges) { int u = e[0], v = e[1]; long long w = e[2]; if (distS[v] == INF || distT[u] == INF) continue; ans = min(ans, distS[v] + w + reverseCost + distT[u]); } return ans >= INF ? -1 : (int)ans;

C++ 里最容易翻车的点是INF的选择和加法溢出。有人用INT_MAX当 INF,一旦某个distS[v]是 INF,那么distS[v] + w + reverseCost + distT[u]会直接溢出成负数,然后被min选中,答案变成一个莫名其妙的东西。所以要么每个分支判 INF 再相加,要么把 INF 设置得足够大且用 long long。我习惯两个都做,保险。

3.3 与“分层图”方案的取舍

可能有人想,把“是否已经反转一条边”做成一层状态,建一个两层图:第一层是还没用反转的原始状态,第二层是已经用掉反转的状态,层间连一条“反转边”,然后在分层图上跑最短路。复杂度也是O(m log n)。

但在这道题里,我强烈建议用枚举边的方案,而不是分层图。理由有两个:一是分层图建图逻辑虽然直观,但内存里节点和边都要翻倍,在 m 很大的时候常数开销明显;二是这道题只需要反转一次,枚举全部的“候选反转边”完全足够,分层图解决的是“反转次数上限更大”的通用问题。杀鸡用牛刀,代码还更长,调试成本更高。通用思路我在最后一段展开讲,但那不代表每个题目都要套最重的方案。

4. 边界情况与实测避坑

4.1 不可达与 INF 的坑

如果起点和终点在图里根本不连通,distS[n-1]是 INF,枚举反转边时也可能一条都不满足,最终ans保持 INF,返回 -1。

这里最容易踩的坑是误判。我见过有人用ans == INF判断不可达,但正常 Dijkstra 里 INF 不会变,所以相等没问题。问题出在他们把 INF 设得太小,比如10**9,结果真实答案超过了它,或者加法溢出后把 INF 变成其他值,这时判断就失效了。解决办法很简单:INF 设成当前规模下的一个极大值,比如10**18,判断时用>= INF // 2这种带余量的写法,避免边界精度问题。

4.2 自环、重边与平行边

如果图里出现自环u == v,反转它没有实际意义,方向没变,但公式会给出一个值:distS[u] + w + reverseCost + distT[u]。这相当于在路径里强行插入一个自环再走出来。因为 w 和 reverseCost 都是非负的,这个值永远比distS[u] + distT[u]大,而后者不一定是合法路径,所以它通常不会干扰答案。但万一 reverseCost 是负数,自环就会变成一个可以反复刷的低成本环,导致最短路不存在,这就不是本题的讨论范围了,题目一般声明所有权重非负。

重边更不用担心,每条边是独立的候选反转对象,枚举时多条同方向的边都会各自算一遍,取最小即可。哪怕两条 u 到 v 的边权重不同,反转成本也一样,最终答案自然会挑权重最小的那种走法。

4.3 反转成本为零甚至很小的情况

reverseCost = 0时公式退化成distS[v] + w + distT[u],等价于允许免费把一条有向边当双向边用一次。这在逻辑上没问题,题目允许免费反转时,答案通常会更小。极端情况下,如果原图已经有一条很短的路,反转一条边反而绕路,那min逻辑会选原路径,不会出错。

唯一需要警惕的是“很小但不为零”的反转成本,比如 reverseCost = 1。此时最优路径里极有可能用上反转边,所以不能为了省事先跑一个“忽略反转”的答案就输出。这个我建议用一组直觉数据自测:把起点周围做一圈“必须绕远路才能到终点”的边,让反转成为唯一方案,验证枚举逻辑拿到的值是不是正确。

4.4 最容易写反的下标:distS[v] 还是 distS[u]

我强调过,原边是u -> v,反转后从 v 到 u。所以路径拼接是distS[v] + w + reverseCost + distT[u]。现实中写反的人非常多,包括我自己第一次写也中招了。

为什么容易反?因为人脑直觉认为“边的起点是 u,那答案里应该有 distS[u]”。但反转后的起点恰恰是 v,因为方向变了。我每次写完这段代码都会顺手拿一个简单样例验证:设一条边0 -> 2权重 5,终点是 2,reverseCost=0。如果下标写反,算出来会变成distS[2] + 5 + 0 + distT[0],而distT[0]是 0 到 2 的最短路 5,整体等于distS[2] + 10,明显偏大;正确写法是distS[0] + 5 + 0 + distT[2] = 0 + 5 + 0 + 0 = 5。这种样例不用等提交,本地一跑就能纠错。

4.5 强制反转与允许不反转的差别

题面如果写“必须反转一条边”,那答案初始化就不能用distS[n-1],而应该把所有distS[v] + w + reverseCost + distT[u]的可行值取最小值,如果全是 INF 就返回 -1。本题标题里说的是“边反转的最小路径总成本”,没有强调“必须”,所以按“最多反转一次”处理比较稳妥,也就是取不含反转和含反转的最小值。刷题时一定要先确认清楚题目要求,否则差一行初始化,整个结果都会错。

5. 从这道题看一类图论变体的通用套路

5.1 允许任意次数反转时的退化模型

如果题目改成“可以任意次反转边”,问题反而变简单了:每条边都天然有两条通行方向,原方向成本 w,反方向成本w + reverseCost。因为任意次反转不需要考虑“原方向是否被占用”,每条边只是提供两个可能方向罢了。直接建图:u -> v权重 w,v -> u权重w + reverseCost,然后跑一遍最短路。之前 1.3 里担心的“一条边正反都走”在这里是完全合法的,因为你反转两次又回到原方向,但成本多付了后续反转的费用,最短路不会自找麻烦,所以模型成立。

这给我们一个启示:限制数量的一次性操作,和放开的资源,完全是两种建模难度。放开时直接摊到边上,限制时就得把操作当“状态”或“枚举对象”处理。

5.2 分层图:解决“至多 k 次操作”的通用武器

如果题目扩展成“最多反转 k 条边”,枚举每条边就做不到了,因为组合数爆炸。这时分层图是更通用的解法:建立 k+1 层图,每层保留原图的全部边,层内正常走;层与层之间连“状态转换边”,表示在某层使用一次操作。具体到本题,第 0 层是没用过反转的状态,第 1 层是用过反转的状态。原图边u -> v在第 0 层内建,权重 w;反转边v -> u作为从第 0 层 u(或 v)到第 1 层 v(或 u)的跨层边,权重w + reverseCost。从第 0 层起点出发,终点取第 0 层和第 1 层的终点的较小值。

这个方案的通用性确实很强,但代价是节点数乘 k+1,边数也相应翻倍,运行常数更大。对于 k=1 而且只是枚举所有边就能算尽的情况,枚举法更好写、更好调,面试时也更好讲清楚。

5.3 distS + distT 拼接还有哪些用武之地

最后说一个更高位的套路:当你发现一条路径可以被某个“关键边”切成前后两半,而且关键边本身需要特殊枚举时,最短路预处理正反两遍,然后枚举关键边拼接,几乎是无脑可靠的解法。这个模式在很多题里出现过,比如判断一条边是否属于某对点之间的最短路:如果distS[u] + w + distT[v] == distS[t],说明这条边在最短路上。再比如找两条最短路相交节点、计算经过某条边的所有最短路计数等等,都是这一个套路的变化。

所以每次做完这种题我都会做一件额外的事:把自己做过的“最短路拼接”题汇总在一起,画一画它们共用的骨架。你会发现刷题刷到后面,图论的难点不是算法模板,而是你能不能把一个操作约束翻译成可枚举的路径形状。能翻译出来,代码只是骨架上的填充。

我个人写这道题时的体会是:别急着敲键盘,先在草稿纸上画几个节点,把可能用到反转边的最优路径手动拆分一下,写出distS[v] + w + reverseCost + distT[u]这个公式,再动手写。整个过程里最耗时间的往往不是 Dijkstra,而是下标写反之后来回怀疑人生。你只要记住一条“反转之后起点是 v、终点是 u”,这道题的核心就抓住了一大半。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 5:13:21

VirtualLab Fusion通用探测器使用指南:关键参数与排查经验

做光学仿真的朋友应该都清楚&#xff0c;在 VirtualLab Fusion 里搭光路时&#xff0c;探测器是绕不开的组件。光路建模再细致&#xff0c;没有探测器去读取场数据&#xff0c;整个仿真就是空转。我用了几年下来&#xff0c;最综合、最常用的就是通用探测器&#xff08;Univers…

作者头像 李华
网站建设 2026/10/13 5:12:41

基于Spring Boot的二手滑板交易系统毕设:数据库设计与状态机解析

去年带一个学弟做毕设&#xff0c;他的选题就是基于Spring Boot的二手滑板交易系统。代码跑起来的那天他特别兴奋&#xff0c;结果一周后模拟答辩&#xff0c;导师一句话就把他问住了&#xff1a;“你这个订单状态是怎么流转的&#xff1f;用户下单之后&#xff0c;商品表里那条…

作者头像 李华
网站建设 2026/10/13 5:11:54

Spring Boot 整合 MyBatis 实战:从依赖选型到多数据源与事务管理

1. 为什么还要聊 Spring Boot 整合 MyBatis1.1 一个老生常谈但绕不开的组合但凡做过几年 Java 后端的人&#xff0c;简历上大概率都写过“熟悉 Spring Boot、MyBatis”。但真到了新项目搭架子的时候&#xff0c;很多人还是会卡在几个具体问题上&#xff1a;依赖到底引哪个 star…

作者头像 李华
网站建设 2026/10/13 5:11:29

SpringBoot汽车美容保养预约系统实战解析

很多准备做Java毕业设计的同学&#xff0c;估计都见过这类标题&#xff1a;基于SpringBoot的汽车美容与保养网站系统、汽车养护web系统、4S店保养服务管理系统。说实话&#xff0c;这几个名字听起来像三个项目&#xff0c;但本质上说的都是同一类东西——围绕汽车后市场服务场景…

作者头像 李华
网站建设 2026/10/13 5:11:23

Wireshark抓HTTP包:从字节流到协议分析的实战指南

1. 这不是“看个包”那么简单&#xff1a;HTTP抓包背后的真实工作流Wireshark抓HTTP包&#xff0c;很多人第一反应是“点开软件&#xff0c;点一下开始&#xff0c;等网页加载完&#xff0c;再点停止”——这确实能抓到数据&#xff0c;但离真正理解网络行为差了至少三层楼。我…

作者头像 李华
网站建设 2026/10/13 5:10:58

C#+SQL Server外卖系统课设:数据库设计到三种角色全流程解析

简介&#xff1a;这份餐饮外卖销售系统数据库设计压缩包&#xff0c;基于C#与SQL Server 2019开发&#xff0c;覆盖商家、客户、骑手三类角色界面及注册登录模块&#xff0c;面向高校数据库课程设计参考&#xff0c;适合需要完成同类业务系统设计的中高级学生。压缩包共46个文件…

作者头像 李华