题单 · 习题解析

洛谷 P3367 【模板】并查集

★★ 题面那两行数字先乘一遍:`M / N = 10⁶ / 2×10⁵ = **5 倍**` —— 同一批点要被查一百万次;★★★ 于是**这道题不用「造对形状」** —— 照题面顶格随机撒 10⁶ 个操作,「路径压缩 / 按秩合并两句话都不用」就跳了 **261 亿步、约 136 秒**(时限 2 秒,超 68 倍)⇒ 这正好是[第 34 章 P3366](/sol/p3366/) 的**反面**(那道题顶格随机只有 2187 万步、必须卡链)⇒ ★★ **「顶格随机抓不抓得到」的主语不是这个 bug,是那道题调用 find 的次数**;★★★ 而三种正确写法四个档**全是 0** —— 本章第 11 步「对拍一辈子也抓不到」的考场版,只能数跳步;⚠ 那个 261 亿**故意没写成断言**(一条跑 136 秒会把 check:viz 拖慢两成),钉的是**增长** 6707 → 30436 → 228955;⚠ 外加两条不在算法里的:`Z` 在最前面、输出是 `Y`/`N`(隔壁 [P1551](/sol/p1551/) 才是 `Yes`/`No`,而「一次查询都没有」的 9 轮 ≡ 它漏掉的 9 轮)

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

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

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

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

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

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

题目背景

本题数据范围已经更新到 1 ≤ N ≤ 2 × 10⁵1 ≤ M ≤ 10⁶

题目描述

如题,现在有一个并查集,你需要完成合并和查询操作。

输入格式

第一行包含两个整数 N, M,表示共有 N 个元素和 M 个操作。

接下来 M 行,每行包含三个整数 Zᵢ, Xᵢ, Yᵢ

Zᵢ = 1 时,将 XᵢYᵢ 所在的集合合并。

Zᵢ = 2 时,输出 XᵢYᵢ 是否在同一集合内,是的输出 Y;否则输出 N

输出格式

对于每一个 Zᵢ = 2 的操作,都有一行输出,每行包含一个大写字母,为 Y 或者 N

数据规模与约定

对于 15% 的数据,N ≤ 10M ≤ 20

对于 35% 的数据,N ≤ 100M ≤ 10³

对于 50% 的数据,1 ≤ N ≤ 10⁴1 ≤ M ≤ 2 × 10⁵

对于 100% 的数据,1 ≤ N ≤ 2 × 10⁵1 ≤ M ≤ 10⁶1 ≤ Xᵢ, Yᵢ ≤ NZᵢ ∈ {1, 2}

时限 2 秒,内存 128 MB。

输入输出样例

输入

4 7
2 1 2
1 1 2
2 1 2
1 3 4
2 1 4
1 2 3
2 1 4

输出

N
Y
N
Y

⚠ 每行是 Z X Y —— 操作类型在最前面(第 ④ 步); 输出是单个大写字母 Y / N,不是 Yes / No(第 ⑤ 步)。

1算法本身,本章第 5、6 步已经讲完了

p3367.cpp★ 这一版就能 AC(顶格 n = 2×10⁵ / m = 10⁶,本机 13 毫秒 / 时限 2 秒)
// P3367【模板】并查集 —— ★ 这一版就能 AC
//
// 本章第 5、6 步那两句话(路径压缩 + 按秩合并)原样搬过来就是它。
// ⚠ 这一页要讲的不是算法 —— 算法正文已经讲完了 —— 而是**这道题把那两句话的价钱量了出来**:
// M ≤ 10⁶ 次操作、N ≤ 2×10⁵ 个点 ⇒ **操作数是点数的 5 倍**,
// 路径压缩省下来的那些跳步会被放大 5 倍(本页第 ③ 步)。
//
// ⚠ 输入格式:每行 **Z X Y**(操作类型在最前面),别读成 X Y Z(本页第 ④ 步)。
// ⚠ 输出是单个大写字母 **Y / N**,不是 Yes / No(本页第 ⑤ 步 —— 隔壁 P1551 正好相反)。
#include <bits/stdc++.h>
using namespace std;
static const int N = 200005;
static int fa[N], rk[N];
static char ibuf[1 << 16];
static int ipos, ilen;
static inline char gc() {
if (ipos == ilen) { ilen = (int)fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (ilen <= 0) return 0; }
return ibuf[ipos++];
}
static inline int rd() {
char c = gc();
while (c && (c < '0' || c > '9')) c = gc();
int x = 0;
while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = gc(); }
return x;
}
static char obuf[1 << 16];
static int opos;
static inline void flushOut() { fwrite(obuf, 1, opos, stdout); opos = 0; }
static inline void pc(char c) { if (opos == (int)sizeof(obuf)) flushOut(); obuf[opos++] = c; }
/* ★ 迭代的两趟:先找到根,再把这一路全挂到根上(和正文 fast.cpp 一样) */
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() {
int n = rd(), m = rd();
for (int i = 1; i <= n; i++) { fa[i] = i; rk[i] = 0; }
for (int i = 0; i < m; i++) {
int z = rd(), x = rd(), y = rd();
int a = find(x), b = find(y);
if (z == 1) {
if (a == b) continue;
if (rk[a] < rk[b]) swap(a, b); // ★ 矮的挂到高的下面
fa[b] = a;
if (rk[a] == rk[b]) rk[a]++;
} else {
pc(a == b ? 'Y' : 'N');
pc('\n');
}
}
flushOut();
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

路径压缩 + 按秩合并,正文 fast.cpp 原样搬过来。这一页不重讲算法 —— 它要做的是把本章第 11 步那句话放到考场规模上量一遍

那两句话谁都不影响答案,只影响速度。⇒ 对拍一辈子也抓不到它们。

2★ 题面那两行数字先乘一遍:这道题的操作数是点数的 5 倍

★★ M / N = 5 —— 而这个比值决定了这一页所有的结论
N(点数) 2 × 10⁵
M(操作数) 10⁶
⇒ 比值 5 倍

★ 前面那些用到并查集的题(P3366 的 Kruskal、P1546P2820)都是「每条边一次 find」——m ≤ 2 × 10⁵,而且一条边只查一次。 这道题不一样:同一批点要被查一百万次,于是那两句话省下来的跳步会被放大。 ⇒ 第 ③ 步就是这个比值的后果。

3★★★ 这道题不用「造对形状」—— 照题面顶格随机就把它打死了

p3367Naive.cpp⚠ 两句话都不用 —— 答案永远对,顶格随机上跑 136 秒(时限 2 秒)
★★★ 顶格随机 —— 顺手写的生成器造出来的那种数据
A 机 · WSL2 · 8 核 / 7 G · 2026-08-31 · 独占 · 顶格 n = 2×10⁵ / m = 10⁶ · 随机操作 find 里往上跳的步数 秒表(时限 2 秒)
两句话都不用 26 146 739 319 136 000 ms ⇒ 超时 68 倍
只用路径压缩 5 140 910 26 ms
只用按秩合并 3 188 781 28 ms
★ 两个都用 1 677 407 13 ms

★ 跳步数那一列是机器无关的(两次跑逐位相同);秒表那一列跨天能差几个百分点,只当量级看。 ★★★ 不需要卡链,不需要构造 —— 照题面顶格随机撒 10⁶ 个操作,它就已经死了。 ⇒ 这正好是第 34 章 P3366 那条的反面: 那道题的「没有路径压缩」在顶格随机上只爬 2187 万步(50 毫秒), 必须专门卡一条链才爬到 19.6 亿步。

P3366(Kruskal 建最小生成树) ★ 这道题
find 被调用多少次 每条边一次,m ≤ 2 × 10⁵ 10⁶ 次,而且点只有 2 × 10⁵
顶格随机够不够毒 不够(2187 万步 / 50 ms) (261 亿步 / 136 秒)

⇒ ★★ 「顶格随机抓不抓得到」的主语不是这个 bug,是那道题调用 find 的次数。

⚠ 而上面那个 261 亿没有写成断言 —— 理由说在这儿

一条断言跑 两分十六秒,会把 check:viz 拖慢两成(本书栽过一次Promise.all 扔 1200 个进程把它从 395 秒拖到 1391 秒)。 ⇒ 所以断言钉的是同一份数据上的增长p3367Count.cppgrow 那一行,跑一秒):

「都不用」在顶格随机的前 …… 次操作上 跳步数 比上一档
5 万 6 707
10 万 30 436 4.54 倍
20 万 228 955 7.52 倍

★★ 操作数翻一倍,跳步数翻四倍、七倍 —— 而且倍数还在往上走P5019 那把尺子)。再往上翻五倍到 10⁶,就是那 261 亿。 ⇒ 想复现那个数:./p3367Count full(度量程序留了这个入口,本机 136 秒)。

★ 那「只写一个」够不够?—— 够,而且两个单独写的几乎一样快
p3367Comp.cpp只用路径压缩(26 毫秒)—— 竞赛里绝大多数人写的就是它
p3367Rank.cpp只用按秩合并(28 毫秒)

26 ms 和 28 ms,两个都远在 2 秒时限之内 —— ⇒ 这道题上「只写一个」完全够,两个都写只是把 26 毫秒变成 13 毫秒。 ★ 但两者的性质完全不同(本章第 5、6 步): 按秩合并给的是每一次操作O(log n) 保证,路径压缩给的是均摊的保证。 ⇒ 单次最坏和均摊最坏是两件事,写哪个取决于你要哪一种保证。

4⚠ 和算法无关的第一条:Z 在最前面

p3367Order.cpp✗ 读成了 X Y Z(官方样例打出 N Y N N N N N,当场挡住)
// P3367 · 错法 ①:读成了 X Y Z —— 操作类型在最前面,不在最后
//
// ⚠ 绝大多数题的格式是「先给数据、后给操作」,这道题反过来。
// 这个错法**每一组都错**,官方样例一测就死。
#include <bits/stdc++.h>
using namespace std;
static const int N = 200005;
static int fa[N], rk[N];
static char ibuf[1 << 16];
static int ipos, ilen;
static inline char gc() {
if (ipos == ilen) { ilen = (int)fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (ilen <= 0) return 0; }
return ibuf[ipos++];
}
static inline int rd() {
char c = gc();
while (c && (c < '0' || c > '9')) c = gc();
int x = 0;
while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = gc(); }
return x;
}
static char obuf[1 << 16];
static int opos;
static inline void flushOut() { fwrite(obuf, 1, opos, stdout); opos = 0; }
static inline void pc(char c) { if (opos == (int)sizeof(obuf)) flushOut(); obuf[opos++] = c; }
/* ★ 迭代的两趟:先找到根,再把这一路全挂到根上(和正文 fast.cpp 一样) */
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() {
int n = rd(), m = rd();
for (int i = 1; i <= n; i++) { fa[i] = i; rk[i] = 0; }
for (int i = 0; i < m; i++) {
int x = rd(), y = rd(), z = rd(); // ✗ 顺序反了
int a = find(x), b = find(y);
if (z == 1) {
if (a == b) continue;
if (rk[a] < rk[b]) swap(a, b); // ★ 矮的挂到高的下面
fa[b] = a;
if (rk[a] == rk[b]) rk[a]++;
} else {
pc(a == b ? 'Y' : 'N');
pc('\n');
}
}
flushOut();
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

绝大多数题的格式是「先给数据、后给操作」,这道题反过来。 四个档 300 / 294 / 300 / 300,一测就死。

5⚠ 和算法无关的第二条:输出是 Y / N,不是 Yes / No

p3367Yn.cpp✗ 输出 Yes / No —— 那是隔壁 P1551 的格式(样例挡住)
// P3367 · 错法 ②:输出 Yes / No —— 那是隔壁 P1551 的格式
//
// ⚠ 同一张题单上的两道题,**问的是同一件事,输出格式却不一样**:
// 这道题要单个大写字母 `Y` / `N`,P1551 要 `Yes` / `No`。
// ⇒ 「上一道的正确写法就是这一道的 bug」在**同一张题单内**又演了一遍。
#include <bits/stdc++.h>
using namespace std;
static const int N = 200005;
static int fa[N], rk[N];
static char ibuf[1 << 16];
static int ipos, ilen;
static inline char gc() {
if (ipos == ilen) { ilen = (int)fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (ilen <= 0) return 0; }
return ibuf[ipos++];
}
static inline int rd() {
char c = gc();
while (c && (c < '0' || c > '9')) c = gc();
int x = 0;
while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = gc(); }
return x;
}
static char obuf[1 << 16];
static int opos;
static inline void flushOut() { fwrite(obuf, 1, opos, stdout); opos = 0; }
static inline void pc(char c) { if (opos == (int)sizeof(obuf)) flushOut(); obuf[opos++] = c; }
/* ★ 迭代的两趟:先找到根,再把这一路全挂到根上(和正文 fast.cpp 一样) */
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() {
int n = rd(), m = rd();
for (int i = 1; i <= n; i++) { fa[i] = i; rk[i] = 0; }
for (int i = 0; i < m; i++) {
int z = rd(), x = rd(), y = rd();
int a = find(x), b = find(y);
if (z == 1) {
if (a == b) continue;
if (rk[a] < rk[b]) swap(a, b); // ★ 矮的挂到高的下面
fa[b] = a;
if (rk[a] == rk[b]) rk[a]++;
} else {
const char* s = (a == b) ? "Yes" : "No"; // ✗ 这是 P1551 的格式
for (const char* p = s; *p; p++) pc(*p);
pc('\n');
}
}
flushOut();
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 同一张题单上的两道题,问的是同一件事,输出格式却不一样

这道题要单个大写字母 Y / N;隔壁的 P1551 亲戚Yes / No。 ⇒ 「上一道的正确写法就是这一道的 bug」同一张题单内又演了一遍。

300 轮 档 0 档 1 全是查询 ★ 档 2 顺序合并 ★ 档 3 先并成一块
这一轮一次查询都没有 0 0 9 9
⇒ 「输出 Yes/No」漏掉的轮数 0 0 9 9

★★ 一个不差 —— 那 9 轮里 m 全被合并操作用光了(n − 1 ≥ m), 一个字都不输出,两版当然一样。 ⇒ 又一次「一致有两种:都算对了,和都没算」

6★ 对拍这一页:三种正确写法一辈子也抓不到

参照物就是正解本身(这道题没有第二个算法 —— 本章第 11 步说的正是这件事)。

300 轮(n 随机 612,m 随机 1020) 档 0 档 1 全是查询 ★ 档 2 顺序合并 档 3 先并成一块
两句话都不用 0 0 0 0
只用路径压缩 0 0 0 0
只用按秩合并 0 0 0 0
读成 X Y Z 300 294 300 300
输出 Yes / No 300 300 291 291

★★★ 上面三行全是 0,而且加多少轮、换什么档位都不会变 —— 它们只坏复杂度,不坏答案。⇒ 本章第 11 步那句话的考场版: 这一类 bug 只能靠数跳步(第 ③ 步那两张表)发现,对拍是聋的。

★ 卡链那一档:真顶格要 1.6 × 10¹¹ 步,度量程序自己都跑不完

生成器还留了一个卡链档(先 n − 1 次顺序合并把树拉成一条链,剩下的全查最深那一头)。 ⚠ 而它太狠了 —— 度量程序在那一档只查 2000 次

顶格卡链(只查 2000 次) 跳步数 秒表
两句话都不用 399 998 000 792 ms
三个用了任意一句的 201 998 1 ms

★ 每次查询要跳 20 万步 ⇒ 按题面真正的 800 001 次查询外推:约 1.6 × 10¹¹ 步。 ⇒ 比顶格随机那 261 亿还狠 6 倍 —— ⚠ 但这道题根本用不着它(第 ③ 步)。

7度量程序和生成器

p3367Count.cpp度量程序(本页所有数字都出自它;★ ./p3367Count full 复现那个 136 秒)
p3367Gen.cpp(六个档位)数据生成器

8一页纸

算法 正文第 5、6 步原样搬过来,这一页不重讲
★★ 题面那两行数字 M / N = 10⁶ / 2×10⁵ = **5 倍** ⇒ 同一批点要被查一百万次
★★★ 顶格随机就够毒 「两句话都不用」261 亿步 / 136 秒(时限 2 秒,超 68 倍
⇒ 和 P3366 正好相反 那道题顶格随机只有 2187 万步,必须卡链;主语是调用 find 的次数
★ 只写一个够不够 够:只压缩 26 ms、只按秩 28 ms、都写 13 ms —— ⚠ 但保证的种类不同(均摊 vs 单次)
★★★ 对拍是聋的 三种正确写法四个档 全 0,加多少轮都不会变 ⇒ 只能数跳步
⚠ 断言的取舍 那个 261 亿没写成断言(一条跑 136 秒)——★ 钉的是增长:6707 → 30436 → 228955
⚠ 两条和算法无关的 Z最前面;输出是 Y / N(隔壁 P1551 才是 Yes / No