题单 · 习题解析

洛谷 P2865 [USACO06NOV] Roadblocks G

★★★ 题面第三段写着次短路**可以回溯** ⇒ 它不一定是简单路径,**最小反例只要一条边**(100 / 300);★ 状态 = 「在哪个点 + 第几短」,每点维护 `d1` / `d2`;★★★ 「严格」两个字:**触发条件 ≡ 抓获数,三个值域一个不差**(23 / 64 / 93),而且是能证的等价 ⇒ 造并列最短路的旋钮是**边权值域**(8 → 2 → 1:抓 29 → 70 → 115);⚠ 而同一档把「忘了把旧 d1 挤下来」压成**精确的 0**;⚠⚠ 官方样例**四个错法一个都没挡住**

原题:洛谷 P2865出自 第 33 章 最短路二:Floyd、Bellman-Ford、SPFA 与负环 的题单题面本地存档:2026-08-31
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P2865,日期见页头。两边不一致时信原站。

题目描述

Bessie 搬到了一个小农场,有时喜欢回去拜访她的一个好朋友。她不想太快到达她的旧家, 因为她喜欢沿途的风景。她决定选择第二短的路径而不是最短的路径。 她知道一定存在某条第二短路径。

乡村由 R1 ≤ R ≤ 100000)条双向道路组成,每条道路连接 N1 ≤ N ≤ 5000)个交叉路口中的两个, 这些交叉路口被方便地编号为 1 到 N。Bessie 从交叉路口 1 出发,她的朋友(目的地)在交叉路口 N

第二短路径可以与任何最短路径共享道路,并且可以回溯,即多次使用相同的道路或交叉路口。 第二短路径是长度比最短路径长的最短路径 (即,如果存在两条或多条最短路径,第二短路径是长度比这些路径长但不比任何其他路径长的路径)。

输入格式

第 1 行:两个用空格分隔的整数:NR

第 2 行到第 R+1 行:每行包含三个用空格分隔的整数:ABD, 描述连接交叉路口 AB 的一条长度为 D1 ≤ D ≤ 5000)的道路。

输出格式

第 1 行:节点 1 和节点 N 之间第二短路径的长度。

说明/提示

两条路径:1 → 2 → 4(长度 100 + 200 = 300)和 1 → 2 → 3 → 4(长度 100 + 250 + 100 = 450)。

(由 ChatGPT 4o 翻译)

时限 1 秒,内存 512 MB。

输入输出样例

输入

4 4
1 2 100
2 4 200
2 3 250
3 4 100

输出

450

最短路 1 → 2 → 4 是 300,次短 1 → 2 → 3 → 4450。 ⚠ 这组样例长得非常「正常」—— 而第 ②③④⑤ 步那四个错法,它一个都没挡住

1★★★ 先把题面第三段读完:次短路可以回头走

★★★ 最小反例只要一条边

「第二短路径可以与任何最短路径共享道路,并且可以回溯,即多次使用相同的道路或交叉路口。」

把这句话当真,就会看见一个只有两个点、一条边的反例:

2 1
1 2 100

最短路是 100;而次短路是 1 → 2 → 1 → 2 = 300 —— 走过去、走回来、再走过去。

次短路不一定是一条简单路径。 而「第二短的路径」这几个字最自然的读法恰恰是 「第二短的简单路径」——

p2865Simple.cpp✗ 枚举简单路径取第二短(官方样例照样打 450)
// 第一版:把「第二短路径」当成「第二短的**简单路径**」,DFS 枚举
//
// 这是很自然的第一反应 —— 而题面第三段明写着:
// 「第二短路径可以与任何最短路径共享道路,**并且可以回溯,即多次使用相同的道路或交叉路口**。」
//
// ⚠ 最小反例只要**一条边**:`2 1 / 1 2 100`
// 最短路是 100,而次短路是 `1 → 2 → 1 → 2` = **300**(走回去再走回来)。
// 枚举简单路径的话,从 1 到 2 只有一条 ⇒ 它根本找不到第二条。
// ⚠ 这一版只能跑很小的图(枚举简单路径是指数的),它是用来看清那句题面的,不是拿去交的。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
int n, r;
vector<vector<pair<int, int>>> g;
vector<char> vis;
set<int> lens;
void dfs(int u, int cur) {
if (cur > 200000) return;
if (u == n) { lens.insert(cur); return; }
for (auto [v, w] : g[u])
if (!vis[v]) { vis[v] = 1; dfs(v, cur + w); vis[v] = 0; }
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> r;
g.assign(n + 1, {});
for (int e = 0; e < r; e++) {
int a, b, d;
cin >> a >> b >> d;
g[a].push_back({b, d});
g[b].push_back({a, d});
}
vis.assign(n + 1, 0);
vis[1] = 1;
dfs(1, 0);
if ((int)lens.size() < 2) { cout << INF << "\n"; return 0; } // ✗ 找不到第二条
auto it = lens.begin();
++it;
cout << *it << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
300 轮 随机图 ★ 链 边权全 1
「当成简单路径」被抓 169 254 103

★ 「链」那一档最狠(254 / 300),道理和上面那个一条边的反例一样: 链上除了往回蹭,根本没有第二条路可走。

2★ 关键的一步:状态不再只是「在哪个点」

★ 「在哪个点 + 这是到它的第几短」

每个点维护两个距离:d1[v](最短)和 d2[v]严格次短)。 一次松弛三选一:

新算出来的 nd 怎么办
nd < d1[v] 旧的 d1[v] 被挤下来变成 d2[v]nd 当新的 d1[v]
d1[v] < nd < d2[v] 换掉 d2[v]
其余 丢掉

⇒ 复杂度还是 O(R log R),只是堆里的东西翻了一倍。答案就是 d2[N]

★ 这是第 32 章 P1073 那条「状态里要塞进第二个维度」在这一章的再现 —— 那里的第二维是「贸易做到哪一步」,这里是「第几短」。

p2865.cpp★ 这一版就能 AC(d1 / d2 双状态 Dijkstra,顶格 0.02 秒)
// 洛谷 P2865 [USACO06NOV] Roadblocks G —— ★ 这一版就能 AC
//
// ★ 关键的一步:**状态不再只是「在哪个点」,而是「在哪个点 + 这是到它的第几短」**。
// 每个点维护两个距离 d1(最短)和 d2(严格次短),松弛时三选一:
// · 比 d1 还短 ⇒ 旧的 d1 被**挤下来**变成 d2,新值当 d1;
// · 严格夹在 d1 和 d2 之间 ⇒ 换掉 d2;
// · 其余 ⇒ 丢掉。
//
// ⚠ 「严格」这两个字是题面自己写的:「第二短路径是长度**比最短路径长**的最短路径」。
// ⚠ 而次短路**允许重复经过边和点** —— 所以它不一定是一条简单路径(第 ① 步那个反例)。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, r;
cin >> n >> r;
vector<vector<pair<int, int>>> g(n + 1);
for (int e = 0; e < r; e++) {
int a, b, d;
cin >> a >> b >> d;
g[a].push_back({b, d});
g[b].push_back({a, d}); // 双向道路
}
vector<int> d1(n + 1, INF), d2(n + 1, INF);
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> q;
d1[1] = 0;
q.push({0, 1});
while (!q.empty()) {
auto [d, u] = q.top();
q.pop();
if (d > d2[u]) continue; // ★ 过期的:连次短都不如
for (auto [v, w] : g[u]) {
int nd = d + w;
if (nd < d1[v]) {
d2[v] = d1[v]; // ★ 旧的最短被挤成次短
d1[v] = nd;
q.push({d1[v], v});
if (d2[v] < INF) q.push({d2[v], v});
} else if (nd > d1[v] && nd < d2[v]) { // ★ 严格大于 d1
d2[v] = nd;
q.push({d2[v], v});
}
}
}
cout << d2[n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3★★★ 第二个错法:把「严格」两个字读丢了

p2865Loose.cpp✗ nd > d1[v] 写成 >=(官方样例照样打 450)
// ✗ 错法①:把「严格次短」写成「非严格次短」
//
// 题面:「第二短路径是长度**比最短路径长**的最短路径
// (即,如果存在两条或多条最短路径,第二短路径是长度比这些路径长但不比任何其他路径长的路径)」
// ⇒ 括号里那半句是专门为这个坑写的:**并列的最短路不算第二短。**
//
// 这一版把 `nd > d1[v]` 写成 `nd >= d1[v]`,于是只要存在两条一样长的最短路,
// 它就会把「另一条最短路」当成次短路交上去。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, r;
cin >> n >> r;
vector<vector<pair<int, int>>> g(n + 1);
for (int e = 0; e < r; e++) {
int a, b, d;
cin >> a >> b >> d;
g[a].push_back({b, d});
g[b].push_back({a, d});
}
vector<int> d1(n + 1, INF), d2(n + 1, INF);
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> q;
d1[1] = 0;
q.push({0, 1});
while (!q.empty()) {
auto [d, u] = q.top();
q.pop();
if (d > d2[u]) continue;
for (auto [v, w] : g[u]) {
int nd = d + w;
if (nd < d1[v]) {
d2[v] = d1[v]; d1[v] = nd;
q.push({d1[v], v});
if (d2[v] < INF) q.push({d2[v], v});
} else if (nd >= d1[v] && nd < d2[v]) { // ✗ 少了一个「严格」
d2[v] = nd;
q.push({d2[v], v});
}
}
}
cout << d2[n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

题面括号里那半句就是专门为它写的: 「(即,如果存在两条或多条最短路径,第二短路径是长度比这些路径长但不比任何其他路径长的路径)」 ⇒ 并列的最短路不算第二短。

★★★ 触发条件 ≡ 抓获数,三个值域一个不差 —— 而它是能证明的等价

把「图里从 1 到 N 的最短路有没有第二条」(第一层)和「严格 / 非严格两版结论不同」(第二层) 沿边权值域各量 300 张图:

边权值域 1~8 1~2 ★ 全是 1
第一层:存在并列的最短路 23 64 93
第二层:两版真的算出不同答案 23 64 93

★★★ 三个档位一个不差。 而这一次「一个不差」不是巧合,是能写下来的等价:

非严格那版会把「另一条同样长的最短路」收进 d2 ⇒ 它的答案恒等于最短路长度。 于是两版不同 ⟺ 最短路不止一条。

⇒ 这是本书量过的第五次「触发条件 ≡ 抓获数」(前四次是 P2240P1094P1090P1077)—— ★ 而报「一个不差」的价值就在于它把结论升级成了判据: 想抓这个 bug,就去造并列的最短路;而造并列最短路的旋钮是边权的值域,不是点数。

300 轮真跑对拍 随机图(值域 8) 值域 2 ★ 边权全 1
「严格写成非严格」被抓 29 70 115

4⚠ 第三个错法:d1 被刷新时,旧的 d1 没「挤下来」

p2865Push.cpp✗ 丢掉了旧的 d1(官方样例照样打 450)
// ✗ 错法②:d1 被刷新时,忘了把旧的 d1「挤下来」当 d2
//
// 这一处最容易漏:新来的 nd 比 d1 还短,那**原来那个 d1 就是一条合法的、更长的路** ——
// 它应该顺位变成 d2。少了这一行,很多次短路会被直接丢掉。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, r;
cin >> n >> r;
vector<vector<pair<int, int>>> g(n + 1);
for (int e = 0; e < r; e++) {
int a, b, d;
cin >> a >> b >> d;
g[a].push_back({b, d});
g[b].push_back({a, d});
}
vector<int> d1(n + 1, INF), d2(n + 1, INF);
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> q;
d1[1] = 0;
q.push({0, 1});
while (!q.empty()) {
auto [d, u] = q.top();
q.pop();
if (d > d2[u]) continue;
for (auto [v, w] : g[u]) {
int nd = d + w;
if (nd < d1[v]) {
d1[v] = nd; // ✗ 旧的 d1 就这么丢了
q.push({d1[v], v});
} else if (nd > d1[v] && nd < d2[v]) {
d2[v] = nd;
q.push({d2[v], v});
}
}
}
cout << d2[n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

新来的 ndd1[v] 还短 ⇒ 原来那个 d1[v] 是一条合法的、更长的走法,它该顺位变成 d2[v]。 少了这一行,很多次短路直接被扔了。

300 轮 随机图 值域 2 ★ 边权全 1
「忘了挤下来」被抓 23 17 3 精确的 0

★ 边权全 1 那一档的 0 是结构性的:所有边一样长 ⇒ Dijkstra 出队顺序就是层序, d1[v] 一旦定下来就再没有更短的来刷它 —— 那一行代码根本没机会执行。 ⇒ 又一次「为一个 bug 精心造的档位,正是另一个 bug 的盲区」: 同一档把「严格」抬到 115,把这个压到 0。

5⚠ 第四个错法:双向道路只存一遍

p2865Dir.cpp✗ 只存一遍(官方样例照样打 450)
// ✗ 错法③:双向道路只存一遍
//
// 题面:「每条道路连接 N 个交叉路口中的两个」,输入格式那节写的是「描述连接交叉路口 A 和 B 的一条道路」——
// 而这道题的次短路**要靠往回走**才走得出来(第 ① 步那个一条边的反例),
// ⇒ 少了反向边,连「回溯」这件事都做不到。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, r;
cin >> n >> r;
vector<vector<pair<int, int>>> g(n + 1);
for (int e = 0; e < r; e++) {
int a, b, d;
cin >> a >> b >> d;
g[a].push_back({b, d}); // ✗ 少了反着那一行
}
vector<int> d1(n + 1, INF), d2(n + 1, INF);
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> q;
d1[1] = 0;
q.push({0, 1});
while (!q.empty()) {
auto [d, u] = q.top();
q.pop();
if (d > d2[u]) continue;
for (auto [v, w] : g[u]) {
int nd = d + w;
if (nd < d1[v]) {
d2[v] = d1[v]; d1[v] = nd;
q.push({d1[v], v});
if (d2[v] < INF) q.push({d2[v], v});
} else if (nd > d1[v] && nd < d2[v]) {
d2[v] = nd;
q.push({d2[v], v});
}
}
}
cout << d2[n] << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

这道题的次短路要靠往回走才走得出来(第 ① 步那个一条边的反例)—— 少了反向边,连「回溯」这件事本身都做不到。300 轮抓 227(链那一档 277)。

⚠⚠ 「样例是一测就死的过滤器」这条规律,这一页拿到了它的极端:四个错法一个都没挡住
错法 300 轮被抓 官方样例
当成简单路径 169 放过
严格写成非严格 29 放过
忘了把旧 d1 挤下来 23 放过
双向只存一遍 227 放过

⇒ 那组样例是一张四点小图,最短路唯一、不需要回溯、而且 1 → 2 那条边正着就够用 —— 它在结构上问不出这四个问题里的任何一个P1746 那条「样例挡不住的第三种原因」)。 ★ 本轮上一道 B3647 是另一个极端(五个里挡住三个),两头在同一张题单里各出现一次。

6★ 另一条路:两次 Dijkstra + 枚举每条边

p2865Edge.cpp★ 也能 AC:ds[u] + w + dt[v],取严格大于最短路的最小值
// ★ 另一条路:两次 Dijkstra + 枚举每条边
//
// 从 1 号点跑一次得到 ds[],从 N 号点跑一次得到 dt[](图是无向的,直接反着跑就行)。
// 那么「经过边 (u, v)」的最短走法就是 ds[u] + w + dt[v];
// 在所有这样的值里挑**严格大于最短路**的最小值,就是次短路。
//
// ★ 它和 d1/d2 那份一行代码都不共享 —— 对拍时两条独立的路。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
static vector<int> dij(int n, vector<vector<pair<int, int>>>& g, int s) {
vector<int> d(n + 1, INF);
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> q;
d[s] = 0;
q.push({0, s});
while (!q.empty()) {
auto [dd, u] = q.top();
q.pop();
if (dd > d[u]) continue;
for (auto [v, w] : g[u])
if (dd + w < d[v]) { d[v] = dd + w; q.push({d[v], v}); }
}
return d;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, r;
cin >> n >> r;
vector<vector<pair<int, int>>> g(n + 1);
vector<array<int, 3>> es;
for (int e = 0; e < r; e++) {
int a, b, d;
cin >> a >> b >> d;
g[a].push_back({b, d});
g[b].push_back({a, d});
es.push_back({a, b, d});
}
auto ds = dij(n, g, 1), dt = dij(n, g, n);
int best = ds[n], sec = INF;
for (auto& e : es)
for (int dir = 0; dir < 2; dir++) {
int u = dir ? e[1] : e[0], v = dir ? e[0] : e[1];
if (ds[u] >= INF || dt[v] >= INF) continue;
int cand = ds[u] + e[2] + dt[v];
if (cand > best && cand < sec) sec = cand; // ★ 严格大于
}
cout << sec << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

从 1 号点跑一次得 ds[],从 N 号点跑一次得 dt[](图无向,直接反着跑), 那么「必须经过边 (u, v)」的最短走法就是 ds[u] + w + dt[v]; 在所有这样的值里挑严格大于最短路的最小值。

★ 它和 d1/d2 那份一行代码都不共享,四个档位 1200 轮逐字节相同 —— 第 32 章 P1629 那条「反着跑一次」在无向图上的正面用法。

7★ 对拍这一页

参照物两条路都不走:把所有「(点, 已走长度)」的状态在上界内铺满, 再看到达 N 的长度里第二小的是谁 —— 照「第二短的走法」这句话的字面意思做。

p2865Brute.cpp参照物:铺满所有 (点, 长度) 状态(1200 轮不一致 0 轮)
300 轮(n 随机 4~7) 随机图 值域 2 ★ 链 ★ 边权全 1
d1/d2 ≡ 铺状态 0 0 0 0
枚举边 ≡ 铺状态 0 0 0 0
当成简单路径 169 117 254 103
严格写成非严格 29 70 4 115
忘了把旧 d1 挤下来 23 17 3 0
双向只存一遍 227 170 277 135

★ 顺带一道三十秒的算术:最短路 ≤ (N−1) × D = 2.5 × 10⁷,次短路再多 2D ⇒ 上界 25 005 000int 余量 86 倍不用 long long。 ⚠ 而这道题顶格(N = 5000R = 100000)本机只要 0.02 秒 —— 关卡不在性能上。

8度量程序和生成器

p2865Count.cpp度量程序(本页所有数字都出自它)
p2865Gen.cpp(五个档位)数据生成器

9一页纸

★★★ 题面第三段 次短路可以回溯 ⇒ 它不一定是简单路径;最小反例只要一条边(100 / 300)
★ 关键的一步 状态 = 「在哪个点 + 第几短」⇒ 每个点维护 d1 / d2,松弛三选一
★★★ 「严格」两个字 触发条件 ≡ 抓获数,三个值域一个不差(23 / 64 / 93),而且是能证的等价
★ 旋钮是值域 造并列最短路靠边权值域(8 → 2 → 1:抓 29 → 70 → 115),不是点数
⚠ 一档只护一半 边权全 1 那档把「严格」抬到 115,同时把「忘了挤 d1」压成精确的 0
⚠⚠ 官方样例 四个错法一个都没挡住 —— 「样例是过滤器」那条规律的极端
★ 另一条路 两次 Dijkstra + 枚举每条边,1200 轮逐字节相同