题源:洛谷 P15803 [GESP202603 七级] 物流网络
题目链接
1. 背景
在算法竞赛中,带特殊优惠条件的最短路问题一直是一类高频考点。这类题目往往在经典最短路模型上附加一条“减免规则”,比如“路径上最大边权免费”“最多跳过一条边”等。乍看之下,我们可以在状态中记录优惠信息,直接用分层图或扩维BFS解决;但当减免规则与边的某种“属性”(如景观评分、优先级)相关时,状态维度可能膨胀,导致时间或空间无法承受。
本题正是这样一个典型例子:每条边既有运输费用又有景观评分,优惠规则是“免除路径上景观评分最高的那条边的费用”。如果直接把“最高评分边”作为状态,你根本不知道当前路径上哪条边评分最高,除非记录整个路径的评分信息——这显然不现实。
本题在GESP七级中定位为“普及+/提高”难度,核心考察的是将复杂条件转化为标准最短路模型的能力,以及枚举优化的工程技巧。本文将通过这道题,带你从“暴力枚举每条边免费”出发,逐步优化到“倒序枚举 + 动态邻接表”的优雅解法,并顺带对比一个常见的84分BFS错误思路,帮你避开那些“看起来对但跑得慢/错得悄无声息”的坑。
2. 核心思想章节
2.1 问题转化:谁才是“最高评分”的那条边?
直觉上,一条路径的费用 = 路径上所有边费用之和 − 路径上最大评分边的费用。我们不妨换个视角:如果我知道路径上哪条边被免除了,那么问题就变成一个普通的最短路——只需把那条边的费用视为0,跑一遍Dijkstra即可。
关键来了:我们并不知道最优路径到底免的是哪条边。但我们可以“猜”——枚举每一条边,假设它就是最优路径上被免除的那条,然后求一次最短路,最后取所有结果的最小值。
因为最优路径上一定存在某条边作为最大评分边,所以枚举所有边一定不会漏解。这就是最朴素的“枚举免除边 + 跑最短路”框架。
2.2 朴素枚举的致命弱点
如果直接枚举m mm条边,每次对整张图跑一遍O ( ( n + m ) log n ) O((n+m)\log n)O((n+m)logn)的Dijkstra,总复杂度是O ( m ( n + m ) log n ) O(m (n+m)\log n)O(m(n+m)logn)。在n , m n,mn,m达到5 × 10 3 5\times 10^35×103级别时,2.5 × 10 7 2.5\times 10^72.5×107乘以对数,在C++中勉强可过,但若数据再大一些就会超时。
但本题n , m ≤ 5000 n,m \le 5000n,m≤5000,这种朴素做法其实也能过(5000 × ( 5000 + 5000 ) log 5000 ≈ 5 × 10 8 5000\times (5000+5000)\log 5000 \approx 5\times 10^85000×(5000+5000)log5000≈5×108,常数优化好勉强可行)。不过,题目给出的AC代码中采用了一个更巧妙的优化:按评分排序后倒序枚举,动态移除边。
2.3 倒序枚举:用“减法”代替“加法”
我们注意到:每次枚举时,我们只关心“当前被免除的那条边是否在图中是评分最高的”。如果我们先将所有边按评分升序排序,然后从评分最高的边开始往下枚举,那么在枚举第i ii条边时,所有评分比它高的边(即i + 1 ∼ m i+1 \sim mi+1∼m)已经被移出图了,剩下的边评分都不超过第i ii条。这样,第i ii条边自然而然就是当前图中评分最高的边,正好符合“免除最高评分边”的语义。
更重要的是,这种“倒序”策略让我们可以动态维护邻接表:每枚举一条边,跑完Dijkstra后,直接从两端点的邻接表中pop_back()删掉它,下一轮图中就不存在比它评分更高的边了。相比每次重新建图,这种方式节省了O ( m ) O(m)O(m)的重建开销,并且代码非常简洁。
小结:将“路径上最高评分边免单”转化为“枚举免单边”,再通过排序倒序实现图的动态缩减,是本题的核心降维思路。
3. 算法模板章节
3.1 算法到底在干什么?—— 直觉解释
想象你有一堆公路,每条公路旁边挂着一个“评分牌”(景观评分)。物流公司说:“你走的这条路线里,评分最高的那条路我免费。”
为了找到最便宜的路线,你可以这样尝试:
- 先把所有路按评分从低到高排成一列。
- 从评分最高的那条路开始,假设它就是免费路,然后在这张图上(暂时移除所有比它评分更高的路,因为那些路不可能成为免费路)跑一遍最短路,记下费用。
- 接着把这条路删掉,继续处理评分次高的路,重复上述过程。
- 最后在所有记下的费用中取最小值。
这个过程就像一层层剥洋葱,每次剥掉最外层的“最高评分”,考察当前内核中的最短路。
3.2 万能模板 —— 伪代码 + 实战代码
伪代码:
读入 n, m 及每条边的 (u, v, w, b) 按 (b, w) 升序排序边,编号 1..m 建立邻接表,每条边以 (目标点, 编号) 存入两端 ans = INF for i = m downto 1: // 此时图中包含边 1..i,边 i 的评分是当前图中最高 dist = dijkstra(免除边 i) ans = min(ans, dist[n]) // 从图中移除边 i 从 e[i].u 的邻接表中 pop_back 从 e[i].v 的邻接表中 pop_back 输出 ans 或 -1完整AC代码(C++,带注释):
#include<bits/stdc++.h>usingnamespacestd;#defineintlonglongtypedefpair<int,int>PII;// (距离, 城市编号)constintN=5005;structEdge{intu,v,w,b;// 端点、费用、评分}e[N];// 存储所有边,编号从1开始intn,m;vector<PII>adj[N];// 邻接表:每个元素为 (邻接点, 边编号)intdist[N];boolst[N];priority_queue<PII,vector<PII>,greater<PII>>heap;intans=1e18;// 排序规则:评分低的在前,评分相同则费用低的在前boolcmp(Edge x,Edge y){if(x.b==y.b)returnx.w<y.w;returnx.b<y.b;}// Dijkstra:x 为被免除费用的边编号voiddijkstra(intx){memset(st,0,sizeof(st));memset(dist,0x3f,sizeof(dist));dist[1]=0;heap.push({0,1});while(!heap.empty()){auto[d,u]=heap.top();heap.pop();if(st[u])continue;st[u]=true;if(u==n){ans=min(ans,dist[n]);break;// 终点确定,可提前结束}for(auto[v,id]:adj[u]){intw=(id==x?0:e[id].w);// 被免除边费用为0if(d+w<dist[v]){dist[v]=d+w;heap.push({dist[v],v});}}}}signedmain(){ios::sync_with_stdio(false);cin.tie(nullptr);cin>>n>>m;for(inti=1;i<=m;i++){cin>>e[i].u>>e[i].v>>e[i].w>>e[i].b;}sort(e+1,e+m+1,cmp);// 构建邻接表,注意存储的是边编号for(inti=1;i<=m;i++){adj[e[i].u].push_back({e[i].v,i});adj[e[i].v].push_back({e[i].u,i});}// 倒序枚举:从评分最高的边开始for(inti=m;i>=1;i--){dijkstra(i);// 移除边 i,为下一轮做准备adj[e[i].u].pop_back();adj[e[i].v].pop_back();}if(ans==1e18)cout<<-1<<'\n';elsecout<<ans<<'\n';return0;}3.3 例题实现 —— 本题完整运行流程
以样例为例:
3 3 1 2 10 5 2 3 20 6 1 3 100 1排序后边顺序为:
1: (1-3, w=100, b=1)
2: (1-2, w=10, b=5)
3: (2-3, w=20, b=6)
倒序枚举:
- i=3(评分最高,边2-3):图中包含所有边,Dijkstra免除边3,得路径1-2(10)+2-3(免费)=10,ans=10。
- 移除边3,图中只剩边1和边2。
- i=2(边1-2):免除边2,得路径1-2(免费)+2-3(20)=20,ans保持10。
- 移除边2,图中只剩边1。
- i=1(边1-3):免除边1,得路径1-3(免费)=0,ans更新为0。
最终输出0。
3.4 对比实现 —— 为什么那个BFS只得了84分?
题目附带了一个84分的BFS版本,核心思想是在状态中记录当前路径的最大评分边的费用,并据此计算实际支付费用。代码结构如下:
// 84分版本(错误/超时原因分析)structState{intv,w,b,bw;};// 当前点、累计费用、最大评分、最大评分边的费用queue<State>q;voidbfs(){memset(dist,0x3f,sizeof(dist));q.push({1,0,0,0});while(!q.empty()){auto[u,w,b,bw]=q.front();q.pop();if(w-bw>=dist[u])continue;dist[u]=w-bw;if(u==n)ans=min(ans,w-bw);for(autoedge:adj[u]){if(edge.b>b||(edge.b==b&&edge.w>bw)){q.push({edge.v,w+edge.w,edge.b,edge.w});}else{q.push({edge.v,w+edge.w,b,bw});}}}}为什么错误?
这个BFS实际上是按“累计费用”进行搜索的,但队列的先进先出无法保证按距离递增扩展,且dist[u]的定义是“到达u时的实际支付费用”,但转移时依赖于路径上的最大评分边,不同路径到达同一城市时,最大评分边可能不同,因此dist[u]并不是一个单调的最优值,直接用if(w-bw >= dist[u]) continue;剪枝是不安全的。这会导致漏掉某些可能更优但当前支付费用稍大的路径。
同时,它没有利用优先队列,扩展顺序混乱,在稠密图中还可能超时。所以它只得了84分,说明部分数据能过,但存在正确性或效率问题。
正确的做法一定是Dijkstra+枚举,因为我们将优惠条件“外挂”到枚举中,每次求解的是标准最短路,保证正确性。
3.5 变体清单
| 变体场景 | 处理方法 | 与本题的差异 |
|---|---|---|
| 免除路径上费用最大的边 | 同样枚举边,按费用排序倒序 | 评分改为费用 |
| 最多免除k条边(k小) | 分层图,状态多一维表示已免次数 | 本题只免1条,无需分层 |
| 免除路径上第k大评分的边 | 需要排序后二分+前缀判断,更复杂 | 本题只免最大 |
| 边权有负值 | 不能Dijkstra,需Bellman-Ford或SPFA | 本题费用为正 |
| 要求输出具体路径 | 记录前驱节点即可 | 本题只求费用 |
3.6 什么时候不能用?
- 图不连通:Dijkstra无法到达n,答案保持INF,输出-1,但算法仍可运行。
- 费用为负:Dijkstra失效,需换用Bellman-Ford,但枚举框架仍适用。
- 免除边不唯一:若有多个最大评分边,题目说“只免除其中一条”,我们的枚举完美覆盖,因为每条边都试了一次,且每次只免一条。
- m非常大(如10 5 10^5105):O ( m 2 log n ) O(m^2\log n)O(m2logn)难以承受,需考虑更优算法(如二分答案+最短路,或用数据结构优化枚举),本题范围较小所以此方法可行。
4. 底层逻辑章节
4.1 为什么“倒序枚举+动态删边”是正确的?
正确性证明:
设最优路径为P PP,其上的最大评分边为e ∗ e^*e∗,评分为b ∗ b^*b∗。在按评分升序排序后,所有评分高于b ∗ b^*b∗的边都不可能出现在P PP上(否则最大评分就不是e ∗ e^*e∗)。因此,当我们倒序枚举到边e ∗ e^*e∗时,图中已经删除了所有评分高于它的边,而e ∗ e^*e∗本身还在。此时P PP上的所有边都存在于图中,且e ∗ e^*e∗是当前图中评分最高的边(因为比它高的都已删除)。于是,当我们在这一轮跑Dijkstra时,将e ∗ e^*e∗费用视为0,一定能求出包含P PP的最短路(至少不会比P PP差)。因此,该轮的结果 ≤ 最优解。而每一轮得到的结果都是某条合法路径的费用(免费边就是当前最高评分边),所以最终答案 ≥ 最优解。结合两者,相等。故算法正确。
4.2 与经典问题“去掉一条边求最短路”的对比
经典问题“给定图,去掉一条边后求最短路”通常用“最短路树”或“必经边”概念,但本题去边的依据不是“必须去掉某条边”,而是“去掉评分最高的边”,具有动态性。经典问题往往枚举每条边分别跑最短路,和本题思路一致,但本题借助评分排序实现了“去边”的天然顺序,省去了重新建图。
4.3 隐含约束:边评分的传递性
题目未保证评分互异,若有相同评分,排序后顺序任意,但倒序枚举时,相同评分的边会依次被移除。假设最优路径包含两个相同最大评分边,那么只要枚举到其中任意一条作为免费边,另一条仍正常计费,路径合法。因为排序后这些边相邻,无论先枚举哪一条,图中都包含它们(除非已移除),但移除顺序是固定的,可能会造成某些组合的遗漏?
分析:若两条边评分相同,当枚举第一条(假设编号较大)时,第二条还在;当枚举第二条时,第一条已被移除。如果最优路径需要免除第一条而第二条也相同评分(但并未免除),那么免除第一条时路径是合法的,结果会被记录。所以不会漏解。
5. 决策表:不同思路的适用场景
| 场景 | 方案 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|---|
| n,m ≤ 5000,本题数据 | 倒序枚举 + Dijkstra | O ( m 2 log n ) O(m^2 \log n)O(m2logn)约5 e 8 5e85e8,可过 | O ( n + m ) O(n+m)O(n+m) | 实现简洁,正确性高 | 对更大数据可能超时 |
| n,m ≤ 2000,追求更稳 | 朴素枚举每条边,每次重新建图跑Dijkstra | O ( m 2 log n ) O(m^2 \log n)O(m2logn)但常数较大 | O ( n + m ) O(n+m)O(n+m) | 思路直接,易调试 | 重建图开销大 |
| 只关心最大边权免费(且边权范围小) | 按边权分块,用线段树维护最短路 | 可降至O ( m log 2 n ) O(m \log^2 n)O(mlog2n) | 复杂 | 效率高 | 实现难度大,且本题不需要 |
| 要求免除的边是路径最大评分,但评分范围小(如1~K) | 可枚举评分阈值,用二分+最短路判定 | O ( K ⋅ ( n + m ) log n ) O(K \cdot (n+m)\log n)O(K⋅(n+m)logn) | 简单 | 可应对更大K | 评分范围大时不适用 |
| 允许免除任意一条边(无评分限制) | 分层图最短路 | O ( ( n + m ) log n ) O((n+m)\log n)O((n+m)logn) | O ( n + m ) O(n+m)O(n+m) | 最优 | 本题规则为“最大评分”,不适用 |
6. 工程视角
在实际工程中,类似“减免最高费用”的逻辑并不少见,比如:
- 快递运费减免:快递公司推出“首重免费,续重收费”,但首重是按体积还是重量?如果按“最大体积”免除,则可抽象为本问题。
- 网络路由中的流量工程:在SDN网络中,可以指定某条“最拥塞”的链路不计费,以优化整体成本。
- 游戏中的道路建造:玩家在规划路线时,系统允许“最高等级道路免费”,从而鼓励玩家探索不同组合。
- 供应链中的关税优惠:一批货物经过多个国家,其中关税最高的那个国家给予免税,求最小总关税。
这些场景都可以转化为“枚举被优惠的关键元素 + 最短路/动态规划”的模式,本题的方法具有很强的迁移价值。
7. 小结
核心公式:
答案 = min e ∈ E ( Dijkstra ( G ∖ { e ′ ∣ b ( e ′ ) > b ( e ) } , 免除 e ) ) \text{答案} = \min_{e \in E} \Big( \text{Dijkstra}(G \setminus \{e'\mid b(e') > b(e)\}, \text{免除 } e) \Big)答案=e∈Emin(Dijkstra(G∖{e′∣b(e′)>b(e)},免除e))
其中E EE按b bb升序排列,倒序枚举时G GG自动缩减。
核心认知:
- 遇到“路径上某种属性的极值被优惠”时,优先考虑枚举那个极值元素,把优惠条件转化成一次性的零权边。
- 排序 + 倒序删除是一种通用的“按属性降维”技巧,能大幅简化图的动态维护。
- 不要轻易将状态扩展到路径属性中,除非你能保证状态压缩的单调性(否则容易写出像84分BFS那样的“看起来对,实则错”的代码)。
这道题教会我们的不是Dijkstra本身,而是如何将动态的优惠规则转化为静态的枚举代价,并利用排序来优化枚举顺序。希望你在遇到类似问题时,能想到这层“剥离最高分”的思路。
本文完
如果你觉得有帮助,欢迎点赞、收藏、转发,让更多算法爱好者看到~
有任何疑问或建议,请在评论区留言交流。