2019年某高校程序设计新生赛(二)里有一道题叫“谍战风云”,当时我印象特别深。考场里一大半新生读完题之后都笑了:这不就是个最短路模板题吗?邻接表、堆优化Dijkstra一套组合拳打上去,样例顺利通过,结果一提交就是一个大红的WA。这道题表面考图论,实际考的是你对“建模”的敏感度:题目里多了一个“先拿到密钥才能走加密信道”的条件,你没把它翻译成图的状态,模板写再多遍也没用。这篇文章把这道题从读题到AC的全过程拆开讲透,适合刚学完Dijkstra、想进阶条件最短路和分层图的新生,也适合想提高题目区分度的出题人参考。
1. 赛题背景与整体设计思路
1.1 这道“谍战风云”到底考什么
先说清楚题目的设定。我按当年原题的大致题意整理一下:地下联络网有N个联络点,M条单向密语信道,每条信道传输情报都有一个耗时。其中一部分信道是加密信道,必须先去任意一个密钥站拿到解码器才能通行。总部在1号点,需要把一份绝密情报送到N号点,问最短耗时是多少,如果无法送达就输出-1。
第一次看到这个题,条件反射就是“裸最短路”。因为节点、有向边、边权全都齐了,最短路的三个要素一个不缺。但往深处一看就发现问题:普通边随时都能走,可加密边在拿到密钥之前是不能走的。也就是说,所有路径都不是单纯的点到点,而是被拆成了“先到密钥站”和“再从密钥站出发”两段。这不是模板能直接解决的,必须在模型层面做文章。
这道题很典型地反映了新生赛进阶题的设计思路:不考复杂算法,考的是你能不能把一个带前置条件的现实场景翻译成图论模型。题目本身并不难,但很多人死在“读题太快,建模太慢”上。
1.2 从题干描述到图论模型的翻译过程
建模的第一步,是把题面里的元素逐个对应到图论概念上:
- 联络点对应图的节点,编号从1到N;
- 单向密语信道对应有向边;
- 信道耗时对应边权;
- 密钥站是带有特殊标记的节点;
- “必须持有密钥才能走加密信道”是一个状态限制,它决定了某些边不是所有时候都能走。
如果只是前三条,这道题就是在模板库里的普通最短路。但是加上最后一条之后,问题性质就变了:路径不仅要满足拓扑上的连通性,还要满足时间上的先后条件。你先到密钥站,拿到了密钥,然后才能解锁加密边的通行权限。这种“某个条件满足后才能走某类边”的模型,学名叫条件最短路,本质上是分层图思想在竞赛里的入门应用。
很多新生不理解为什么要分“状态”,总觉得图就是点和边,一条边能走就是能走。实际上在带状态的图里,同一个节点在不同的状态下可以有不同的含义。比如节点v,在“无密钥”状态下到达它,和“有密钥”状态下到达它,虽然物理位置一样,但后续能走的边完全不同。这就是为什么要维护一个带状态的dist数组,而不是简单的一维dist。
1.3 难度分层与出题意图:为什么不能只会模板
如果把这道题的选手表现按能力分层,大概是三个层次:
第一层,只会背模板。这部分人看到最短路就敲,把加密边也当成普通边一起松弛,样例可能能过,但一到有区分度的数据就WA。
第二层,能想到拆成两段最短路的思路。他们会意识到路径必然经过某个密钥站,于是设计出“先求起点到每个密钥站的最短路,再求每个密钥站到终点的最短路,然后合并答案”的方案。这个思路已经对了,但如果直接枚举每个密钥站跑Dijkstra,复杂度可能撑不住大数据。
第三层,会做复杂度优化。他们能想到在反图上从终点跑一次Dijkstra,一次性求出所有点到达终点的最短路,从而把第二层的多次Dijkstra压缩成两次。
出题人把题目命名为“谍战风云”,本质就是想用背景故事包装一个条件最短路模型。所以这道题真正考验的不是算法储备量,而是面对新场景时能不能完成“文字—模型—算法”的快速映射。这也是新生在学完基础算法后最需要补的一课。
2. 核心细节解析与算法选型
2.1 数据范围决定算法上限
做任何图论题,第一步必须估算数据范围,因为这直接决定算法能不能活下来。当年这道题的约束范围大概是N不超过1e5,M不超过2e5。在这个量级下,几个常见方案的复杂度差异就很明显了。
写题的时候我习惯先用一张表在心里过一遍候选算法。这里把关键选项列出来:
| 算法 | 时间复杂度 | 能否胜任 |
|---|---|---|
| Floyd | O(N^3) | 完全不行,N到1e5直接是天文数字 |
| DFS暴力枚举路径 | 指数级 | 完全不行 |
| SPFA | 最坏O(NM) | 不行,容易被构造数据卡爆 |
| 堆优化Dijkstra | O(M log N) | 能胜任,且非常稳定 |
很多人喜欢用SPFA,觉得代码短、常数小。但SPFA的最坏复杂度是O(NM),在N=1e5、M=2e5的数据下,一旦被卡,跑出天文数字都有可能。堆优化Dijkstra虽然多写几行,但复杂度是稳定的O(M log N),在竞赛里属于“用时间换安心”的经典选择。
2.2 为什么是两次最短路而不是一次全局扫描
理解了模型之后,可能会冒出另一个问题:从起点1到终点N,直接跑一次Dijkstra,遇到加密边就跳过不就行了?不行,因为Dijkstra本身不具备“中途改变状态”的能力。
举个例子:路径是1 → 2 → 3,其中2是密钥站,3是加密边的终点。在“无密钥”阶段的Dijkstra里,边2→3是加密边,走不了,所以Dijkstra根本不会把它加入候选集合。可是当到达2号点拿到密钥之后,2→3这条边突然就变得可走了。这种“运行过程中边集合动态变化”的行为,已经超出了单次静态Dijkstra的处理范围。
正确的思路是拆开走。任何一条合法路径都可以描述成:从起点出发,经过一些普通边,到达某个密钥站;再从密钥站出发,走普通边和加密边,最终到达终点。把问题拆成两段看:
- 前半段:在“不能走加密边”的图上,求起点到所有密钥站的最短路;
- 后半段:在“所有边都能走”的图上,求每个密钥站到终点的最短路;
- 最后把两段拼起来,取最小值。
这就是一个典型的“分段最短路”模型。理解了为什么要拆,比记住这个解法更重要。
2.3 反向建图:省掉K次Dijkstra的关键技巧
前半段只需要一次Dijkstra就能解决,因为起点固定。但后半段如果真去枚举每个密钥站作为起点,分别跑一次Dijkstra,那复杂度就是O(K × M log N)。密钥站数量一多,基本就超时了。
这里需要做一个转化。在正向图里求“任意一个密钥站到终点N的最短路”,等价于在反向图里求“从终点N到任意一个密钥站的最短路”。反向图的构造方法很简单:对正向图里的每一条有向边u → v(边权w),在反向图里生成一条v → u(边权同样为w),方向完全反过来。
为什么可行?因为一条原图上的路径k → ... → N,把边的方向全部翻转之后,就变成反向图上从N出发的路径N → ... → k,路径包含的边完全一样,边权之和也完全一样。所以,只要在反向图上以N为起点跑一次Dijkstra,就能一次算出所有点到终点的最短路。这样整道题就只需要两轮Dijkstra,一轮正图不带加密边,一轮反图带加密边,复杂度直接落到O(M log N)。
这个“反向建图”的技巧在竞赛里非常常用,凡是“多个起点、一个终点”的最短路问题,都可以用这个思路把多次Dijkstra压缩成一次。千万别只会正着建图,遇到这类问题会绕不少弯路。
3. 实操过程与核心环节实现
3.1 完整C++实现(含注释)
下面给出这道题的一份完整可运行代码。为了保证可读性,我直接使用两个邻接表分别存正向图和反向图。Dijkstra函数用一个额外的布尔参数控制“能不能走加密边”,这样两次调用就能复用同一个函数。
#include <bits/stdc++.h> using namespace std; using ll = long long; const ll INF = 4e18; const int MAXN = 100010; struct Edge { int to; ll w; bool secure; // true表示这条边是加密信道 }; int n, m, K; bool isKey[MAXN]; // 标记节点是否为密钥站 vector<Edge> g[MAXN], rg[MAXN]; // g正向图,rg反向图 // graph传引用,canUseSecure表示本轮Dijkstra是否允许走加密边 vector<ll> dijkstra(int s, vector<Edge> graph[], bool canUseSecure) { vector<ll> dist(n + 1, INF); priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<pair<ll, int>>> pq; dist[s] = 0; pq.push({0, s}); while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); if (d != dist[u]) continue; // 旧状态直接丢弃 for (const Edge &e : graph[u]) { if (e.secure && !canUseSecure) continue; if (dist[e.to] > d + e.w) { dist[e.to] = d + e.w; pq.push({dist[e.to], e.to}); } } } return dist; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> m; cin >> K; for (int i = 0; i < K; i++) { int x; cin >> x; isKey[x] = true; } for (int i = 0; i < m; i++) { int u, v, type; ll w; cin >> u >> v >> w >> type; // type=1表示加密信道 bool secure = (type == 1); g[u].push_back({v, w, secure}); rg[v].push_back({u, w, secure}); } // 第一次:正向图,不能走加密边,求起点到所有点的最短路 vector<ll> distFromStart = dijkstra(1, g, false); // 第二次:反向图,可以走加密边,求终点到所有点的最短路 vector<ll> distToEnd = dijkstra(n, rg, true); ll ans = INF; for (int i = 1; i <= n; i++) { if (isKey[i] && distFromStart[i] != INF && distToEnd[i] != INF) { ans = min(ans, distFromStart[i] + distToEnd[i]); } } if (ans == INF) cout << -1 << '\n'; else cout << ans << '\n'; return 0; }代码本身不算长,但里面有几个细节值得反复琢磨。
3.2 关键代码段逐行解读
先说建图部分。正向图g用来处理从起点出发的路径,反向图rg用来处理从终点回推的路径。注意加边时,正向图的u → v写入g[u],反向图的v → u写入rg[v],边权和加密标记完全保留。这样做的目的是让Dijkstra在反图上以n为起点跑一次,就能直接算出所有点到n的距离。
再看Dijkstra函数里的canUseSecure参数。第一次调用传false,表示在获取密钥之前加密边都不能走;第二次调用传true,表示拿到密钥之后加密边全部开放。这个参数是整个算法的灵魂,它把“状态限制”成功地封装进了最短路框架里,比真的写二维dist数组要简洁得多。
最后是答案合并部分。枚举所有密钥站i,答案只可能是“起点到密钥站的最短路 + 密钥站到终点的最短路”。这里有两个常见坑:一个是忘记判断distFromStart[i]和distToEnd[i]是否为INF,直接相加会导致INT_MAX溢出成负数;另一个是忘记考虑起点或终点本身就是密钥站的情况。如果起点就是密钥站,distFromStart[起点] = 0,这个逻辑是自动支持的;如果终点就是密钥站,distToEnd[终点] = 0,同样没问题。
3.3 样例推导与边界数据验证
为了让你彻底搞懂这个模型,我手写一个样例并逐步推演。
输入:
5 6 1 2 1 2 1 0 2 3 1 0 3 4 1 1 4 5 1 0 1 5 100 0 1 3 6 0解释一下:节点1到5共5个点,密钥站只有2号节点。加密信道只有一条,就是3 → 4,其余都是普通信道。边权中1 → 5直达需要100,而经过密钥站的“先绕路再走加密线”路线明显更短。
先看第一次Dijkstra,从1号点出发,不能走加密边。边1 → 2、1 → 5、1 → 3、2 → 3都能走,但3 → 4因为加密被跳过。于是得到关键距离:到达2号点的最小耗时是1,到达3号点的最小耗时是2(走1 → 2 → 3),到达5号点最小耗时是100。4号点目前不可达。
再看第二次Dijkstra,在反向图上从5号点出发,允许走加密边。反向边里有5 → 4、4 → 3、3 → 2、2 → 1、5 → 1、3 → 1。算一下从5回推的距离:到4是1,到3是2,到2是3,到1是4。
最后合并。密钥站只有2号点,答案为distFromStart[2] + distToEnd[2] = 1 + 3 = 4。实际路线是1 → 2(耗时1),拿到密钥,再走2 → 3(耗时1),3 → 4(加密信道,耗时1),4 → 5(耗时1),总耗时正好4。而1 → 5直达需要100,所以正确答案是4。代码输出的结果也确实是4。
边界情况也验证一遍:如果N=1,起点就是终点,且1号点恰好是密钥站,那么distFromStart[1] = 0,distToEnd[1] = 0,答案0,代码能正确处理。如果某个密钥站根本到不了,distFromStart里对应值还是INF,合并阶段会被过滤掉,不会进入答案候选。如果所有密钥站的组合都不可达,ans保持INF,输出-1,符合题意。
4. 常见问题与调试技巧实录
4.1 样例过不了?八成是状态转移的锅
很多人第一次交这道题,代码逻辑看着完全没毛病,但就是WA。最常见的原因就是把加密边当成普通边直接松弛了。比如写出这样的松弛代码:
if (dist[v] > dist[u] + w) { dist[v] = dist[u] + w; }这种写法在普通最短路里没问题,但在本题里完全错误。因为边3 → 4如果是加密边,在没有密钥的状态下根本不应该进入候选集合。Dijkstra一旦把加密边加进去,整个“先取密钥”的前置条件就被绕过了,答案自然偏小。
正确的做法是像上面代码里那样,在遍历边时用canUseSecure先做一次过滤。如果你没有用正反图两次Dijkstra,而是想直接在Dijkstra内部维护状态,那就需要把dist数组扩展成二维dist[state][node],也就是分层图的做法,新增一个状态维度。但从代码复杂度看,对新生来说两次Dijkstra反而更容易写对。
还有一种情况是密钥站本身就在加密边的“下游”,导致无法先到达密钥站。这类数据出题人一般会避免,但自己构造样例时要注意检查一下,避免迷惑。
4.2 超时与内存超限的排查路径
我自己当年写这道题就踩过一个巨蠢的坑:priority_queue默认是大顶堆,我忘了加greater,导致每次弹出的都是最大距离,算法完全退化。后来养成了习惯,写堆优化Dijkstra时,优先队列声明固定写成:
priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<pair<ll, int>>> pq;另外,Dijkstra出队后一定要判断一下当前距离是不是已经过期的旧值。如果不判断,同一个节点可能会被重复松弛多次,虽然小数据看不出问题,但大数据就会超时。
内存方面,vector邻接表本身比较安全,但要注意MAXN开得够不够。N是1e5,最稳妥是开100010或者直接用n+1动态开。如果你用静态链表,记得每条边在两个图里要分别存一次,总边数空间要预留2M。我建议新手别碰静态链表,vector邻接表已经足够稳,重点是别把MAXN开小了导致RE。
4.3 从WA到AC:对拍与手算的习惯
最后说一个我特别想强调的习惯:对拍。很多新生写竞赛题,WA之后只会盯着代码发呆,然后瞎改。我自己的做法是,在本地写一个绝对正确但复杂度很高的暴力程序,用它跟Dijkstra版本跑同一个随机小数据,逐条比对答案。
对于这道题,暴力版本可以直接DFS枚举所有路径,在枚举过程中记录是否已经拿到密钥。N比较小的时候这个暴力程序是对的,只是慢。用随机数据生成器造几百组N不超过10的小样例,把暴力输出和Dijkstra输出比对,有差异就说明代码里有隐藏问题。
我曾经用这个方法帮一个同学抓到了他代码里的bug:他把“反向图”建成了正向图的拷贝,忘了把边反向,导致第二次Dijkstra跑的根本不是从终点回推。这种逻辑错误靠肉眼盯很难发现,但对拍一比对立刻露馅。
再补充一个小技巧:写题之前先在草稿纸上手算一遍样例,把每个关键数组的变化过程写下来。比如本题的distFromStart和distToEnd,手动推一遍之后,代码该在什么地方过滤、什么地方跳过,心里就有数了。很多WA不是算法想错,是细节状态没梳理清楚。
我个人在实际操作中的体会是,这道“谍战风云”的价值不在题目本身,而在它逼着你把模板之外的那一步思考补上。以后再遇到什么“带前置条件的最短路”“解锁类图论题”,脑子里能第一时间冒出“分段处理”和“反向建图”这两个工具,那这道题才算真的没白做。