按编号前缀删点后最大连通块不超过 floor(n/2):删 1..k 等价于只保留后缀,倒序加点用并查集维护集团大小,首个超过 n/2 的集团所在轮次即最小 k,复杂度 O((n+m)log n)。
OJ: roj
题目 ID: 1386
难度:普及+/提高-
标签:图论并查集连通块倒序加点python
创建: 2026-09-30 08:07
更新: 2026-10-13 22:33
形式化题目
给定一张
求最小的
样例是一张 7 个点的图,
graph G {
layout=neato;
node [shape=circle, fontname="Helvetica"];
1 -- 2;
1 -- 5;
2 -- 3;
2 -- 4;
3 -- 4;
5 -- 6;
5 -- 7;
6 -- 7;
}
观察这张图:团伙
正解
思路
从朴素做法出发。 最直接的写法是枚举
瓶颈在哪。 相邻两次检验之间只差了一个被删掉的点,但每次都要把绝大部分边重新合并一遍。 之所以不能"在原有基础上删点",是因为并查集只支持合并、不支持分裂——删掉一个点会把一个连通块拆开, 这正是并查集的短板。既然"删"不好做,就把过程反过来做"加"。
关键观察。 两个性质把问题彻底简化:
- 单调性:
变大意味着删除的点更多、保留的点更少,剩余子图是原剩余子图的子图。 删点只会让连通块分裂或缩小,所以 关于 单调不增。 于是" "一旦对某个 成立,对更大的 都成立;答案就是"首次满足"的那个 。 - 倒序等价:删除前缀
的过程,与从空图开始倒序添加后缀 互为逆过程。
把这两点合起来,就得到本文的做法:让
为什么首触发轮次
一个容易踩的坑:只需检查 sz[dsu.find(i)](点 sz[dsu.find(i)] > n // 2 与
用样例走一遍倒序过程(
| 本轮加入 | 集合 |
最大集团 | 超阈值? |
|---|---|---|---|
| 7 | 1 | 1 | 否 |
| 6 | 2 | 2 | 否 |
| 5 | 3 | 3 | 否 |
| 4 | 3, 1 | 3 | 否 |
| 3 | 3, 2 | 3 | 否 |
| 2 | 3, 3 | 3 | 否 |
| 1 | 7 | 7 | 是 → 答案 |
前六轮里
实现上的三个要点。
- 邻接表只存大编号邻居:倒序加入点
时,编号更小的点还没进来,因此 与 之间的边 会在处理 时被处理。每条无向边只存一次,既省空间又保证每条边只被访问一次。 - 按大小合并 + 路径压缩:
sz只在根上有效。unite会按大小换根,所以本轮合并完 不能沿用合并前取出的根,必须重新find(i)再读sz[root],才能读到本轮累计值。 两端根相同时unite返回False,顺带挡住重边造成的重复累加。 - 读入一次切分:输入规模
,用 sys.stdin.buffer.read().split()一次读完再迭代, 避免逐行input()的开销。
代码
Python 版:
#!/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-09-30 08:10
# update_at: 2026-10-13 22:28
import sys
class DSU:
"""并查集 (Disjoint Set Union)"""
def __init__(self, n: int):
# 1-indexed, fa 初始指向自己,sz 初始为 1
self.fa = list(range(n + 1))
self.sz = [1] * (n + 1)
def find(self, x: int) -> int:
"""查找根节点,两趟路径压缩:先走到根,再把整条路径挂到根上"""
root = x
while self.fa[root] != root:
root = self.fa[root]
while self.fa[x] != root:
self.fa[x], x = root, self.fa[x]
return root
def same(self, x: int, y: int) -> bool:
"""判断 x 和 y 是否属于同一个集合"""
return self.find(x) == self.find(y)
def unite(self, x: int, y: int) -> bool:
"""按大小合并两个集合 (小树挂大树);已在同一集合返回 False"""
fx, fy = self.find(x), self.find(y)
if fx == fy:
return False
if self.sz[fx] < self.sz[fy]:
fx, fy = fy, fx
self.fa[fy] = fx
self.sz[fx] += self.sz[fy]
return True
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
# 团伙只可能和编号更大的团伙连通才影响"删掉 1..k"之后的答案,
# 所以邻接表只保留大编号邻居,无向图的一半就够用。
graph: list[list[int]] = [[] for _ in range(n + 1)]
for i in range(1, n + 1):
degree = next(data)
graph[i] = [j for j in (next(data) for _ in range(degree)) if j > i]
dsu = DSU(n) # sz 只在根上有效:集团内的团伙数
# 倒序加入:第 i 轮结束后集合 {i, i+1, ..., n} 的连通块,
# 恰好等于"删掉 1..i-1 后剩下来的集团"。首次出现超过一半的集团时即答案。
for i in range(n, 0, -1):
for j in graph[i]:
dsu.unite(i, j)
# unite 按大小合并会换根,所以这里必须重新 find 一次拿合并后的根
if dsu.sz[dsu.find(i)] > n // 2:
print(i)
return
print(n) # 整图连通时上面必然已经返回;这里兜住非连通输入,删光才算达标
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* 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-05 00:27
* update_at: 2026-10-13 22:33
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// 并查集竞赛精简版(不用 struct):路径压缩 + 按大小合并
// 口诀:find 找根压到底,unite 小树挂大树
// 易错点:init 别只写 fa[i] = i,sz[i] 也要置 1;合并后 sz 只在新根上有效
// 和别的模板一起用时 maxn 只留一份
const int maxn = 1005;
int fa[maxn], sz[maxn]; // sz[x] 仅在根上有效
void init(int n) {
for (int i = 1; i <= n; i++) fa[i] = i, sz[i] = 1;
}
// 查 x 的根,顺手把整条链压到根上
int find(int x) {
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
bool same(int x, int y) { return find(x) == find(y); }
// 小树挂大树,返回是否真的发生了合并
bool unite(int x, int y) {
int fx = find(x), fy = find(y);
if (fx == fy) return false;
if (sz[fx] < sz[fy]) swap(fx, fy);
fa[fy] = fx;
sz[fx] += sz[fy];
return true;
}
ll n;
vector<int> bigger_neighbor[maxn]; // bigger_neighbor[i] 只存编号大于 i 的邻居,无向边只留一半
void read_input() {
cin >> n;
for (ll i = 1; i <= n; i++) {
ll cnt;
cin >> cnt;
for (ll t = 1; t <= cnt; t++) {
ll j;
cin >> j;
if (j > i) {
bigger_neighbor[i].push_back(j); // 小邻居会在处理小编号点时读到,这里丢掉
}
}
}
}
void solve() {
init(n);
// 倒序加点:第 i 轮结束后集合 {i, i+1, ..., n} 的连通块,
// 恰好等于"打击掉 1..i-1 之后剩下的集团"。首次出现超过 n/2 的集团时,答案就是 i。
for (ll i = n; i >= 1; i--) {
ll neighbor_cnt = bigger_neighbor[i].size();
for (ll k = 0; k < neighbor_cnt; k++) {
unite(i, bigger_neighbor[i][k]);
}
// unite 按大小换根,必须合并后重新 find(i) 再读 sz[r],不能沿用旧 root
int r = find(i);
if (sz[r] > n / 2) {
cout << i << "\n";
return;
}
}
cout << n << "\n"; // 整图连通时上面必然已返回,这里兜住非连通输入
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
solve();
return 0;
}复杂度
- 时间复杂度:每条边只被访问一次,合并总次数不超过
,路径压缩下单次 find均摊, 总计 (包括查找带来的对数因子),与 C++ 标准做法同阶。 这比枚举 的 少了因子 。 实测最大数据( )本地单测例约 0.020s、峰值 15.2MB。 - 空间复杂度:邻接表
、并查集 ,合计 。
总结
- 本题的核心等价变形:前缀删点 = 只看后缀,因此"删
"与"从 倒着加到 "是同一件事的两种方向。 - 关键性质是
关于 单调不增,它同时解释了"可以二分答案"和"首触发即答案、可以提前返回"。 - 并查集只支持合并,所以要把删点问题翻成加点问题;这是本题从
降下来的那一步。 - 实现细节可以直接迁移:无向图只存大编号邻居让每条边只访问一次、按大小合并会换根所以合并后必须重新
find再读sz、 重边靠unite内部同根返回False和j > i过滤。
图示解析
这张图串起本题从建模到得到答案的主线:
输入:n 个团伙 + 联系边
`- 打击 1..k == 只看编号 > k 的点
`- 单调性:k 变大,点更少,最大集团只可能变小
`- 二分 O(log n) 次判定,每次 并查集 O(n + m)
`- (本文路线)倒序加点:删 1..k 是加 n..k+1 的逆过程
`- 第 i 轮集合 {i..n} 的集团 == 删 1..i-1 后的集团
`- 首个超过 floor(n/2) 的集团出现在第 i 轮 ==> 答案 k = i先确认第一层等价(“打击掉的团伙"就是"不在图里的点”),再顺着分支看两种判定方式:
二分答案每次重建并查集,倒序加点则让并查集只在加点时增长,均摊到一次加点即得答案。
最后一个分支是本题的核心——因为大小超过
