多次询问无向图中点对的最大瓶颈路:Kruskal 建最大生成森林,倍增 LCA 上跳时维护路径最小边权,O(log n) 回答每个询问。
OJ: luogu
题目 ID: P1967
难度:提高+/省选-
标签:最大生成树KruskalLCA倍增瓶颈路图论并查集python
创建: 2026-06-22 21:38
更新: 2026-10-21 12:00
形式化题目
给定无向带权图
有
即所有
数据范围:
为什么与最短路无关
第一反应可能是「跑一遍最短路」。但最短路最小化的是边权之和:
而本题要最大化的是路径上的最小边权。两个目标会选出完全不同的路径:一条边数少、总和小的路径可能中途夹着一条限重 1 的窄桥,而一条绕远路却条条边都限重
核心性质:最大生成树路径上的最小边权 = 点对最大瓶颈值
这是本题唯一的难点。断言:设
其中
证明。 记右边(原图最大瓶颈值)为
先证「
再证「
- 只用权
的边已能连通 ,所以存在一条全程限重 的路径, ; - 只用权
的边不能连通 ,所以任何 路径必含一条权 的边, 。
两边夹住:
这个论证和 P1396 的「阈值连通性」是同一个骨架,只是方向翻过来了:P1396 问「让两点连通的最小阈值」(minimax),本题问「让两点连通的最大阈值」(maximin)。
为什么是最大生成树而不是最小生成树
因为阈值的方向反了。本题的有效边是「权大的边」——限重越大越好。把上面的证明按「权
一个具体的反例:三点
图不连通:建最大生成森林
题面不保证图连通(样例里
关键的一点是正确性不损失:上文证明里的阈值论证是在 component[x] 块编号,查询时先比对块号,
倍增 LCA 上如何维护路径最小边权
树建好后,每次询问就是在树上找
倍增表除了常规的 up[x][j](向上跳 min_edge[x][j](这
即「前一半的最小」和「后一半的最小」拼起来,因为取 min 满足结合律,min_edge[root][0] 填
查询分三步,每步沿途用 min_edge 更新答案:
- 拉齐深度。 设
,把 向上跳 条边。把差值按二进制拆分,第 位为 1 就跳 ,跳之前 answer = min(answer, min_edge[x][j])。 - 同时上跳。 若此时
,说明 本来就是祖先,第 1 步的答案即最终答案。否则从高位到低位:只要 up[x][j] != up[y][j]就让两点一起跳,两段的 min_edge都并入答案。这一步结束时都停在 LCA 的儿子处。 - 最后一步。 各自再跳一条父边到达 LCA,这两条父边也是路径的一部分,它们的
min_edge[·][0]必须并入答案。漏掉这一步是最常见的错误——倍增「跳到 LCA 儿子」的写法天然把最后一条边留在了外面。
注意 min_edge[x][j] 维护的是「向上
代码
/**
* 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-06-22 21:38
* update_at: 2026-10-22 12:25
*/
// 货车运输:多次询问无向带权图中两点之间「路径最小边权」的最大值(最大瓶颈路)。
// 做法:Kruskal 按边权从大到小建最大生成森林,把原图瓶颈信息压缩成树上唯一路径,
// 再用倍增 LCA 在向上跳的同时维护路径最小边权,每个询问 O(log n)。
// 图不连通:森林天然分多个连通块,跨块询问输出 -1。
//
// 为什么树路径最小边权 = 原图最大瓶颈值(证明要点):
// 设按权从大到小加边时 x、y 首次连通于权 W,则
// 只用权 >= W 的边已能连通 x、y,故答案 >= W;
// 只用权 > W 的边还不能连通 x、y,故任何 x->y 路径必含权 <= W 的边,答案 <= W。
// 而 Kruskal 结束时 x、y 在同一棵树里,树上路径都是已加入的边(权 >= W),
// 且两端到 LCA 的两段里必有一段含权恰为 W 的关键边,最小值恰为 W。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 10005; // 城市数上界 n <= 1e4
const int MAXM = 50005; // 道路数上界 m <= 5e4
const int LOG = 15; // 2^14 = 16384 > 1e4,够覆盖树深
const int INF = 1000000000;
// 一条无向道路:连接 u 和 v,限重 w。点编号 1..n。
struct Edge {
int u;
int v;
int w;
};
int n, m, q;
Edge edge[MAXM];
// Kruskal 用的并查集
int father[MAXN];
// 最大生成森林(链式前向星存树边,双向)
int head[MAXN], to[MAXM * 2], weight_edge[MAXM * 2], nxt[MAXM * 2], edge_cnt;
int depth_node[MAXN]; // 树上深度,根为 1
int component[MAXN]; // 点所在的连通块编号,跨块询问输出 -1
int up[MAXN][LOG]; // up[x][j]:x 向上跳 2^j 条边到达的祖先,根之上为 0
int min_edge[MAXN][LOG]; // min_edge[x][j]:这段 2^j 条边的路径上最小限重
bool cmp_edge(const Edge &a, const Edge &b) {
return a.w > b.w; // 最大生成森林:从大边开始加
}
int find_root(int x) {
if (father[x] == x) {
return x;
}
father[x] = find_root(father[x]);
return father[x];
}
bool merge_set(int x, int y) {
int rx = find_root(x);
int ry = find_root(y);
if (rx == ry) {
return false; // 已在同一集合,加这条边会成环,不进森林
}
father[rx] = ry;
return true;
}
void add_tree_edge(int u, int v, int w) {
edge_cnt++;
to[edge_cnt] = v;
weight_edge[edge_cnt] = w;
nxt[edge_cnt] = head[u];
head[u] = edge_cnt;
}
void read_input() {
cin >> n >> m;
for (int i = 1; i <= m; i++) {
cin >> edge[i].u >> edge[i].v >> edge[i].w;
}
}
void build_maximum_spanning_forest() {
for (int i = 1; i <= n; i++) {
father[i] = i;
}
sort(edge + 1, edge + m + 1, cmp_edge);
for (int i = 1; i <= m; i++) {
if (merge_set(edge[i].u, edge[i].v)) {
// 这条边连接了两个不同集合,进入最大生成森林
add_tree_edge(edge[i].u, edge[i].v, edge[i].w);
add_tree_edge(edge[i].v, edge[i].u, edge[i].w);
}
}
}
// 从 start 出发 BFS 遍历一个连通块,同时按层填倍增表
void bfs_component(int start, int cid) {
queue<int> que;
que.push(start);
component[start] = cid;
depth_node[start] = 1;
up[start][0] = 0;
min_edge[start][0] = INF; // 根没有父边,填 INF 不影响取 min
while (!que.empty()) {
int u = que.front();
que.pop();
// 出队时填表:此时 u 的 2^(j-1) 级祖先的表已填好
for (int j = 1; j < LOG; j++) {
up[u][j] = up[up[u][j - 1]][j - 1];
min_edge[u][j] = min(min_edge[u][j - 1], min_edge[up[u][j - 1]][j - 1]);
}
for (int i = head[u]; i != 0; i = nxt[i]) {
int v = to[i];
if (v == up[u][0]) {
continue; // 不走回父节点
}
component[v] = cid;
depth_node[v] = depth_node[u] + 1;
up[v][0] = u;
min_edge[v][0] = weight_edge[i];
que.push(v);
}
}
}
void prepare_lca() {
int cid = 0;
for (int i = 1; i <= n; i++) {
if (component[i] == 0) {
cid++;
bfs_component(i, cid); // 每个连通块单独 BFS,根深度为 1
}
}
}
// 询问 x 到 y 的最大载重:同块时是树上路径的最小边权,跨块为 -1
int query(int x, int y) {
if (component[x] != component[y]) {
return -1; // 不在同一连通块,货车无法到达
}
int answer = INF;
if (depth_node[x] < depth_node[y]) {
swap(x, y); // 让 x 是更深的那个
}
// 第一步:把 x 跳到与 y 同深度,沿途取最小边权
int diff = depth_node[x] - depth_node[y];
for (int j = LOG - 1; j >= 0; j--) {
if ((diff & (1 << j)) != 0) {
answer = min(answer, min_edge[x][j]);
x = up[x][j];
}
}
if (x == y) {
return answer; // y 本来就是 x 的祖先
}
// 第二步:两个点一起向上跳,跳到 LCA 的儿子为止
for (int j = LOG - 1; j >= 0; j--) {
if (up[x][j] != up[y][j]) {
answer = min(answer, min_edge[x][j]);
answer = min(answer, min_edge[y][j]);
x = up[x][j];
y = up[y][j];
}
}
// 最后一步:各自跳到 LCA,这两条父边也是路径的一部分
answer = min(answer, min_edge[x][0]);
answer = min(answer, min_edge[y][0]);
return answer;
}
void solve() {
build_maximum_spanning_forest();
prepare_lca();
cin >> q;
for (int i = 1; i <= q; i++) {
int x, y;
cin >> x >> y;
cout << query(x, y) << '\n';
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
solve();
return 0;
}#!/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-10-22 12:26
# update_at: 2026-10-22 12:26
#
# main.py:货车运输。多次询问两点之间「路径最小边权」的最大值(最大瓶颈路)。
# 与 main.cpp 同一算法:Kruskal 建最大生成森林 + 倍增 LCA 维护路径最小边权。
# 图不连通时森林分多个连通块,跨块询问输出 -1。
import sys
from collections import deque
INF = 10**9 # 哨兵:根没有父边、以及题面不会出现的 x == y 询问;z <= 1e5 远小于它
# 树上每个节点存 (父亲, 到父亲的边权, 深度);点编号 1..n
type Node = tuple[int, int, int] # (parent, parent_edge_weight, depth)
def find(parent: list[int], x: int) -> int:
"""找 x 所在集合的根,顺路把路径上的点挂到根上(迭代压缩)。"""
root = x
while parent[root] != root:
root = parent[root]
while parent[x] != root:
parent[x], x = root, parent[x]
return root
def main() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, m = next(data), next(data)
# 只靠路径压缩:单次操作摊还 O(log n),本题 n <= 1e4 已经足够
parent = list(range(n + 1))
edges: list[tuple[int, int, int]] = []
for _ in range(m):
u, v, w = next(data), next(data), next(data)
edges.append((u, v, w))
# ---- Kruskal:按边权从大到小建最大生成森林,树边存成 children 列表 ----
children: list[list[tuple[int, int]]] = [[] for _ in range(n + 1)]
for u, v, w in sorted(edges, key=lambda e: e[2], reverse=True):
ru, rv = find(parent, u), find(parent, v)
if ru != rv:
parent[ru] = rv
children[u].append((v, w))
children[v].append((u, w))
# ---- 每个连通块 BFS,求父亲、到父亲的边权、深度、连通块编号 ----
nodes: list[Node] = [(-1, -1, -1)] * (n + 1) # 根为 (0, INF, 1)
component = [0] * (n + 1) # component[x]:x 所在连通块编号
cid = 0
for start in range(1, n + 1):
if component[start]:
continue
cid += 1
nodes[start] = (0, INF, 1)
component[start] = cid
que = deque([start])
while que:
u = que.popleft()
pu, _, du = nodes[u]
for v, w in children[u]:
if v == pu: # 不走回父节点
continue
nodes[v] = (u, w, du + 1)
component[v] = cid
que.append(v)
# ---- 倍增表:up[j][x] 向上跳 2^j 条边的祖先,mn[j][x] 该段路径最小边权 ----
LOG = max(1, (n).bit_length()) # 2^(LOG-1) >= n,覆盖最大树深
up = [[-1] * (n + 1) for _ in range(LOG)] # 用 -1 表示「根之上」的虚拟点
mn = [[INF] * (n + 1) for _ in range(LOG)]
for x in range(1, n + 1):
up[0][x], mn[0][x] = nodes[x][0], nodes[x][1]
for j in range(1, LOG): # 递推:先跳 2^(j-1) 再跳 2^(j-1)
upj, upj1 = up[j], up[j - 1]
mnj, mnj1 = mn[j], mn[j - 1]
for x in range(1, n + 1):
mid = upj1[x]
if mid < 0:
upj[x], mnj[x] = -1, INF
else:
upj[x] = upj1[mid]
mnj[x] = min(mnj1[x], mnj1[mid])
# ---- 查询:深点先跳齐,再一起跳到 LCA,沿途取最小边权 ----
def query(x: int, y: int) -> int:
if component[x] != component[y]:
return -1 # 不在同一连通块,货车无法到达
answer = INF
px, py = nodes[x], nodes[y]
if px[2] < py[2]: # 让 x 是更深的那个
x, y = y, x
px, py = py, px
# 第一步:把 x 跳到与 y 同深度
diff = px[2] - py[2]
for j in range(LOG - 1, -1, -1):
if diff >> j & 1:
answer = min(answer, mn[j][x])
x = up[j][x]
if x == y:
# y 本来就是 x 的祖先,刚才跳过的段就是全部路径
return answer
# 第二步:两个点一起向上跳到 LCA 的儿子
for j in range(LOG - 1, -1, -1):
if up[j][x] != up[j][y]:
answer = min(answer, mn[j][x], mn[j][y])
x, y = up[j][x], up[j][y]
# 最后各自跳一条父边到 LCA
answer = min(answer, mn[0][x], mn[0][y])
return answer
out = []
q = next(data)
for _ in range(q):
x, y = next(data), next(data)
out.append(str(query(x, y)))
print("\n".join(out))
if __name__ == "__main__":
main()暴力对拍用 Floyd 改造版(把转移从加法换成「取 min 再取 max」),
#include <bits/stdc++.h>
using namespace std;
// brute.cpp:Floyd 求任意两点最大瓶颈路,只适合小数据对拍。
const int MAXN = 55;
int n, m, q;
int best[MAXN][MAXN]; // best[i][j] 表示 i 到 j 能达到的最大路径瓶颈值。
void read_input() {
cin >> n >> m;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
best[i][j] = -1;
}
best[i][i] = 1000000000;
}
for (int i = 1; i <= m; i++) {
int u, v, w;
cin >> u >> v >> w;
best[u][v] = max(best[u][v], w);
best[v][u] = max(best[v][u], w);
}
}
void floyd() {
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (best[i][k] == -1 || best[k][j] == -1) {
continue;
}
int value = min(best[i][k], best[k][j]);
if (value > best[i][j]) {
best[i][j] = value;
}
}
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
floyd();
cin >> q;
for (int i = 1; i <= q; i++) {
int x, y;
cin >> x >> y;
cout << best[x][y] << '\n';
}
return 0;
}复杂度
| 阶段 | 时间 | 空间 |
|---|---|---|
| 排序边表 | ||
| Kruskal 建最大生成森林 | ||
| BFS + 倍增表预处理 | ||
| 单次询问 | — | |
| 总计 |
代入本题规模(
总结
- 「路径最小边权的最大值」是最大瓶颈路(maximin),与最短路(最小化边权和)目标不同,不能用 Dijkstra/Floyd 的加法框架。
- 核心性质:最大生成树路径上的最小边权 = 点对最大瓶颈值。证明用 Kruskal 阈值语言:按权从大到小放开边,两点首次连通时的阈值
既是「存在全程 路径」的下界,又是「任何路径必含 边」的上界。方向是最大生成树,选最小生成树会把阈值方向弄反。 - 图不连通时建最大生成森林,每个连通块的正确性独立成立;跨块询问不存在路径,输出
。 - 询问多的时候不要每次在原图上找路:先用最大生成森林把全部瓶颈信息压缩到树上,把点对查询变成树上路径查询,再用倍增 LCA 的
up[x][j]+min_edge[x][j]双表回答。上跳合并信息依赖 min 的结合律,最后一条父边别忘并入。 - 与 P1396 对读:一个是最小化路径最大边(minimin 阈值从小放开),一个是最大化路径最小边(maximax 阈值从大放开),骨架同为「阈值连通性 + Kruskal」,是同一族问题的两个方向。