度限制最小生成树:缩掉 Park 跑 Kruskal,每块接最便宜公园边,再用剩余车位做「加公园边、删路径最重非公园边」的最优交换。
OJ: roj
题目 ID: 3186
难度:提高
标签:图论最小生成树度限制最小生成树Kruskal并查集python
创建: 2026-10-02 00:00
更新: 2026-10-10 13:30
形式化题目
给一张无向带权图:节点是若干人(不超过 20 人)和公园 Park,每条边有长度 Park。
一辆车的行驶过程是:从某个人的家出发,沿边行驶,途中可以经过其他人的家并把他们全部收上车(车容量无限),最终开进 Park。车一旦进入 Park 就停在那里,不能再开出接人。Park 最多停
所有人都要到达 Park。求所有汽车行驶路程总和的最小值。
正解
思路
第一步:把问题翻译成生成树
「车一旦进公园就必须停下」意味着:每辆车最终对应一条从某人的家到 Park 的路径,车上所有人挤在一起沿这条路径走。把每个人看作树上的一个点、每条被走过的道路看作一条树边,则:
- 一个合法方案恰好对应一棵包含
Park的生成树; - 总里程 = 树上所有边的权值和;
- 停车数 = 树上与
Park相连的边数(每条公园边都是一辆车开进公园的"最后一公里")。
于是题目变成经典模型:求包含 Park 的生成树中边权和最小、且 Park 的度数
第二步:先不管限制,缩掉公园跑 MST
先看 Park 和它的所有关联边删掉,剩下的图会被分成若干连通块。观察两点:
- 每个连通块内部:块内所有人怎么互相连接,与公园无关,直接对人—人边跑 Kruskal 求最小生成森林即可;
- 每个连通块必须有一条边接入
Park(否则块里的人到不了公园),而且只需一条:车先开到块内某个人的家,沿块内的生成树边绕一圈把所有人收齐,再从任意一点开进公园。所以每个块取最便宜的那条公园边接入。
此时设原图(去掉 Park 后)有 Park 度数恰为
对样例(Park 后对人—人边跑 Kruskal,按权依次选中 Bernardo-Park 19。得到度数
graph LR
P((Park)) ---|19| B((Bernardo))
B ---|32| A((Alphonzo))
A ---|43| E((Eduardo))
E ---|79| H((Herb))
B ---|82| C((Clemenzi))
上图是第二步结束时的形状:Park 度数为 Bernardo 的车从自己家出发,沿 Alphonzo、Eduardo、Herb,再沿 Clemenzi,最后开进公园(
第三步:用剩余车位做「交换」
若
- 往树里加一条公园边
,权 ——树上出现一个环; - 删掉
树上路径中最重的一条非公园边 ,恢复成树。
总里程变化
为什么逐次交换就是最优的?这是度限制最小生成树的经典交换引理:若
两个实现细节:
- 找"路径最重的非公园边"时不比较公园边——删掉公园边不会让公园度数加一,起不到多停一辆车的作用;
自己就是所在块"接入公园的那个人"(单人块)时,路径上没有非公园边可删,这种交换只会让同一辆车翻倍占两个车位,直接跳过。
继续样例:
- 试
Herb-Park 24:树上路径中最重非公园边是 , ,总里程 ; - 再试
Clemenzi-Park 65:此时路径中最重非公园边是 , ,总里程 。
剩下的交换(如 Eduardo-Park 57 换
代码
/**
* 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-10 13:30
* update_at: 2026-10-10 13:30
*/
// 野餐规划:度限制最小生成树。缩掉 Park 跑 Kruskal,每块接最便宜的公园边,
// 再用剩余车位反复做「加公园边、删路径最重非公园边」的最优交换。
#include <algorithm>
#include <cstdio>
#include <map>
#include <string>
#include <vector>
using namespace std;
typedef long long ll;
const int PARK = 0; // 公园固定编号为 0,人按出现顺序从 1 开始
const int MAXN = 105; // 人数不超过 20,节点总数留足余量
struct Edge { // 一条边:(权值, 端点 a, 端点 b)
int w, a, b;
};
struct Node { // 树上路径扫描的栈元素
int u, parent, wmx, eidx;
};
map<string, int> nameId; // 名字 -> 编号
int fa[MAXN]; // 并查集
int nodeCnt; // 节点总数(含公园)
int chW[MAXN]; // 每个连通块用的最便宜公园边权
int chV[MAXN]; // 该公园边接的人
bool hasCh[MAXN]; // 该连通块是否已经接入
vector<int> roots; // 连通块根的首次出现顺序
vector<Edge> roadE; // 人—人边
vector<Edge> linkE; // 人—公园边,存在 (w, 人, PARK)
vector<Edge> treeE; // 当前生成树的边
vector<vector<int> > adj; // 邻接表,存 treeE 的下标
vector<Node> stk;
int findRoot(int x) { // 并查集查找(路径减半)
while (fa[x] != x) {
fa[x] = fa[fa[x]];
x = fa[x];
}
return x;
}
int ident(const string &name) { // 名字 -> 编号,新名字按出现顺序编号
map<string, int>::iterator it = nameId.find(name);
if (it != nameId.end()) return it->second;
int id = nameId.size();
nameId[name] = id;
return id;
}
bool cmpEdge(const Edge &x, const Edge &y) { // 按 (权, a, b) 字典序
if (x.w != y.w) return x.w < y.w;
if (x.a != y.a) return x.a < y.a;
return x.b < y.b;
}
int worstOnPath(int src) { // src 到公园的树上路径中最重的非公园边,返回下标;没有则 -1
adj.assign(nodeCnt, vector<int>());
for (int i = 0; i < (int)treeE.size(); ++i) {
adj[treeE[i].a].push_back(i);
adj[treeE[i].b].push_back(i);
}
stk.clear();
Node s;
s.u = src; s.parent = PARK; s.wmx = -1; s.eidx = -1;
stk.push_back(s);
while (!stk.empty()) {
Node cur = stk.back();
stk.pop_back();
for (int k = 0; k < (int)adj[cur.u].size(); ++k) {
int idx = adj[cur.u][k];
const Edge &e = treeE[idx];
int v = (e.a == cur.u) ? e.b : e.a;
if (v == cur.parent) continue;
if (v == PARK) return cur.eidx; // 树上路径唯一,碰到公园即收工
Node nx;
nx.u = v;
nx.parent = cur.u;
if (e.w > cur.wmx) { nx.wmx = e.w; nx.eidx = idx; }
else { nx.wmx = cur.wmx; nx.eidx = cur.eidx; }
stk.push_back(nx);
}
}
return -1;
}
int main() {
int n;
if (scanf("%d", &n) != 1) return 0; // 空输入安全返回
nameId["Park"] = PARK;
char s1[64], s2[64];
int w;
for (int i = 0; i < n; ++i) {
if (scanf("%63s %63s %d", s1, s2, &w) != 3) break;
int a = ident(string(s1)), b = ident(string(s2));
if (a == PARK && b == PARK) continue; // 公园—公园的边没有意义
Edge e;
e.w = w;
if (a == PARK || b == PARK) { // 人—公园边
e.a = (a == PARK) ? b : a;
e.b = PARK;
linkE.push_back(e);
} else { // 人—人边
e.a = a;
e.b = b;
roadE.push_back(e);
}
}
int s;
if (scanf("%d", &s) != 1) s = 0;
nodeCnt = nameId.size();
for (int i = 0; i < nodeCnt; ++i) fa[i] = i;
// 第一步:只用人—人边跑 Kruskal 得最小生成森林
sort(roadE.begin(), roadE.end(), cmpEdge);
for (int i = 0; i < (int)roadE.size(); ++i) {
int ra = findRoot(roadE[i].a), rb = findRoot(roadE[i].b);
if (ra != rb) {
fa[ra] = rb;
treeE.push_back(roadE[i]);
}
}
// 每个连通块用最便宜的公园边接入公园,此时公园度数 = 连通块数 k
for (int i = 0; i < (int)linkE.size(); ++i) {
int root = findRoot(linkE[i].a);
if (!hasCh[root]) {
hasCh[root] = true;
chW[root] = linkE[i].w;
chV[root] = linkE[i].a;
roots.push_back(root);
} else if (linkE[i].w < chW[root]) {
chW[root] = linkE[i].w;
chV[root] = linkE[i].a;
}
}
for (int i = 0; i < (int)roots.size(); ++i) {
Edge e;
e.w = chW[roots[i]];
e.a = chV[roots[i]];
e.b = PARK;
treeE.push_back(e);
}
int k = roots.size();
// 第二步:还有空车位时,反复做「加一条公园边、删路径上最重的非公园边」的交换
for (int it = 0; it < s - k; ++it) {
bool has = false;
int bestGain = 0, bw = 0, bv = 0, bdrop = -1;
for (int i = 0; i < (int)linkE.size(); ++i) {
int drop = worstOnPath(linkE[i].a);
if (drop < 0) continue; // 单人块没有内部边可删,加边只会翻倍停车
int gain = linkE[i].w - treeE[drop].w;
if (gain < 0 && (!has || gain < bestGain)) {
has = true;
bestGain = gain;
bw = linkE[i].w;
bv = linkE[i].a;
bdrop = drop;
}
}
if (!has) break; // 剩下的交换都会变贵,提前收手
treeE.erase(treeE.begin() + bdrop);
Edge e;
e.w = bw;
e.a = bv;
e.b = PARK;
treeE.push_back(e);
}
ll total = 0;
for (int i = 0; i < (int)treeE.size(); ++i) total += treeE[i].w;
printf("Total miles driven: %lld\n", total);
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-02 00:00
# update_at: 2026-10-02 00:00
import sys
from collections import defaultdict
PARK = 0 # 公园节点编号,人按出现顺序从 1 开始编号
def find(fa: list[int], x: int) -> int:
"""并查集查找(路径压缩),返回 x 所在连通块的根。"""
while fa[x] != x:
fa[x] = fa[fa[x]]
x = fa[x]
return x
def kruskal(edges: list[tuple[int, int, int]], m: int) -> tuple[list[tuple[int, int, int]], list[int]]:
"""只用人—人边跑最小生成森林,返回 (选中的边, 并查集)。"""
fa = list(range(m))
tree = []
for w, a, b in sorted(edges):
ra, rb = find(fa, a), find(fa, b)
if ra != rb:
fa[ra] = rb
tree.append((w, a, b))
return tree, fa
def worst_on_path(tree: list[tuple[int, int, int]], src: int) -> tuple[int, int, int] | None:
"""src 到公园的树上路径中,最重的「非公园边」;路径上没有(单点块)则 None。
返回边本身而不是权值,调用方要把它从树上删掉。最后一段公园边不参与比较:
删公园边不会让公园度数 +1,起不到「多停一辆车」的作用。
"""
adj = defaultdict(list)
for e in tree:
w, a, b = e
adj[a].append((b, w, e))
adj[b].append((a, w, e))
stack = [(src, PARK, -1, None)] # (点, 来时的点, 路径最重非公园边权, 对应边)
while stack:
u, fa, w_mx, e_mx = stack.pop()
for v, w, e in adj[u]:
if v == fa:
continue
if v == PARK: # 树上路径唯一,碰到公园即可收工
return e_mx
stack.append((v, u, max(w_mx, w), e if w > w_mx else e_mx))
return None
def solve() -> None:
it = iter(sys.stdin.read().split())
n = int(next(it))
name_id: dict[str, int] = {"Park": PARK}
def ident(name: str) -> int:
"""名字 → 编号:公园固定为 PARK,其他人按出现顺序编号。"""
if name not in name_id:
name_id[name] = len(name_id)
return name_id[name]
road, link = [], [] # 人—人边 (w,a,b);人—公园边 (w,人)
for _ in range(n):
a, b, w = ident(next(it)), ident(next(it)), int(next(it))
if a == PARK and b == PARK:
continue # 公园—公园的边没有意义
if a == PARK or b == PARK:
link.append((w, b if a == PARK else a))
else:
road.append((w, a, b))
s = int(next(it))
tree, fa = kruskal(road, len(name_id))
# 第一步:每个连通块用它最便宜的公园边接入公园,此时公园度数 = 连通块数 k
cheapest: dict[int, tuple[int, int]] = {} # 块根 -> (权, 人)
for w, v in link:
root = find(fa, v)
if root not in cheapest or w < cheapest[root][0]:
cheapest[root] = (w, v)
for w, v in cheapest.values():
tree.append((w, v, PARK))
k = len(cheapest)
# 第二步:还有空车位时,反复做「加一条公园边、删路径上最重的非公园边」的交换
for _ in range(s - k):
pick = None # (净收益 w-删边权, 公园边权, 人, 要删的边)
for w, v in link:
drop = worst_on_path(tree, v)
if drop is None: # 单人块没有内部边可删,加边只会翻倍停车
continue
gain = w - drop[0]
if gain < 0 and (pick is None or gain < pick[0]):
pick = (gain, w, v, drop)
if pick is None: # 剩下的交换都会变贵,提前收手
break
_, w, v, drop = pick
tree.remove(drop)
tree.append((w, v, PARK))
print(f"Total miles driven: {sum(w for w, _, _ in tree)}")
if __name__ == "__main__":
solve()复杂度
设人数为
总结
- 「车进公园即停 + 容量无限」把方案压缩成一棵包含
Park的生成树,停车数就是Park的度数,本题即度限制最小生成树。 - 三段式解法:① 删掉
Park,对人—人边跑 Kruskal 得生成森林;② 每个连通块用最便宜的公园边接入,得到度数最小的最优树;③ 剩余车位用来反复做「加公园边、删路径最重非公园边」的最优交换,每次净收益才执行。 - 交换的正确性依赖经典交换引理:度数为
的最优树总能从度数为 的最优树经一次最优交换得到。 - 实现细节:多重边交给排序;路径上只删非公园边;单人块没有可删边,跳过;没有负收益交换就提前结束(不要求停满)。
图示解析
一次「交换」长什么样
下图展示样例的第一次交换:加入 Herb-Park 24,删去路径 Eduardo-Herb 79。
交换前(度数 1,总里程 255): 交换后(度数 2,总里程 200):
Park Park
| / \
Bernardo 19/ \24(加)
/ \ / \
32 82 Bernardo Herb
/ \ / \ |
Alphonzo Clemenzi 32 82 (79 已删)
| | / \
43 | Alphonzo Clemenzi
| | |
Eduardo ---------+ 43
| |
79(删) Eduardo
| |
Herb Herb(改从 Park 直接进园)观察重点:交换前 Herb 挂在树上,去公园要走 Herb-Park 24 后,环上 Clemenzi-Park 65 换