每个结点同时保留最短与次短两个距离标签,堆优化 Dijkstra 用严格开区间登记候选,一次弹堆直接得到第二短路长度。
OJ: roj
题目 ID: 1498
难度:普及+/提高-
标签:最短路Dijkstra堆
创建: 2026-09-30 14:38
更新: 2026-10-07 11:01
形式化题目
给定
记
即长度严格大于最短路、又不大于其他一切非最短路径的第二小长度。第二短路与最短路可以共用任意多条边,同一条边也可以被重复走多次;若有多条路径并列最短,它们只算同一个长度值,都必须被跳过。
样例:
flowchart LR
N1((1)) -->|100| N2((2))
N2 -->|200| N4((4))
N2 -->|250| N3((3))
N3 -->|100| N4
linkStyle 0 stroke-width:3px;
linkStyle 1 stroke:#e5484d,stroke-width:3px;
linkStyle 2 stroke:#3b82f6,stroke-width:3px;
linkStyle 3 stroke:#3b82f6,stroke-width:3px;
红色的
正解
思路
朴素做法及其瓶颈。 最直接的想法是 DFS 枚举所有路径再逐条比较长度,但路径条数是指数级,且允许重复走边后甚至无穷,
关键观察(双标签引理)。 把每个结点的距离标签从一个扩成两个:best[v][0] 记最短、best[v][1] 记次短(严格递增,未发现为
设
是到结点 的第二短路,长度 ,去掉最后一条边 得到前缀,长度 。则 一定是 的最短或次短标签值。
反证:若
双标签 Dijkstra。 于是算法水到渠成:小根堆按长度弹出
| 条件 | 动作 | 含义 |
|---|---|---|
cand < best[nxt][0] |
原最短降级为次短,cand 成为新最短 |
找到更短的路,两个槽位整体下移 |
best[nxt][0] < cand < best[nxt][1] |
cand 登记为次短 |
严格落在开区间内,才是"第 2 小" |
| 其余 | 丢弃 | 等于最短不算(题面要求严格大于),超过已知次短也无意义 |
两条配套规则缺一不可:
- 不设 visited 封印:题面允许重复走边;边权全为正,标签只会严格递减地刷新,且等于槽位值的候选被开区间拒绝,因此每个值只入堆一次,不会死循环;
- 过期项跳过:弹出时若
length > best[v][1],说明这条值入堆后已被更小的次短淘汰,直接丢弃,避免用陈旧长度污染邻居。
下表按堆的弹出顺序,展示样例上 best[2]、best[3]、best[4] 三个结点的槽位刷新过程(每格写成 最短 / 次短,— 表示尚未发现):
| 弹出的堆项 | 触发的更新 | best[2] |
best[3] |
best[4] |
|---|---|---|---|---|
| 2 登记最短 100 | 100 / — | — / — | — / — | |
| 4 最短 300、3 最短 350,另给 1 登记次短 200 | 100 / — | 350 / — | 300 / — | |
| 候选 300 卡进 |
100 / 300 | 350 / — | 300 / — | |
| 500 登记 4 的次短(回 1 的 400、去 3 的 550 被拒) | 100 / 300 | 350 / — | 300 / 500 | |
| 400 登记 3 的次短,500 被拒 | 100 / 300 | 350 / 400 | 300 / 500 | |
| 候选 450 把 4 的次短从 500 刷新为 450 | 100 / 300 | 350 / 400 | 300 / 450 | |
| 无更新,候选全部落在槽位之外 | 100 / 300 | 350 / 400 | 300 / 450 | |
| 过期项: |
100 / 300 | 350 / 400 | 300 / 450 |
观察两点:其一,best[4] = 300 / 450,次短 450 即答案。这也说明每个结点的两个槽位各自只会严格递减地更新,总更新次数有限。
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 14:44
# update_at: 2026-09-30 14:44
import heapq
import sys
INF = 10**18 # 未发现的长度哨兵:最长简单路远小于此
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
farm_cnt, road_cnt = next(data), next(data)
road: list[list[tuple[int, int]]] = [[] for _ in range(farm_cnt + 1)] # 2R 条有向邻接
for _ in range(road_cnt):
a, b, d = next(data), next(data), next(data)
road[a].append((b, d))
road[b].append((a, d))
# 每个农场保留"最短 + 次短"两个长度;第二短路允许重复走边,所以松弛不禁止回头,
# 只要求候选值严格落在 (最短, 次短) 开区间内才登记为新的次短。
best = [[INF, INF] for _ in range(farm_cnt + 1)]
best[1][0] = 0
heap = [(0, 1)] # (从农场 1 走到的长度, 农场编号)
while heap:
length, v = heapq.heappop(heap)
if length > best[v][1]: # 期间已登记更小的次短,堆里这条是过期项
continue
for nxt, w in road[v]:
cand = length + w
if cand < best[nxt][0]: # 刷新最短:原最短自动降级为次短
best[nxt][1] = best[nxt][0]
best[nxt][0] = cand
heapq.heappush(heap, (cand, nxt))
elif best[nxt][0] < cand < best[nxt][1]: # 严格更长但不超过现次短
best[nxt][1] = cand
heapq.heappush(heap, (cand, nxt))
print(best[farm_cnt][1])
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-05 04:33
* update_at: 2026-10-05 04:33
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 5005; // 结点数上限
const int MAXM = 400005; // 边数上限(无向边存两条,2 * 10^5 * 2)
const ll INF = 1e18;
ll n, m; // 结点数 N、边数 R
ll best[MAXN][2]; // best[v][0] 最短距离,best[v][1] 次短距离(严格大于最短)
// 链式前向星存图(边权需要保存,单独开 w 数组)
int head[MAXN], nxt[MAXM], to[MAXM], edge_cnt;
ll w[MAXM];
// 加一条无向边。
void add_edge(int u, int v, ll d) {
edge_cnt++;
to[edge_cnt] = v;
w[edge_cnt] = d;
nxt[edge_cnt] = head[u];
head[u] = edge_cnt;
}
// 双标签 Dijkstra:小根堆按长度弹出,对每个结点同时维护最短与次短两个槽位。
void solve() {
// 初始化两个槽位都是无穷大
for (int i = 1; i <= n; i++) {
best[i][0] = INF;
best[i][1] = INF;
}
best[1][0] = 0;
// 小根堆存 (长度, 结点)
priority_queue<pair<ll, int>, vector<pair<ll, int> >, greater<pair<ll, int> > > pq;
pq.push(make_pair(0LL, 1));
while (!pq.empty()) {
pair<ll, int> top = pq.top();
pq.pop();
ll length = top.first;
int u = top.second;
// 过期项跳过:这条长度入堆后已被更小的次短淘汰
if (length > best[u][1]) continue;
// 松弛 u 的每条出边
for (int i = head[u]; i != 0; i = nxt[i]) {
int v = to[i];
ll cand = length + w[i];
if (cand < best[v][0]) {
// 原最短降级为次短,cand 成为新最短
best[v][1] = best[v][0];
best[v][0] = cand;
pq.push(make_pair(cand, v));
} else if (cand > best[v][0] && cand < best[v][1]) {
// 严格落在开区间 (最短, 次短) 内,才登记为次短
best[v][1] = cand;
pq.push(make_pair(cand, v));
}
// 其余情况丢弃:等于最短不算(要求严格大于),超过已知次短无意义
}
}
cout << best[n][1] << "\n";
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int a, b;
ll d;
cin >> a >> b >> d;
add_edge(a, b, d);
add_edge(b, a, d);
}
solve();
return 0;
}复杂度
- 时间复杂度:建图遍历
条有向边耗时 ;主循环中每个结点的两个槽位各刷新入堆常数次,堆元素总数 ,每次堆操作 ,整体 ,与普通堆优化 Dijkstra 同阶。 时约为 量级的堆操作,全部 11 组真实数据最慢 0.116s,远低于 OJ 的 1000ms 限制。作为对比,每点跑一次 Dijkstra 的 在同规模下约 ,不可接受。 - 空间复杂度:双向邻接表
条元组、双标签数组 、小根堆 ,整体 。实测峰值 60.9MB(大头是一次性读入的输入字节串),低于 128MB 的内存限制; INF = 10**18远大于最长简单路,哨兵安全。
总结
第二短路问题的难点不是"多跑一遍最短路",而是单标签 Dijkstra 没有地方安放那些更长但仍可能夺冠的候选。双标签引理证明只需保留每个结点的前两个长度标签即可不重不漏,于是普通 Dijkstra 平凡地推广为"三段式开区间松弛 + 过期项跳过"的双标签版本:复杂度只多一个常数,却把答案直接算了出来。同一框架继续扩展就是 k 短路问题(Yen 算法、Eppstein 算法)的起点。