题单 · 习题解析

洛谷 P1967 [NOIP 2013 提高组] 货车运输

★★★ 倍增在这道题里不是主角是配角 —— 主角是那句**需要证明的话**「最大生成树上 x 到 y 的路径最小边权,就是原图所有路径里瓶颈的最大值」,倍增只是把这棵树上的路径 min 问快一点;★★★ 而这一页最值钱的动作不是写倍增,是**用一条一行代码都不共享的路(每询问跑一次「最大瓶颈路」——把 Dijkstra 的「加法+取小」换成「取小+取大」)在五种顶格数据 × 3×10⁴ 个询问上把那句话逐字节验了一遍**;★ 次数上正解快 **6942 倍**(22 万次跳 vs 15.5 亿次松弛),秒表快 **2300 倍**(0.015 vs 34.9 秒),而题面那两个部分分档正好是「暴力值多少分」的答案(30% 档 0.13 秒、60% 档 0.41 秒 ⇒ **稳拿 60 分**);⚠⚠ 三个坑题面全写着而两个很容易看漏:**图不保证连通**(是最大生成**森林**,BFS 要对每个连通块各跑一趟)/**`0 ≤ z`** ⇒ 答案可以是 0,**别拿 0 当「不通」的标志** / 重边;★★ 而「忘了判不连通」在**顺手档是结构性的精确的 0** —— 那一档的生成器「先造一棵树保证连通」,而这几乎是所有人写图论题生成器的本能(和[第 34 章 P3366](/sol/p3366/) 那次一模一样);★★ 四个错法里 ③「漏了 LCA 正下方那两条边」和 ④「倍增合并只写了一半」**都只会让答案偏大** ⇒ 拿两个错法互相对拍查不出它们,**必须和一条独立的路比**;★ 而值域这个旋钮**把 ①(排序反了)和 ④ 推向相反方向**(边权只有 0 / 1 时:① 274、④ 从 243 掉到 113 —— 因为路径最小值很容易就是 0);⚠⚠ 外加一档在**顶格验零**的现场:只给 `n/3` 条边 ⇒ 6667 个连通块 ⇒ 3 万个询问里 **29994 个答案是 −1**,「一律输出 −1」的试金石拿 29994/30000,而倍增在那一档一共只跳了 **7 次**

原题:洛谷 P1967出自 第 51 章 LCA 与倍增:把「往上跳多少步」拆成二进制 的题单题面本地存档:2026-09-11
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目背景:NOIP2013 提高组 D1T3

题目描述

A 国有 n 座城市,编号从 1 到 n,城市之间有 m双向道路。 每一条道路对车辆都有重量限制,简称限重。

现在有 q 辆货车在运输货物,司机们想知道每辆车在不超过车辆限重的情况下,最多能运多重的货物。

输入格式

第一行有两个用一个空格隔开的整数 n, m,表示 A 国有 n 座城市和 m 条道路。

接下来 m 行每行三个整数 x, y, z,表示从 x 号城市到 y 号城市有一条限重为 z 的道路。 注意:x ≠ y,两座城市之间可能有多条道路。

接下来一行有一个整数 q,表示有 q 辆货车需要运货。

接下来 q 行,每行两个整数 x, y,表示一辆货车需要从 x 城市运输货物到 y 城市,保证 x ≠ y

输出格式

共有 q 行,每行一个整数,表示对于每一辆货车,它的最大载重是多少。 如果货车不能到达目的地,输出 −1。

数据范围

  • 对于 30% 的数据,1 ≤ n < 10001 ≤ m < 100001 ≤ q < 1000
  • 对于 60% 的数据,1 ≤ n < 10001 ≤ m < 5×10⁴1 ≤ q < 1000
  • 对于 100% 的数据,1 ≤ n < 10⁴1 ≤ m < 5×10⁴1 ≤ q < 3×10⁴0 ≤ z ≤ 10⁵

时限 1 秒,内存 131072 KB(128 MiB)。

输入输出样例

输入

4 3
1 2 4
2 3 3
3 1 1
3
1 3
1 4
1 3

输出

3
-1
3

第一辆车 1 → 3:走 1-2-3 的瓶颈是 min(4,3) = 3,走 1-3 的瓶颈是 1 ⇒ 取大的 3。 第二辆车 1 → 4:4 号城市根本没有路n = 4 但只有三条边,全在 1~3 之间)⇒ −1

1★★ 这道题立在一句需要证明的话上

★★★ 「最大生成树上的路径瓶颈」=「所有路径里最大的瓶颈」

题目问的是:从 xy所有路径里,让「这条路上最小的那条边」尽量大。 而正解只在一棵树上找路 —— 凭什么?

把边按权值从大到小加(Kruskal 求最大生成树),xy 第一次连通的那一刻, 用到的那条边就是答案。

· 比它更大的边全都已经加过了,而那时 xy 还没连通 ⇒ 不可能有更好的路; · 比它更小的边加不加都改不了「已经连通」⇒ 不会更差。

⇒ 而这句话这一页真去验了(第 ③ 步):拿一条一行代码都不共享的路 (每个询问跑一次「最大瓶颈路」)在五种顶格数据 × 3×10⁴ 个询问上逐字节对了一遍。

★ 于是这道题就是第 34 章(最小生成树)第 51 章第 13 步「同一张表,换个东西记」的合体。

p1967.cpp★ 正解:最大生成森林 + 倍增维护路径最小边权
// P1967 货车运输 —— 正解:**最大生成树 + 倍增维护路径最小边权**
//
// ★ 这道题是[第 34 章(最小生成树)](/ch/34-mst/)和[第 51 章第 13 步](/ch/51-lca/)的合体,
// 而它立在一句需要证明的话上:
//
// > **在最大生成树上,x 到 y 那条路径的最小边权,就是原图上所有 x→y 路径里
// > 「最小边权」能取到的最大值。**
//
// 一句话的道理:把边按权值**从大到小**加(Kruskal 求最大生成树),
// x 和 y 第一次连通的那一刻,用到的最后一条边就是答案 ——
// 再小的边加进来也改不了「已经连通」这件事,而更早(更大)的边不足以让它们连通。
// ⇒ 解析页第 ③ 步用一条**完全无关的路**(每询问跑一次「最大瓶颈路」)把它验了一遍。
//
// ⚠⚠ 三处题面写着的坑:
// ① **图不保证连通** ⇒ 最大生成**森林**,两点不在同一棵树里就输出 −1;
// ② **`0 ≤ z`** ⇒ 答案可以是 **0**,而 0 和 −1 是两件事(别拿 0 当「不通」的标志);
// ③ **两座城市之间可能有多条道路**(重边)—— Kruskal 天然不怕,可暴力那边要小心。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 10005;
const int MAXM = 50005;
const int LOG = 15; // 2^14 = 16384 > 10⁴
const int INF = 0x3f3f3f3f;
struct Edge { int u, v, w; };
Edge es[MAXM];
int fa[MAXN];
int head_[MAXN], nxt_[MAXN * 2], to_[MAXN * 2], wt_[MAXN * 2], ecnt; // ★ 只存生成森林的边 ⇒ 至多 2(n−1) 条
int up[MAXN][LOG]; // 祖先
int mn[MAXN][LOG]; // ★ 同一张表,换个东西记:到 2^k 级祖先那一段的**最小边权**
int dep[MAXN], comp[MAXN]; // comp = 属于哪一棵树(森林)
int n, m, q;
int find_(int x) { while (fa[x] != x) x = fa[x] = fa[fa[x]]; return x; }
inline void addEdge(int u, int v, int w) { to_[++ecnt] = v; wt_[ecnt] = w; nxt_[ecnt] = head_[u]; head_[u] = ecnt; }
int main() {
if (scanf("%d %d", &n, &m) != 2) return 0;
for (int i = 0; i < m; i++)
if (scanf("%d %d %d", &es[i].u, &es[i].v, &es[i].w) != 3) return 0;
/* ① 最大生成森林:按权值**从大到小**排(⚠ 不是从小到大) */
sort(es, es + m, [](const Edge& a, const Edge& b) { return a.w > b.w; });
for (int i = 1; i <= n; i++) fa[i] = i;
for (int i = 0; i < m; i++) {
int a = find_(es[i].u), b = find_(es[i].v);
if (a == b) continue; // 已经连通(重边、成环的边都在这儿被丢掉)
fa[a] = b;
addEdge(es[i].u, es[i].v, es[i].w);
addEdge(es[i].v, es[i].u, es[i].w);
}
/* ② 每棵树各 BFS 一趟(⚠ ① 是森林,不是一棵树) */
{
vector<char> vis(n + 1, 0);
vector<int> que;
que.reserve(n);
for (int r = 1; r <= n; r++) {
if (vis[r]) continue;
vis[r] = 1; dep[r] = 0; comp[r] = r;
up[r][0] = r; mn[r][0] = INF; // 根往上没有边 ⇒ 那一段的最小边权是「无穷大」
que.clear(); que.push_back(r);
for (size_t i = 0; i < que.size(); i++) {
int u = que[i];
for (int e = head_[u]; e; e = nxt_[e]) {
int v = to_[e];
if (vis[v]) continue;
vis[v] = 1; dep[v] = dep[u] + 1; comp[v] = r;
up[v][0] = u; mn[v][0] = wt_[e];
que.push_back(v);
}
}
}
}
for (int k = 1; k < LOG; k++)
for (int v = 1; v <= n; v++) {
up[v][k] = up[up[v][k - 1]][k - 1];
mn[v][k] = min(mn[v][k - 1], mn[up[v][k - 1]][k - 1]); // ★ 两段拼起来取 min
}
if (scanf("%d", &q) != 1) return 0;
while (q--) {
int x, y;
if (scanf("%d %d", &x, &y) != 2) return 0;
if (comp[x] != comp[y]) { printf("-1\n"); continue; } // ⚠ ① 不连通
int res = INF;
if (dep[x] < dep[y]) swap(x, y);
int d = dep[x] - dep[y];
for (int k = 0; k < LOG; k++)
if ((d >> k) & 1) { res = min(res, mn[x][k]); x = up[x][k]; }
if (x != y) {
for (int k = LOG - 1; k >= 0; k--)
if (up[x][k] != up[y][k]) {
res = min(res, min(mn[x][k], mn[y][k]));
x = up[x][k]; y = up[y][k];
}
res = min(res, min(mn[x][0], mn[y][0])); // ⚠ 最后各再上一步
}
printf("%d\n", res);
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 三处坑,题面全写着,而两处很容易看漏
题面原话 后果
「如果货车不能到达目的地,输出 −1」 ★★ 图不保证连通 ⇒ 是最大生成森林,不是一棵树
0 ≤ z 答案可以是 0,而 0 和 −1 是两件事 —— 别拿 0 当「不通」的标志
「两座城市之间可能有多条道路 Kruskal 天然不怕(第二条自动被丢),可暴力那边要存两遍

⚠ 而「森林」这一条会连累两处:BFS 要对每个连通块各跑一趟(不能只从 1 号点出发), comp[] 也要记下「谁属于哪棵树」。

2第一版:每个询问跑一次「最大瓶颈路」

p1967Brute.cpp✗ 第一版:Dijkstra 骨架,把「加法+取小」换成「取小+取大」
★ 它是题面的直译,而且和正解一行代码都不共享

「不超过限重最多运多重」= 「让路上最小的那条边尽量大」,写成递推就是

   best[v] = max over 边(u,v) of  min( best[u], w(u,v) )

—— 把第 32 章 Dijkstra 那套骨架里的「加法 + 取小」换成「取小 + 取大」, 堆也从小根换成大根。不排序、不建生成树、不用倍增。 ⇒ 所以它既是对拍的标准答案,也是第 ① 步那句话的独立验证者

⚠ 顺带一个自己踩的:它的边表要按 m 开(2m = 10⁵), 第一版照着正解写成了 MAXN*2 = 2×10⁴ —— 顶格上直接写飞, 而小数据(m < 10⁴)上一点事都没有

3★★★ 顶格实测:正解快 1724 倍,而两条路的答案一个字节都不差

p1967Count.cpp★ 数「暴力松弛了多少条边 / 倍增跳了多少次」+ 这份数据长什么样
// P1967 的两笔账:这份数据长什么样,以及两条路各做多少次基本动作(机器无关)
//
// 用法:./p1967Count <csv|table> < 一份输入
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 10005;
const int MAXM = 50005;
const int LOG = 15;
const int INF = 0x3f3f3f3f;
struct Edge { int u, v, w; };
static Edge es[MAXM];
static int fa[MAXN];
static int head_[MAXN], nxt_[MAXN * 2], to_[MAXN * 2], wt_[MAXN * 2], ecnt;
static int up[MAXN][LOG], mn[MAXN][LOG], dep[MAXN], comp[MAXN];
static int gh[MAXN], gn[MAXM * 2], gt[MAXM * 2], gw[MAXM * 2], gc;
static int n, m, q;
static int find_(int x) { while (fa[x] != x) x = fa[x] = fa[fa[x]]; return x; }
int main(int argc, char** argv) {
string mode = argc > 1 ? argv[1] : "table";
if (scanf("%d %d", &n, &m) != 2) return 0;
for (int i = 0; i < m; i++) {
if (scanf("%d %d %d", &es[i].u, &es[i].v, &es[i].w) != 3) return 0;
gt[++gc] = es[i].v; gw[gc] = es[i].w; gn[gc] = gh[es[i].u]; gh[es[i].u] = gc;
gt[++gc] = es[i].u; gw[gc] = es[i].w; gn[gc] = gh[es[i].v]; gh[es[i].v] = gc;
}
sort(es, es + m, [](const Edge& a, const Edge& b) { return a.w > b.w; });
for (int i = 1; i <= n; i++) fa[i] = i;
int used = 0;
for (int i = 0; i < m; i++) {
int a = find_(es[i].u), b = find_(es[i].v);
if (a == b) continue;
fa[a] = b; used++;
to_[++ecnt] = es[i].v; wt_[ecnt] = es[i].w; nxt_[ecnt] = head_[es[i].u]; head_[es[i].u] = ecnt;
to_[++ecnt] = es[i].u; wt_[ecnt] = es[i].w; nxt_[ecnt] = head_[es[i].v]; head_[es[i].v] = ecnt;
}
int comps = n - used, maxDep = 0;
{
vector<char> vis(n + 1, 0);
vector<int> que; que.reserve(n);
for (int r = 1; r <= n; r++) {
if (vis[r]) continue;
vis[r] = 1; dep[r] = 0; comp[r] = r; up[r][0] = r; mn[r][0] = INF;
que.clear(); que.push_back(r);
for (size_t i = 0; i < que.size(); i++) {
int u = que[i];
for (int e = head_[u]; e; e = nxt_[e]) {
int v = to_[e];
if (vis[v]) continue;
vis[v] = 1; dep[v] = dep[u] + 1; comp[v] = r;
up[v][0] = u; mn[v][0] = wt_[e]; que.push_back(v);
}
}
}
for (int i = 1; i <= n; i++) maxDep = max(maxDep, dep[i]);
}
for (int k = 1; k < LOG; k++)
for (int v = 1; v <= n; v++) { up[v][k] = up[up[v][k - 1]][k - 1]; mn[v][k] = min(mn[v][k - 1], mn[up[v][k - 1]][k - 1]); }
if (scanf("%d", &q) != 1) return 0;
long long jumps = 0, relax = 0;
int neg = 0, zero = 0;
vector<int> best(n + 1);
for (int t = 0; t < q; t++) {
int x, y;
if (scanf("%d %d", &x, &y) != 2) return 0;
/* ① 倍增:数「跳了多少次」 */
int ans;
if (comp[x] != comp[y]) ans = -1;
else {
int a = x, b = y, res = INF;
if (dep[a] < dep[b]) swap(a, b);
int d = dep[a] - dep[b];
for (int k = 0; k < LOG; k++) if ((d >> k) & 1) { res = min(res, mn[a][k]); a = up[a][k]; jumps++; }
if (a != b) {
for (int k = LOG - 1; k >= 0; k--) if (up[a][k] != up[b][k]) { res = min(res, min(mn[a][k], mn[b][k])); a = up[a][k]; b = up[b][k]; jumps += 2; }
res = min(res, min(mn[a][0], mn[b][0]));
}
ans = res;
}
if (ans < 0) neg++; else if (ans == 0) zero++;
/* ② 暴力:数「松弛了多少条边」 */
for (int i = 1; i <= n; i++) best[i] = -1;
best[x] = INT_MAX;
priority_queue<pair<int, int> > pq;
pq.push(make_pair(best[x], x));
while (!pq.empty()) {
pair<int, int> tt = pq.top(); pq.pop();
int u = tt.second;
if (tt.first != best[u]) continue;
if (u == y) break;
for (int e = gh[u]; e; e = gn[e]) {
relax++;
int v = gt[e], cand = min(best[u], gw[e]);
if (cand > best[v]) { best[v] = cand; pq.push(make_pair(cand, v)); }
}
}
}
vector<pair<string, string> > out;
out.push_back(make_pair("n", to_string(n)));
out.push_back(make_pair("m", to_string(m)));
out.push_back(make_pair("q", to_string(q)));
out.push_back(make_pair("comps", to_string(comps)));
out.push_back(make_pair("maxdep", to_string(maxDep)));
out.push_back(make_pair("ans_neg", to_string(neg)));
out.push_back(make_pair("ans_zero", to_string(zero)));
out.push_back(make_pair("jumps", to_string(jumps)));
out.push_back(make_pair("relax", to_string(relax)));
char buf[64]; snprintf(buf, sizeof(buf), "%.0f", relax / (double)max(1LL, jumps));
out.push_back(make_pair("ratio", string(buf)));
if (mode == "csv") for (size_t i = 0; i < out.size(); i++) printf("%s,%s\n", out[i].first.c_str(), out[i].second.c_str());
else for (size_t i = 0; i < out.size(); i++) printf(" %-10s %s\n", out[i].first.c_str(), out[i].second.c_str());
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
p1967GenBig.cpp★ 顶格 / 分档生成器:full / chain / forest / p30 / p60
★★★ 本机实测(2026-09-11,时限 1 秒)
数据 n / m / q 连通块 生成森林最深 ✗ 暴力松弛边数 ★ 倍增跳数 ✗ 暴力 ★ 正解
30% 档 999 / 9999 / 999 1 51 10 439 997 5 240 0.13 秒 0.00
60% 档 999 / 5×10⁴ / 999 1 32 50 384 928 5 575 0.41 秒 0.00
full(100%) 10⁴ / 5×10⁴ / 3×10⁴ 1 106 1 552 698 807 223 660 34.9 秒 0.015
chain(生成树是一条链) 同上 1 9 999 1 501 734 704 184 186 25.1 秒 0.015
forest(只给 n/3 条边) 10⁴ / 3333 / 3×10⁴ 6 667 13 121 888 7 0.01 秒 0.00

⇒ ★★★ 五种顶格数据、每种 3×10⁴ 个询问,暴力和正解逐字节相同 —— 这就是第 ① 步那句话的验证,而验它的那条路和正解一行代码都不共享

★ 次数上正解快 6942 倍,秒表上快 2300 倍(34.9 / 0.015 —— ⚠ 正解那一格 单跑一次分辨率不够,是跑 20 次算的平均)。 ★★ 而题面那两个部分分档正好是「暴力值多少分」的答案: 30% 档 0.13 秒、60% 档 0.41 秒 ⇒ 暴力稳拿 60 分。

⚠⚠ 而 `forest` 那一档在验零:30000 个询问里 29994 个答案是 −1

只给 n/3 条边 ⇒ 6667 个连通块 ⇒ 随便挑两个点,几乎一定不在同一棵树里。 ⇒ 那一档「一律输出 −1」的试金石能拿 29 994 / 30 000, 而倍增在那一档一共只跳了 7 次

「一致有两种:都算对了,和都没算」的顶格版: 这一档测的是「你会不会判不连通」,测不出任何和倍增有关的东西。

4⚠ 四个错法:两个在「建哪棵树」上,两个在「怎么合并 min」上

p1967Min.cpp✗ 错法①:排序方向反了 —— 建成了最小生成树
p1967Conn.cpp✗ 错法②:忘了「图不保证连通」
p1967Last.cpp✗ 错法③:忘了「最后各再上一步」那两条边
p1967Half.cpp✗ 错法④:倍增合并那一行只写了一半
★ 四个错法各自「算了什么」
它其实在算什么 白送的推论
①Min 最小生成树上的路径瓶颈 —— 题目要的反义词 所有边权都一样时一分不扣
②Conn 两个点各自到自己那棵树的根这一路的最小边权 图恰好连通时一分不扣
③Last 路径上除了 LCA 正下方那两条边以外的最小边权 ⇒ 答案只会偏大;祖先对上一分不扣
④Half mn[v][k] 恒等于 mn[v][1](往上翻只看最靠下那两条边) ⇒ 同样只会偏大;路径短时一分不扣

★★ 而 ③ 和 ④ 都只会让答案偏大 —— 于是「拿两个错法互相对拍」是查不出它们的 (两边一起偏大时可能撞成同一个数)。必须和一条独立的路比。

5★ 对拍:四个错法 + 一份「一律 −1」

p1967AllNeg.cpp★ 试金石:一律输出 −1
p1967Gen.cpp★ 生成器:四个档位
★★★ 300 轮 × 四档(本机实测,2026-09-11)
档位 ①Min ②Conn ③Last ④Half 试金石
0 顺手写法(先造一棵树保证连通 + 几条额外边) 275 0 277 243 300
1 稀疏随机边(不保证连通 198 258 172 181 290
2 边权只有 0 和 1 + 重边 274 0 252 113 300
3 最终档 = 1 + 2 + 点多一些 190 281 154 140 288

★★★ ②Conn 在档 0 和档 2 上是结构性的精确的 0 —— 那两档的生成器 先造一棵树保证连通,而这几乎是所有人写图论题生成器的本能 (第 34 章 P3366 那次一模一样:那道题「不连通输出 orz」也是被同一个习惯藏住的)。

⚠ 而档 2 把 ④Half 从 243 压到 113:边权只有 0 和 1 ⇒ 路径上的最小值很容易就是 0, 而 0 一旦出现在最靠下那两条边里,「只看前半段」和「两段取 min」就一样了。 ⇒ ★ 同一个旋钮(值域)把 ①Min 和 ④Half 推向了相反方向(274 vs 113)。

6★ 哪一版就已经能过了

★★ 结论
版本 能过吗 数字
✗ 每询问跑一次最大瓶颈路 60 分 60% 档 0.41 秒;100% 档 34.9 秒 / 时限 1 秒
正解 0.015 秒、6.3 MiB / 128 MiB —— 余量 60 倍以上

⇒ 这道题的正解一点都不卡常,功课全在三件事上: ① 那句需要证明的话(最大生成树); ② 「同一张表,换个东西记」(倍增维护路径 min); ③ 题面写着的三个坑(森林z 可以是 0 / 重边)。

★ 而这三件里,只有第 ② 件是这一章教的 —— 另外两件一件来自第 34 章, 一件只来自把题面读完

★ 一句话带走

倍增在这道题里不是主角,是配角 —— 主角是那句「最大生成树上的路径瓶颈就是答案」,而倍增只是把这棵树上的路径 min 问快一点。 ⇒ 于是这一页最值钱的动作不是写倍增,是用一条完全无关的路(最大瓶颈路) 在五种顶格数据上把那句话验了一遍