题单 · 习题解析

洛谷 P1551 亲戚

★★ 三条路都能过 —— 并查集 26 996 步 / 一次 BFS 染色 **5 000** 步 / 每个询问跑一次 BFS 790 万步(147 ms,时限 1 秒)⇒ **选并查集的理由不是快**;★★★ 那它凭什么不可替代?—— 它是**在线**的:把同一份数据改成「合并和询问交错」,染色那条路 200 轮就要碰 100 万个点,而并查集一共只跳了 **15** 步(差六万倍)⇒ 这道题是**离线**的,所以三条路平手,而隔壁 [P3367](/sol/p3367/) 用得上;★★★ 「合并没先找根」那个错法**三个精心设计的极端档(链式 / 星形 / 空关系)全是能证的 0,只有最朴素的随机档抓到 135** ⇒ **极端档往往结构太规整,反而把 bug 喂对了**(同一章的 [P1892](/sol/p1892/) 又撞了一次);★ `m == p` 的轮数 ≡ 「第一行读成 n p m」漏掉的轮数(四档一个不差)

原题:洛谷 P1551出自 第 36 章 并查集 的题单题面本地存档:2026-08-31
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易, 现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。

规定:xy 是亲戚,yz 是亲戚,那么 xz 也是亲戚。 如果 xy 是亲戚,那么 x 的亲戚都是 y 的亲戚,y 的亲戚也都是 x 的亲戚。

输入格式

第一行:三个整数 n, m, pn, m, p ≤ 5000),分别表示有 n 个人,m 个亲戚关系, 询问 p 对亲戚关系。

以下 m 行:每行两个数 MᵢMⱼ1 ≤ Mᵢ, Mⱼ ≤ n,表示 MᵢMⱼ 具有亲戚关系。

接下来 p 行:每行两个数 Pᵢ, Pⱼ,询问 PᵢPⱼ 是否具有亲戚关系。

输出格式

p 行,每行一个 YesNo。表示第 i 个询问的答案为「具有」或「不具有」亲戚关系。

时限 1 秒,内存 128 MB。

输入输出样例

输入

6 5 3
1 2
1 5
3 4
5 2
1 3
1 4
2 3
5 6

输出

Yes
Yes
No

⚠ 第一行是 n m p —— 中间那个是关系数,不是询问数(第 ④ 步); 输出是 Yes / No,不是隔壁 P3367 的单字母 Y / N

1并查集那一版,五分钟就能写完

p1551.cpp★ 这一版就能 AC(顶格 n=m=p=5000,本机 1 毫秒以内)
// P1551 亲戚 —— ★ 这一版就能 AC
//
// 最裸的连通性查询:给 m 条「是亲戚」的关系,问 p 对人是不是亲戚。
// 题单里它的定位是「**用它确认你默写的版本是对的**」——
// ⚠ 而这一页真正想说清楚的是另一件事:**这道题其实用不着并查集**(第 ② 步),
// 那么「什么时候非它不可」?答案在第 ③ 步。
//
// ⚠ 输出是 `Yes` / `No`(首字母大写)—— 隔壁 [P3367](/sol/p3367/) 要的是单字母 `Y` / `N`。
#include <bits/stdc++.h>
using namespace std;
static const int N = 5005;
static int fa[N];
static int find(int x) {
int r = x;
while (fa[r] != r) r = fa[r];
while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; }
return r;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, p;
if (!(cin >> n >> m >> p)) return 0;
for (int i = 1; i <= n; i++) fa[i] = i;
for (int i = 0; i < m; i++) {
int a, b;
cin >> a >> b;
fa[find(a)] = find(b);
}
string out;
for (int i = 0; i < p; i++) {
int a, b;
cin >> a >> b;
out += (find(a) == find(b)) ? "Yes\n" : "No\n";
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

题单里这道题的定位是「用它确认你默写的版本是对的」。 ⇒ 所以这一页不讲并查集 —— 它要回答的是另一个问题:这道题真的需要并查集吗?

2★★ 三条路都能过 —— 选并查集的理由不是快

p1551Color.cpp★ 建图 + 一次 BFS 染色 O(n+m) —— 比并查集还直白
// P1551 · 另一条正确的路:建图 + 一次 BFS 把每个连通块染上颜色
//
// ★ 它和并查集一行代码都不共享:把 m 条关系当成无向边建图,从每个没染色的点出发 BFS 一遍,
// 同色即亲戚。复杂度 O(n + m),比并查集还直白。
//
// ⚠⚠ 但它有一个**结构上的前提**:**所有的边必须先读完**。
// 这道题恰好是「先给全部关系、再给全部询问」——**离线**的,所以它成立。
// ⇒ 一旦合并和询问**交错**出现(比如 [P3367](/sol/p3367/) 那种格式),
// 它就得每来一个询问重染一次 —— 那才是并查集不可替代的地方(本页第 ③ 步)。
#include <bits/stdc++.h>
using namespace std;
static const int N = 5005;
static vector<int> g[N];
static int col[N];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, p;
if (!(cin >> n >> m >> p)) return 0;
for (int i = 0; i < m; i++) {
int a, b;
cin >> a >> b;
g[a].push_back(b);
g[b].push_back(a);
}
for (int i = 1; i <= n; i++) col[i] = 0;
int c = 0;
for (int s = 1; s <= n; s++) {
if (col[s]) continue;
col[s] = ++c;
vector<int> q{s};
for (size_t h = 0; h < q.size(); h++) {
int u = q[h];
for (int v : g[u]) if (!col[v]) { col[v] = c; q.push_back(v); }
}
}
string out;
for (int i = 0; i < p; i++) {
int a, b;
cin >> a >> b;
out += (col[a] == col[b]) ? "Yes\n" : "No\n";
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p1551Each.cpp⚠ 每个询问跑一次 BFS,O(p(n+m)) —— 本机 147 毫秒,居然也过得去
A 机 · WSL2 · 8 核 / 7 G · 2026-08-31 · 独占 · 顶格 n = m = p = 5000 碰了多少个点(机器无关) 秒表(时限 1 秒)
并查集 26 996 < 1 ms
一次 BFS 染色 5 000 < 1 ms
⚠ 每个询问跑一次 BFS 7 904 518 147 ms

★★ 三条路输出逐字节相同,而且最慢的那条也只用了时限的 15%。 ⇒ 在这道题上,「用并查集」是一个没有代价也没有收益的选择 —— 连「一次 BFS 染色」都比它碰的点少(5000 vs 26996,因为它一个点只碰一次)。

3★★★ 那什么时候非并查集不可?—— 把询问插到合并中间去

★★★ 这道题是「离线」的:所有关系先给完,所有询问才开始

「一次 BFS 染色」能成立,靠的是一个结构上的前提所有的边都已经读完了。 这道题的输入格式恰好满足它 —— 先 m 行关系,再 p 行询问。

⚠ 一旦合并和询问交错出现(隔壁 P3367 就是那种格式), 染色那条路每来一个询问就得整张图重染一遍。同一份顶格数据改成交错形式:

只做了 200 轮「一条关系 + 一个询问」
「一次染色」要重染 200 遍,一共碰 ★ 1 000 000 个点
并查集一共跳了 15

★★★ 200 轮就差了六万倍,而题面顶格是 5000 轮 (外推:染色约 2.5 × 10⁷ 个点,而并查集仍然是几百步)。

⇒ ★★ 并查集真正不可替代的地方不是「快」,是「边合并边回答」 —— 它是一个在线的数据结构。 这道题用不上那个优势,所以三条路平手; P3367 用得上,所以那道题只有它能写。

4⚠ 三个错法,全被官方样例挡住 —— 但其中一个的抓获形状很值得看

p1551Direct.cpp✗ 合并时没先找根(样例打出 Yes No No)
// P1551 · 错法 ①:合并时**没有先找根** —— 直接 fa[a] = b
//
// fa[find(a)] = find(b); ← 正解:把 a 的**祖宗**挂到 b 的**祖宗**下面
// fa[a] = b; ← 这一版:把 a 自己挂过去
//
// ⚠ 它把 a 原来的父亲**弄丢了** —— a 底下那一整棵子树就此和 a 的老家断开。
// ★ 这是初学者最常写出来的一发 WA,而且**对拍抓得到**(和本章第 11 步那五种正好相反)。
#include <bits/stdc++.h>
using namespace std;
static const int N = 5005;
static int fa[N];
static int find(int x) {
int r = x;
while (fa[r] != r) r = fa[r];
while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; }
return r;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, p;
if (!(cin >> n >> m >> p)) return 0;
for (int i = 1; i <= n; i++) fa[i] = i;
for (int i = 0; i < m; i++) {
int a, b;
cin >> a >> b;
fa[a] = b; // ✗ 没先找根
}
string out;
for (int i = 0; i < p; i++) {
int a, b;
cin >> a >> b;
out += (find(a) == find(b)) ? "Yes\n" : "No\n";
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
fa[find(a)] = find(b);   // ★ 正解:把 a 的**祖宗**挂到 b 的**祖宗**下面
fa[a] = b;               // ✗ 这一版:把 a 自己挂过去 —— a 原来的父亲就此丢了
★★★ 三个「专门设计」的档全是精确的 0,抓到它的偏偏是最朴素的随机档
300 轮 档 0 随机 ★ 档 1 一条关系都没有 ★ 档 2 链式 1-2、2-3…… ★ 档 3 星形(都连 1 号)
「合并没先找根」被抓 135 0 0 0

三个 0 都能证

档 1:一次合并都没有 ⇒ 那行代码根本没执行到。 档 2:关系是 1-22-33-4……⇒ fa[1]=2, fa[2]=3, fa[3]=4 —— 正好拼出一条正确的链,答案一模一样。 档 3:关系全是 (i, 1)fa[i]=1,而 1 号一直是根 —— 每次挂的本来就是根

⇒ ★★★ 这一章里同样的形状出现了两次(另一次是隔壁 P1892 那个「foe 按点存」, 三个极端档全 0、随机档抓 61): 「造一个极端档去测边界」有系统性的盲区 —— 极端档往往结构太规整,反而把 bug 喂对了。 ⇒ 别把随机那一档当成「凑数的默认值」,它有时候是唯一抓得到的那一档。

p1551Yn.cpp✗ 输出 Y / N —— 那是隔壁 P3367 的格式(四档全是 300)
p1551Swap.cpp✗ 第一行读成 n p m(关系数和询问数读反)
★ 而「读反」漏掉的那些轮,能数到底
300 轮 档 0 档 1 档 2 档 3
m == p 的轮数 49 0 53 53
⇒ 「读成 n p m」漏掉的轮数 49 0 53 53

★★ 一个不差 —— mp 相等时,读反和读对是同一件事。

5★ 对拍这一页

参照物是「一次 BFS 染色」那条路(和并查集一行代码都不共享)。

300 轮(n 随机 5~10) 档 0 随机 ★ 档 1 没有关系 ★ 档 2 链式 ★ 档 3 星形
一次 BFS 染色 0 0 0 0
每个询问一次 BFS 0 0 0 0
合并没先找根 135 0 0 0
输出 Y / N 300 300 300 300
第一行读成 n p m 251 300 247 247

6度量程序和生成器

p1551Count.cpp度量程序(本页所有数字都出自它)
p1551Gen.cpp(五个档位)数据生成器

7一页纸

★★ 三条路都能过 并查集 26 996 步 / 一次染色 5 000 步 / 每问一次 BFS 790 万步(147 ms,时限 1 秒)
⇒ 选并查集的理由 不是快 —— 这道题是离线的,染色那条路完全够
★★★ 那它凭什么不可替代 边合并边回答(在线):同一份数据改成交错形式,200 轮就差 六万倍
★★★ 三个极端档一起漏 「合并没先找根」:链式 / 星形 / 空关系全是能证的 0,只有随机档抓到 135
⇒ 一条能带走的规矩 极端档往往结构太规整,反而把 bug 喂对了 —— 随机那一档不是凑数的
★ 读反 m == p 的轮数 ≡ 「读成 n p m」漏掉的轮数(49 / 0 / 53 / 53,一个不差)
⚠ 输出格式 Yes / No —— 隔壁 P3367 要的是单字母 Y / N