两遍 Kruskal:第一遍先加水泥路求鹅卵石路下界 need 并标记被迫边,第二遍放回被迫边、补鹅卵石路到 k 条、水泥路填满,得恰好含 k 条免费路的生成树。
OJ: luogu
题目 ID: P3623
难度:普及+/提高-
标签:最小生成树并查集Kruskal图论构造
创建: 2026-10-22 12:18
更新: 2026-10-21 00:00
形式化题目
给定 no solution。
这是一个带数量约束的生成树问题:不是求权和最小,而是要求生成树里某一类边的条数精确等于
暴力解法
思路
最直接的做法:枚举 dfs(dep) 决定第 dep 条边、check() 在叶子统一验证的 01 序列套路。
代码
/**
* 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-21 00:00
* update_at: 2026-10-21 00:00
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 01 序列枚举每条道路"免费 / 收费":dfs(dep) 决定第 dep 条道路选不选,
// 到叶子统一检查三件事:恰好 n-1 条、整图连通、鹅卵石路恰好 k 条。
// 复杂度 O(2^m * (n+m)),只适合 m <= 20 左右的小数据。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 25; // 小数据村庄数上限
const int MAXM = 25; // 小数据道路数上限
int n, m, k;
int eu[MAXM]; // eu[i]:第 i 条道路的端点 u
int ev[MAXM]; // ev[i]:第 i 条道路的端点 v
int ec[MAXM]; // ec[i]:第 i 条道路类型,0 鹅卵石 1 水泥
int choose_[MAXM]; // choose_[i]:第 i 条道路选(1)/不选(0)
int parent[MAXN]; // check 里用的并查集
// 小工具:并查集找根(路径压缩)
int find_root(int x) {
if (parent[x] == x) return x;
return parent[x] = find_root(parent[x]);
}
// 检查一个完整的选边方案:n-1 条、连通、鹅卵石路恰 k 条。
bool check() {
int total = 0, cobble = 0;
for (int i = 1; i <= m; i++)
if (choose_[i]) {
total++;
if (ec[i] == 0) cobble++;
}
if (total != n - 1 || cobble != k) return false; // 不是生成树或鹅卵石数不对
for (int i = 1; i <= n; i++) parent[i] = i;
for (int i = 1; i <= m; i++)
if (choose_[i]) {
int ru = find_root(eu[i]);
int rv = find_root(ev[i]);
if (ru != rv) parent[ru] = rv; // 合并;若有环 total 已不对,无需再查
}
// n-1 条边 + 无环 等价于 生成树;这里直接数连通块兜底
int blocks = 0;
for (int i = 1; i <= n; i++)
if (find_root(i) == i) blocks++;
return blocks == 1;
}
// 01 序列递归:这一层在决定第 dep 条道路选不选。
bool dfs(int dep) {
if (dep > m) return check(); // 完整方案已生成,统一检查
choose_[dep] = 0; // 不选第 dep 条
if (dfs(dep + 1)) return true;
choose_[dep] = 1; // 选第 dep 条
return dfs(dep + 1);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> k;
for (int i = 1; i <= m; i++) cin >> eu[i] >> ev[i] >> ec[i];
if (!dfs(1))
cout << "no solution" << "\n";
else {
for (int i = 1; i <= m; i++)
if (choose_[i]) cout << eu[i] << " " << ev[i] << " " << ec[i] << "\n";
}
return 0;
}复杂度
瓶颈
生成树的数量是指数级的,逐个枚举无法覆盖真实规模。问题出在"选哪
正解
思路
第一步:求鹅卵石路数的下界 need
第一遍 Kruskal:先加全部水泥路,能用则用;然后剩下的连通块之间已经没有水泥路可走(否则早就合并了),只能靠鹅卵石路桥接。补进来的桥接边条数记为 need,并且把每条这样的鹅卵石路标记为被迫边(forced)。
为什么这给出了真正的下界?把水泥连通块各自收缩成一个点,收缩图是连通的(否则整个图不连通),且它的每条边都对应原图的一条鹅卵石路。任何生成树在收缩图上的像也是生成树,必须横跨所有块间连接——而这些连接全是鹅卵石路,所以任何生成树都至少含 need 条鹅卵石路。同时这也说明:
- 图不连通(
水泥边数 + need ≠ N-1)⇒ 无解; need > K⇒ 无解(下界已经超过要求)。
反过来,need ≤ K 时并不是直接有解——还要看上界。
第二步:从下界出发,恰好凑到 K 条
第二遍 Kruskal 分三段:
- 放回被迫边:
forced集(need条)本身是森林——第一遍里每一条都真正合并了两个不同集合——所以重新放回必然全部成功; - 凑鹅卵石路:从其余鹅卵石路里,按输入顺序挑"能继续扩大森林"的边,直到凑满
条; - 水泥路填满:用水泥路把森林补成一棵完整的生成树。
关键在两处:
- 凑不满
条 ⇒ 无解。第 2 段是"只把鹅卵石路当可用边做 Kruskal",它能选入的最大条数 cap就是所有生成树中鹅卵石路边数的上界:任何生成树的鹅卵石边子集自身是森林,而只考虑鹅卵石路的 Kruskal 得到的就是鹅卵石路森林的最大边数。cap < K⇒ 任何生成树都放不下条鹅卵石路。 - 水泥路一定能填满,且总数恰好
。第一遍那棵生成树 由 水泥边 + forced 鹅卵石边组成;第二遍已选的条鹅卵石路 ,于是 全部是水泥边。第 3 段对全部水泥边做 Kruskal, 里的每条水泥边要么被选入、要么因两端已被 中更早的鹅卵石路连通而成为环边(此时 已选水泥边已覆盖了它的连接作用)。两种情况最终都连通,且因为生成树只有 条边,水泥阶段恰好补进 条,鹅卵石路恰好 条。
为什么第二遍必须先放回被迫边
如果跳过被迫边、直接贪心选鹅卵石路的前缀,选中的鹅卵石边可能把水泥路"堵死":某条鹅卵石边连接了两个本可由水泥路连通的块,占掉树上的位置后,可能没有足够的水泥边去连接真正必须用鹅卵石路连接的块。先放回被迫边,就锁定了"必须用鹅卵石路"的连接,剩下的
样例推演
官方样例(need = 1,边 1 被标记 forced。第二遍放回边 1,再选鹅卵石路 main.cpp、main.py、brute.cpp)输出的是三种不同但都合法的方案——这正是本题答案不唯一的体现。
代码
/**
* 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-21 00:00
* update_at: 2026-10-21 00:00
*/
// P3623 [APIO2008] 免费道路:两遍 Kruskal 构造一棵恰好含 k 条鹅卵石路的生成树。
#include <bits/stdc++.h>
using namespace std;
// 一条无向道路:连接村庄 u、v;c = 0 是鹅卵石路(免费),c = 1 是水泥路。
struct Edge {
int u;
int v;
int c;
};
const int MAXN = 2e4 + 5; // 村庄数上限
const int MAXM = 1e5 + 5; // 道路数上限
int n, m, k;
Edge edges[MAXM]; // edges[i]:第 i 条道路,编号与输入一致(1..m)
// 并查集:路径压缩 + 按大小合并,风格对齐 rbook 的 Kruskal 模板。
int dsu_parent[MAXN]; // dsu_parent[x]:x 的父节点,x 是根时等于 x
int dsu_size[MAXN]; // dsu_size[x]:x 当根时所在集合的大小
// 找 x 所在集合的根,顺路把路径上的点直接挂到根上(路径压缩)。
int dsu_find(int x) {
if (dsu_parent[x] == x) return x;
dsu_parent[x] = dsu_find(dsu_parent[x]);
return dsu_parent[x];
}
// 合并 a、b 所在的两个集合;两端本来就连通(这条边成环)时返回 false。
bool dsu_merge(int a, int b) {
int root_a = dsu_find(a);
int root_b = dsu_find(b);
if (root_a == root_b) return false;
// 小集合接到大集合下面,配合路径压缩,单次操作接近常数。
if (dsu_size[root_a] < dsu_size[root_b]) swap(root_a, root_b);
dsu_parent[root_b] = root_a;
dsu_size[root_a] += dsu_size[root_b];
return true;
}
void dsu_reset() {
for (int i = 1; i <= n; i++) {
dsu_parent[i] = i;
dsu_size[i] = 1;
}
}
bool forced[MAXM]; // forced[i]:第一遍里"不得不选"的鹅卵石路(桥接水泥连通块)
int plan[MAXM]; // 最终方案的边编号
int plan_cnt; // 方案里的边数,一棵生成树正好 n - 1 条
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m >> k;
for (int i = 1; i <= m; i++) cin >> edges[i].u >> edges[i].v >> edges[i].c;
// ---------- 第一遍 Kruskal:先加全部水泥路,再补鹅卵石路 ----------
// 目的:求出"任何生成树都至少需要"的鹅卵石路条数 need。
// 只用水泥路先连通能连通的局部,剩下的连通块之间没有水泥路可走,
// 只能靠鹅卵石路桥接,所以这一遍补进来的鹅卵石路条数就是下界 need。
dsu_reset();
int cement_cnt = 0; // 第一遍选中的水泥路条数
for (int i = 1; i <= m; i++)
if (edges[i].c == 1 && dsu_merge(edges[i].u, edges[i].v)) cement_cnt++;
int need = 0; // 任何生成树至少要用的鹅卵石路条数
for (int i = 1; i <= m; i++)
if (edges[i].c == 0 && dsu_merge(edges[i].u, edges[i].v)) {
forced[i] = true; // 桥接水泥连通块,"不得不选"
need++;
}
// 图本身不连通(拼不成 n-1 条),或者最少的鹅卵石路都超过 k,都无解。
if (cement_cnt + need != n - 1 || need > k) {
cout << "no solution" << "\n";
return 0;
}
// ---------- 第二遍 Kruskal:精确凑够 k 条鹅卵石路 ----------
// 先放回第一遍"不得不选"的鹅卵石路(need 条);
// 再从其余鹅卵石路里挑还能扩大森林的边,直到凑满 k 条;
// 最后用水泥路补满生成树。
dsu_reset();
plan_cnt = 0;
int cobble_cnt = 0; // 方案里已有的鹅卵石路条数
for (int i = 1; i <= m; i++)
if (forced[i]) {
dsu_merge(edges[i].u, edges[i].v); // forced 是森林,必定合并成功
plan[++plan_cnt] = i;
cobble_cnt++;
}
for (int i = 1; i <= m && cobble_cnt < k; i++)
if (edges[i].c == 0 && !forced[i] && dsu_merge(edges[i].u, edges[i].v)) {
plan[++plan_cnt] = i;
cobble_cnt++;
}
if (cobble_cnt < k) { // 凑不满 k 条:k 超过鹅卵石路在生成树里的上限
cout << "no solution" << "\n";
return 0;
}
// 水泥路补满:forced 集合 + 全部水泥路已经包含第一遍的那棵生成树,
// 所以这一步结束后一定连通,恰好再补进 n-1-k 条水泥路。
for (int i = 1; i <= m; i++)
if (edges[i].c == 1 && dsu_merge(edges[i].u, edges[i].v)) plan[++plan_cnt] = i;
sort(plan + 1, plan + plan_cnt + 1); // 按输入顺序输出,便于对照
for (int j = 1; j <= plan_cnt; j++) {
Edge &e = edges[plan[j]];
cout << e.u << " " << e.v << " " << e.c << "\n";
}
return 0;
}Python 版与 C++ 逐阶段对应:Dsu 类封装 find/merge,第一遍求 need 并标记 forced,第二遍三段式凑方案。
#!/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-21 00:00
# update_at: 2026-10-21 00:00
import sys
class Dsu:
"""并查集:路径压缩 + 按大小合并,对应 C++ 版的 dsu_find / dsu_merge。"""
def __init__(self, n: int) -> None:
self.parent = list(range(n + 1)) # parent[x]:x 的父节点,x 是根时等于 x
self.size = [1] * (n + 1) # size[x]:x 当根时所在集合的大小
def find(self, x: int) -> int:
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # 路径减半
x = self.parent[x]
return x
def merge(self, a: int, b: int) -> bool:
"""合并 a、b 所在集合;两端已连通(这条边成环)返回 False。"""
root_a, root_b = self.find(a), self.find(b)
if root_a == root_b:
return False
if self.size[root_a] < self.size[root_b]: # 小的接到大的下面
root_a, root_b = root_b, root_a
self.parent[root_b] = root_a
self.size[root_a] += self.size[root_b]
return True
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, m, k = next(data), next(data), next(data)
# edges[i]:第 i 条道路 (u, v),c = 0 鹅卵石路,c = 1 水泥路
edges = [(next(data), next(data), next(data)) for _ in range(m)]
# ---------- 第一遍 Kruskal:先加全部水泥路,再补鹅卵石路 ----------
# 求出"任何生成树至少需要"的鹅卵石路条数 need,
# 同时标记桥接水泥连通块的"不得不选"鹅卵石路 forced。
dsu = Dsu(n)
cement_cnt = sum(dsu.merge(u, v) for u, v, c in edges if c == 1)
forced = [False] * (m + 1)
need = 0
for i, (u, v, c) in enumerate(edges, 1):
if c == 0 and dsu.merge(u, v):
forced[i] = True
need += 1
# 图不连通,或最少的鹅卵石路都超过 k,都无解。
if cement_cnt + need != n - 1 or need > k:
print("no solution")
return
# ---------- 第二遍 Kruskal:精确凑够 k 条鹅卵石路 ----------
# 先放回 forced 的鹅卵石路,再挑其余鹅卵石路补到 k 条,最后水泥路填满。
dsu = Dsu(n)
plan: list[int] = [] # 方案的边编号,恰好 n - 1 条
for i, (u, v, c) in enumerate(edges, 1):
if forced[i]:
dsu.merge(u, v) # forced 是森林,必定合并成功
plan.append(i)
cobble_cnt = len(plan)
for i, (u, v, c) in enumerate(edges, 1):
if cobble_cnt >= k:
break
if c == 0 and not forced[i] and dsu.merge(u, v):
plan.append(i)
cobble_cnt += 1
if cobble_cnt < k: # 凑不满 k 条:鹅卵石路在生成树里放不下这么多
print("no solution")
return
for i, (u, v, c) in enumerate(edges, 1):
if c == 1 and dsu.merge(u, v):
plan.append(i)
plan.sort() # 按输入顺序输出,便于对照
out = [f"{edges[i - 1][0]} {edges[i - 1][1]} {edges[i - 1][2]}" for i in plan]
print("\n".join(out))
if __name__ == "__main__":
solve()复杂度
两遍 Kruskal 共
总结
- 本题是"带数量约束的生成树":不排序、不求权值和,只要求某类边恰好
条。解法是把双向约束拆开——下界用"先水泥后鹅卵石"的 Kruskal 求出 need,上界用"被迫边 + 鹅卵石路贪心扩森林"验证cap。 - 无解当且仅当三个条件之一:图不连通、
need > K、cap < K。每个条件都独立蕴含"不存在满足要求的生成树",合起来给出完整的判定。 - 第二遍先放回被迫边是正确性的核心:它锁定必须用鹅卵石路的连接,剩余位置自由分配,水泥路必然能填满。
- 实现上两遍都是普通 Kruskal 变体,DSU 完全复用;
brute.cpp的 01 序列枚举只服务小数据对拍与题意理解。
验证记录
- 官方样例
in1:main.cpp、main.py、brute.cpp均通过独立校验(边存在、条、连通、鹅卵石恰 条)。 - 对拍:
gen.py生成 320 组小图(,0/1 混合边, ,其中有解 116 组、无解 204 组,无解占 63.8%), main.cpp与main.py的输出全部通过独立合法性校验器,有解/无解判定与brute.cpp100% 一致(320/320)。 - 规模测试:
、 、 的随机连通图,C++ 0.037s / Python 0.229s,输出均为恰含 9000 条鹅卵石路的生成树。
