0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1551,日期见页头。两边不一致时信原站。
题目描述
若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易, 现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
规定:x 和 y 是亲戚,y 和 z 是亲戚,那么 x 和 z 也是亲戚。
如果 x,y 是亲戚,那么 x 的亲戚都是 y 的亲戚,y 的亲戚也都是 x 的亲戚。
输入格式
第一行:三个整数 n, m, p(n, m, p ≤ 5000),分别表示有 n 个人,m 个亲戚关系,
询问 p 对亲戚关系。
以下 m 行:每行两个数 Mᵢ,Mⱼ,1 ≤ Mᵢ, Mⱼ ≤ n,表示 Mᵢ 和 Mⱼ 具有亲戚关系。
接下来 p 行:每行两个数 Pᵢ, Pⱼ,询问 Pᵢ 和 Pⱼ 是否具有亲戚关系。
输出格式
p 行,每行一个 Yes 或 No。表示第 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 亲戚 —— ★ 这一版就能 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;}点「运行 ▶」看结果
题单里这道题的定位是「用它确认你默写的版本是对的」。 ⇒ 所以这一页不讲并查集 —— 它要回答的是另一个问题:这道题真的需要并查集吗?
2★★ 三条路都能过 —— 选并查集的理由不是快
// 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;}点「运行 ▶」看结果
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⚠ 三个错法,全被官方样例挡住 —— 但其中一个的抓获形状很值得看
// 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;}点「运行 ▶」看结果
fa[find(a)] = find(b); // ★ 正解:把 a 的**祖宗**挂到 b 的**祖宗**下面
fa[a] = b; // ✗ 这一版:把 a 自己挂过去 —— a 原来的父亲就此丢了
| 300 轮 | 档 0 随机 | ★ 档 1 一条关系都没有 | ★ 档 2 链式 1-2、2-3…… | ★ 档 3 星形(都连 1 号) |
|---|---|---|---|---|
| 「合并没先找根」被抓 | ★ 135 | ★ 0 | ★ 0 | ★ 0 |
三个 0 都能证:
档 1:一次合并都没有 ⇒ 那行代码根本没执行到。 档 2:关系是
1-2、2-3、3-4……⇒fa[1]=2, fa[2]=3, fa[3]=4—— 正好拼出一条正确的链,答案一模一样。 档 3:关系全是(i, 1)⇒fa[i]=1,而 1 号一直是根 —— 每次挂的本来就是根。
⇒ ★★★ 这一章里同样的形状出现了两次(另一次是隔壁 P1892 那个「foe 按点存」, 三个极端档全 0、随机档抓 61): 「造一个极端档去测边界」有系统性的盲区 —— 极端档往往结构太规整,反而把 bug 喂对了。 ⇒ 别把随机那一档当成「凑数的默认值」,它有时候是唯一抓得到的那一档。
| 300 轮 | 档 0 | 档 1 | 档 2 | 档 3 |
|---|---|---|---|---|
m == p 的轮数 |
49 | ★ 0 | 53 | 53 |
| ⇒ 「读成 n p m」漏掉的轮数 | 49 | ★ 0 | 53 | 53 |
★★ 一个不差 —— m 和 p 相等时,读反和读对是同一件事。
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度量程序和生成器
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 |