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 ≤ 10,M ≤ 20。
对于 35% 的数据,N ≤ 100,M ≤ 10³。
对于 50% 的数据,1 ≤ N ≤ 10⁴,1 ≤ M ≤ 2 × 10⁵。
对于 100% 的数据,1 ≤ N ≤ 2 × 10⁵,1 ≤ M ≤ 10⁶,1 ≤ Xᵢ, Yᵢ ≤ N,Zᵢ ∈ {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【模板】并查集 —— ★ 这一版就能 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;}点「运行 ▶」看结果
路径压缩 + 按秩合并,正文 fast.cpp 原样搬过来。这一页不重讲算法 ——
它要做的是把本章第 11 步那句话放到考场规模上量一遍:
那两句话谁都不影响答案,只影响速度。⇒ 对拍一辈子也抓不到它们。
2★ 题面那两行数字先乘一遍:这道题的操作数是点数的 5 倍
N(点数) |
≤ 2 × 10⁵ |
M(操作数) |
≤ 10⁶ |
| ⇒ 比值 | ★ 5 倍 |
★ 前面那些用到并查集的题(P3366 的 Kruskal、P1546、
P2820)都是「每条边一次 find」——m ≤ 2 × 10⁵,而且一条边只查一次。
这道题不一样:同一批点要被查一百万次,于是那两句话省下来的跳步会被放大。
⇒ 第 ③ 步就是这个比值的后果。
3★★★ 这道题不用「造对形状」—— 照题面顶格随机就把它打死了
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 的次数。
一条断言跑 两分十六秒,会把 check:viz 拖慢两成(本书栽过一次:
Promise.all 扔 1200 个进程把它从 395 秒拖到 1391 秒)。
⇒ 所以断言钉的是同一份数据上的增长(p3367Count.cpp 的 grow 那一行,跑一秒):
| 「都不用」在顶格随机的前 …… 次操作上 | 跳步数 | 比上一档 |
|---|---|---|
| 5 万 | 6 707 | — |
| 10 万 | 30 436 | ★ 4.54 倍 |
| 20 万 | 228 955 | ★ 7.52 倍 |
★★ 操作数翻一倍,跳步数翻四倍、七倍 —— 而且倍数还在往上走
(P5019 那把尺子)。再往上翻五倍到 10⁶,就是那 261 亿。
⇒ 想复现那个数:./p3367Count full(度量程序留了这个入口,本机 136 秒)。
26 ms 和 28 ms,两个都远在 2 秒时限之内 ——
⇒ 这道题上「只写一个」完全够,两个都写只是把 26 毫秒变成 13 毫秒。
★ 但两者的性质完全不同(本章第 5、6 步):
按秩合并给的是每一次操作的 O(log n) 保证,路径压缩给的是均摊的保证。
⇒ 单次最坏和均摊最坏是两件事,写哪个取决于你要哪一种保证。
4⚠ 和算法无关的第一条:Z 在最前面
// 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;}点「运行 ▶」看结果
绝大多数题的格式是「先给数据、后给操作」,这道题反过来。 四个档 300 / 294 / 300 / 300,一测就死。
5⚠ 和算法无关的第二条:输出是 Y / N,不是 Yes / No
// 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;}点「运行 ▶」看结果
这道题要单个大写字母 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 随机 6m 随机 10 |
档 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 只能靠数跳步(第 ③ 步那两张表)发现,对拍是聋的。
生成器还留了一个卡链档(先 n − 1 次顺序合并把树拉成一条链,剩下的全查最深那一头)。
⚠ 而它太狠了 —— 度量程序在那一档只查 2000 次:
| 顶格卡链(只查 2000 次) | 跳步数 | 秒表 |
|---|---|---|
| 两句话都不用 | 399 998 000 | 792 ms |
| 三个用了任意一句的 | ★ 201 998 | 1 ms |
★ 每次查询要跳 20 万步 ⇒ 按题面真正的 800 001 次查询外推:约 1.6 × 10¹¹ 步。 ⇒ 比顶格随机那 261 亿还狠 6 倍 —— ⚠ 但这道题根本用不着它(第 ③ 步)。
7度量程序和生成器
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) |