CSP-S 提高级课程中心 / 数据结构·线性与特殊树 / 第13讲
CSP-S 提高级 · 数据结构·线性与特殊树 · s3-04

并查集:把「连通性」压缩到 几乎 O(1)

只用一行 parent[x] = find(parent[x]) 的路径压缩,就把「你俩是不是一伙的」从 O(n) 拉到了近乎常数时间——这是提高组性价比最高的数据结构。

6 大模块 3 个动作 init/find/union O(α(n)) 反阿克曼复杂度 2 处交互动画 12 道洛谷原题
本讲 · 第 13/52 讲并查集
模块 01

情景导航

从「社团不断合并」这件小事出发,发现只需要维护一张「父亲表」,就能闪电般回答任意两人的关系。

故事社团联合会风波

开学第一周,学校的社团联合会迎来了 n 位新同学,编号 1 到 n。最初每个人都是独立的,谁也不认识谁。

随着招新推进,各种消息不断传来:「2 号同学和 5 号同学加入了同一个摄影社」、「摄影社和美食社合并了」、「7 号加入了篮球社」……消息一共有 m 条。

社联主席每天都要不停地问:「现在 x 号同学和 y 号同学,是不是在同一个社团里?」 这个问题一天要问上十万次。

最笨的办法是:每次合并就把两个社团的所有成员名单都抄一遍,重新贴标签。可是社团越合越大,一次合并要抄几千个名字;而每天要回答的问题又有十万个——时间完全不够。

聪明的做法是:每个社团只选一个「会长」,其他成员只需要记住「我的上级是谁」。想看两个人是不是一个社团的,就让他们沿着上级链一路往上爬,看最后爬到的是不是同一个会长。

合并 = 让一个会长归顺另一个会长;查询 = 比比谁爬到的会长相同

如果把「爬链子」的路上顺手做一次「电话簿更新」——让沿途每个人以后都直接记住会长,那么下一次再问就一步到位了。这,就是并查集。

💡 故事背后的算法思想

① 「集合」不必真的存成员名单。我们只关心「谁和谁在一起」,并不关心集合内部长什么样。于是可以用一棵树来代表一个集合:树根是会长,每个节点记录自己的父亲。

② 「查找」就是一路向上到根。两个元素的根相同 ⟺ 属于同一集合。这一步是 find。

③ 「合并」只需挂一棵树的根到另一棵树的根上。一步完成,不需要搬动任何成员——这一步是 union。

④ 树不能长太高。链太长,find 就会退化成 O(n)。两个经典对策:路径压缩(把路走短)与按秩合并(把树挂矮)。两者叠加后,均摊复杂度只有 O(α(n)),其中 α 是反阿克曼函数,在 n ≤ 1080 时 α(n) 也不超过 4。

①生活类比
🏫

社团合并 = union

两个社团各有一个会长,让其中一个会长认另一个当上级,两社即刻合并,成员一个都不用动。

→ parent[rootB] = rootA
🧭

顺线索找族长 = find

「我的上级是谁」一路问上去,直到有人回答「我就是最高上级」,他就是整个家族的族长。

→ while(parent[x]!=x) x=parent[x]
🚀

更新电话簿 = 路径压缩

第一次爬完整条链后,顺手记下族长的电话,以后所有后代都能直接打给族长,不必层层转达。

→ parent[x] = find(parent[x])
⚖️

小庙并入大庙 = 按秩合并

合并时永远让「小的那棵树」挂到「大的那棵树」下,树高最多长一层,不会出现长链。

→ 按 size/rank 挂接
②核心定义

并查集(Disjoint Set Union,DSU):维护 n 个元素的「不相交集合」的一种数据结构,支持两种操作——把两个元素所在集合合并,以及查询两个元素是否属于同一集合。

森林表示:每个集合用一棵树表示,parent[x] 记录 x 的父亲;若 parent[x] == x,则 x 是所在树的根。

代表元(representative):一棵树的根节点,用来唯一标识这个集合。判断同一集合,就是判断 find(x) == find(y)。

路径压缩(path compression):find 时把查询路径上所有节点的父亲直接改成根,让下次查找接近 O(1)。

按秩/按大小合并(union by rank / size):合并时把「矮的树」或「小的树」挂到另一棵的根上,保证树高 O(log n)。

带权并查集:在父指针之外再维护一个「到父亲的距离(或关系)」d[x],可以回答「x 与根的关系」这类问题;路径压缩时要同步累加 d。

可撤销并查集:不使用路径压缩、只使用按秩合并,并用一个栈记录每次合并改动的位置,即可把上次合并「回滚」——常用于线段树分治 / 离线问题。

📌 本讲核心知识地图

板块核心内容对应大纲
思想模型森林表示 + find / union 三动作【6】并查集
朴素实现parent 数组,最坏退化成 O(n) 链【6】并查集
路径压缩parent[x] = find(parent[x]),均摊 O(log n)【6】并查集
按秩合并矮树挂高树,树高严格 O(log n)【6】并查集
复杂度两者结合均摊 O(α(n)),α 为反阿克曼函数【6】复杂度分析
连通块计数统计 parent[i]==i 的个数,随合并递减【6】并查集
带权并查集维护到根距离/相对关系,路径压缩同步累加【6】并查集
可撤销并查集按秩合并 + 栈记录,支持回滚拓展
③核心口诀
🔑 find 口诀

一路向上问父亲,谁的爹是自己谁就是根;
递归返回顺手改,路径压缩一次到位。

🔑 union 口诀

先查根、再比较:同根不动,异根才挂;
小树挂大树,树高不失控。

🔑 复杂度口诀

只压缩是 O(log n),只按秩也是 O(log n),
两招一起上,几乎 O(1)(O(α(n)))。

模块 02

知识探究

先看懂「森林 + 代表元」的模型,再亲手踩一次退化的坑,最后用两大优化把复杂度压到底。

1并查集思想:森林与代表元 核心

并查集把「集合」表示成一棵有根树:所有属于同一集合的元素恰好是同一棵树上的节点,树的根就是这个集合的代表元。

1 2 3 4 5 6 7 root ① root ⑥
图 1 · 两棵并查集树 = 两个集合
  • 左树代表集合 A,根为 ①,成员 {1,2,3,4,5}
  • 右树代表集合 B,根为 ⑥,成员 {6,7}
  • 只看 find(4) == 1、find(7) == 6,就知道 4 与 7 不同集合
  • 蓝色根 parent[x]==x,"自己当自己的父亲"
图 1 由内联 SVG 描边生长绘制,滚动到可见区域时自动"画"出来。
1
🌱
init 初始化

每个元素自成一派:parent[i] = i,共 n 个集合

2
🔍
find 查根

沿 parent 一路向上,直到自指节点,返回它

3
🔗
union 合并

求出两个根,若不同就把一个根挂到另一个根下

C++并查集三件套 · 最简骨架
int parent[MAXN];

// ① init:每人自成一派,n 个独立集合
void init(int n) {
    for (int i = 1; i <= n; i++) parent[i] = i;
}

// ② find:一路向上找根(朴素版,还没压缩)
int find(int x) {
    while (parent[x] != x) x = parent[x];
    return x;
}

// ③ union:先找根,根不同才合并
bool unite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra == rb) return false;  // 已在同一集合,无需操作
    parent[rb] = ra;                // 让 rb 认 ra 当父亲
    return true;
}

// 查询:两元素是否同集合
bool same(int a, int b) { return find(a) == find(b); }
概念在代码中的体现易混提醒
集合 = 树整棵树的所有节点同属一个集合树长什么样完全不重要,只有根重要
代表元 = 根parent[x] == x 的那个 x代表元不一定是编号最小的那个
合并 = 挂根parent[rb] = ra只能挂根,不能挂普通节点!
判同集合find(a) == find(b)不能写成 parent[a] == parent[b]
2朴素实现的退化问题 必踩的坑

如果合并时「随手挂」,并查集的树可能被搭成一条长长的链。这时 find 退化成 O(n),整体复杂度轰然崩塌。

🧨 制造退化:依次执行 unite(1,2)、unite(2,3)、unite(3,4) …… unite(n-1,n)。

如果每次都执行 parent[rb] = ra(即让后一个根挂到前一个根下),会发生什么?

第 1 次:2 挂到 1 下 → 1 - 2;第 2 次:find(2)=1,find(3)=3,3 挂到 1 下 → 1 - 3(此时链没变长)。

但如果我们统一写成 parent[ra] = rb(前一个挂到后一个下),链就会一路向右延伸:n ← n-1 ← ... ← 2 ← 1,此时 find(1) 要爬 n−1 步!

更隐蔽的坑:CSP-S 的测试数据往往就包含「按 1,2,3,…,n 顺序链式合并」的构造数据。一旦退化,105 次操作 × 105 步 = 1010,直接 TLE。

🐌 动画:链式合并如何把树拉成一条线
1
初始状态:1~6 各自独立,都是自己的根。parent = [1,2,3,4,5,6]
2
执行 unite(1,2),find(1)=1, find(2)=2,让根 1 挂到根 2 下:parent[1] = 2。此时 2 是根,树高 2。
3
执行 unite(2,3),find(2)=2, find(3)=3,令 parent[2] = 3。树变成 1→2→3,高度 3。
4
执行 unite(3,4),find(3)=3,令 parent[3] = 4。树变成 1→2→3→4,高度 4。
5
依次执行到 unite(5,6):得到一条长链 1→2→3→4→5→6,高度 6,完全退化成链表。
6
此时查询 find(1) 必须走 5 步才能到达根 6。n=105 时,单次 find 就要 105 步 —— 这就是退化。
7
第一个对策:路径压缩。在 find(1) 返回时顺手把 1~5 的父亲统统改成 6,链被"拍平"成一层。第二个对策:按秩合并,从源头阻止长链生成。
点击「自动演示」逐步播放,观察每次 union 后树高的变化。「单步」可用鼠标逐句确认。
C++会退化的错误写法(请对照理解,不要模仿)
// ❌ 危险写法:固定让 ra 挂到 rb 下,不比较高度
void badUnite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra != rb) parent[ra] = rb;   // 顺序合并时会拉成长链!
}

// ✅ 安全写法:谁大谁当根(按大小合并)
int sz[MAXN];
void goodUnite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra == rb) return;
    if (sz[ra] < sz[rb]) swap(ra, rb); // 保证 ra 是更大的那棵
    parent[rb] = ra;                     // 小树挂到大树下
    sz[ra] += sz[rb];
}
3路径压缩 + 按秩合并 提速关键

两个优化各管一头:路径压缩负责「把已经走长的路压平」,按秩合并负责「一开始就别让路变长」。

🚀 路径压缩(Path Compression)
  • 时机:每次 find 回溯时
  • 动作:parent[x] = find(parent[x])
  • 效果:整条查询链上的节点全部直连根
  • 代价:常数极小,一行代码
  • 局限:单独使用时,最坏单次仍是 O(n)(均摊 O(log n))
⚖️ 按秩合并(Union by Rank/Size)
  • 时机:每次 union 挂根时
  • 动作:小树挂大树,sz/rank 更新
  • 效果:树高严格 O(log n),不会出现长链
  • 代价:多一个数组、一次比较
  • 局限:单独使用时不改变查找路径的长短
❌ 朴素 find(无压缩)

每查一次都重新爬完整条链

链被反复"完整走一遍",最坏 O(n)

n=105、m=105 时可能 1010 次操作

✅ 压缩 find(递归一行)

第一次爬完,链上所有点直连根

第二次查同一个点,一步到达

均摊 O(α(n)) ≈ O(1),跑满 106 无压力

🔍 路径压缩前后对比:执行一次 find(1)

压缩前(长链,find(1) 要走 5 步):

①1
→2
→3
→4
→5
root6

压缩后(全部直连根 6,find(1) 只要 1 步):

①1
②2
③3
④4
⑤5
root6
parent[1]=parent[2]=parent[3]=parent[4]=parent[5]=6  →  树被"拍平"成高度 2
C++两大优化完整实现(两种 find 写法)
int parent[MAXN], sz[MAXN];

void init(int n) {
    for (int i = 1; i <= n; i++) { parent[i] = i; sz[i] = 1; }
}

// 写法一:递归(最短最清晰,推荐)
int find(int x) {
    if (parent[x] == x) return x;          // 到根了
    return parent[x] = find(parent[x]);    // 压缩 + 返回根
}

// 写法二:迭代(无递归栈风险,压两次更均匀)
int findIter(int x) {
    int r = x;
    while (parent[r] != r) r = parent[r];   // 第一趟:找根
    while (parent[x] != r) {               // 第二趟:路径上全部直连根
        int nx = parent[x];
        parent[x] = r;
        x = nx;
    }
    return r;
}

// 按大小合并(把 size 大的那棵当根)
bool unite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra == rb) return false;
    if (sz[ra] < sz[rb]) swap(ra, rb);
    parent[rb] = ra;
    sz[ra] += sz[rb];
    return true;
}
提示:把鼠标移到代码行上,该行会被高亮,方便对照讲解。
4复杂度直觉与连通块计数 考点
实现方式单次 find 最坏m 次操作均摊说明
完全不优化O(n)O(nm)链式数据必 TLE
仅路径压缩O(n)(罕见)O(m log n)均摊已很好,常数小
仅按秩合并O(log n)O(m log n)单次有保证
两者结合O(α(n))O(m·α(n))α(n) ≤ 4(n ≤ 1080)

📈 反阿克曼函数 α(n) 有多小?

阿克曼函数 A(m,n) 增长得极其疯狂:A(1,n)=2n,A(2,n)=n·2n,A(3,n) 已经是指数塔级别。它的反函数 α(n) 增长得极其缓慢。

具体数值:n < 5 时 α(n) ≤ 2;n < 265536 时 α(n) ≤ 4。而 265536 是一个有近两万位的大数,远远超过宇宙原子总数。

所以工程上可以直接把 α(n) 当成常数 4。这就是「并查集几乎 O(1)」这句话的数学依据(Tarjan 1975 年证明)。

注意:严格地说 α(n) 不是常数的证明复杂,竞赛中只需记住结论 —— 并查集操作 ≈ O(1),1 秒内 106~107 次操作绰绰有余。

🧩连通块个数怎么数?

初始时 n 个点各自独立,连通块个数 = n。每成功执行一次「真正不同根」的合并,连通块个数就 −1。因此维护一个全局变量 cnt 即可:

C++连通块计数
int cnt;  // 当前连通块个数(全局)

void init(int n) {
    for (int i = 1; i <= n; i++) parent[i] = i;
    cnt = n;                        // 开始有 n 个孤立点
}

bool unite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra == rb) return false;   // 已连通,块数不变
    parent[rb] = ra;
    cnt--;                          // 两个块并成一个
    return true;
}

// 也可以最后统计:parent[i]==i 的个数即为块数,O(n)
int countBlocks(int n) {
    int c = 0;
    for (int i = 1; i <= n; i++) if (find(i) == i) c++;
    return c;
}
1
2
3
4
5
6
7
8
被高亮的编号代表当前检查到的节点,逐一确认它是否为根

统计连通块时,我们逐个检查 find(i) == i。上图中被高亮的元素相当于「正在被检查」,只有自指的才是根。

5带权并查集:维护到根的距离 提高

普通并查集只回答「在不在一个集合」。如果还想回答「x 和 y 之间有多远 / 是什么关系」,就要在 parent 之外再维护一个权值数组 d[x]。

⚖️ 核心设定

d[x] 表示「x 到其父亲 parent[x] 的距离」(也可以理解为边权)。

那么「x 到根的距离」= 从 x 爬到根的路上所有 d 之和。find 回溯时必须同步把 d[x] 累加成「到根的距离」。

合并时(把 rb 挂到 ra 下):已知 x 到 ra 距离为 dx,y 到 rb 距离为 dy,且 x 与 y 之间的距离为 w,则 d[rb] = dx + w − dy(画个向量图就能推出来)。

C++带权并查集 · 维护「到根的距离」
int parent[MAXN];
long long d[MAXN];   // d[x] = x 到 parent[x] 的距离(边权)

void init(int n) {
    for (int i = 1; i <= n; i++) { parent[i] = i; d[i] = 0; }
}

// 带权 find:边压缩边把 d 累加成「到根的距离」
int find(int x) {
    if (parent[x] == x) return x;
    int root = find(parent[x]);      // 先递归,parent[x] 已被压到根
    d[x] += d[parent[x]];            // 再累加:x→父 + 父→根
    return parent[x] = root;
}

// 合并:已知 x 与 y 距离为 w,把 y 所在树挂到 x 所在树下
void unite(int x, int y, long long w) {
    int rx = find(x), ry = find(y);
    if (rx == ry) return;             // 已同集合,可用于校验矛盾
    parent[ry] = rx;
    d[ry] = d[x] + w - d[y];         // 关键推导公式
}

// 查询 x 与 y 的距离(前提:同一集合)
long long dist(int x, int y) {
    if (find(x) != find(y)) return -1;  // 不连通
    return d[x] - d[y];               // 到根距离相减
}
rx x y d[x] d[y] w 向量关系:d[ry] = d[x] + w − d[y]
图 2 · 带权合并的向量推导
  1. 从 rx 走到 x,距离是 d[x]
  2. 约束「x 与 y 之间距离为 w」给出:y 到 rx 的距离 = x 到根的距离 + w,即 d[x] + w
  3. 合并后 y 仍先走到 ry 再到 rx:y 到 rx 的距离 = d[y] + d[ry]
  4. 两条路径必须相等:d[y] + d[ry] = d[x] + w
  5. 整理即 d[ry] = d[x] + w − d[y]——与代码中 d[ry] = d[x] + w - d[y] 完全一致
图 2 · 带权并查集的合并公式可以在纸上画向量图 5 秒推出来,考场上不必死记,但要记牢「路径压缩必须同步累加 d」。
带权并查集的两种经典题型:① 维护「到根的距离」,典型是 银河英雄传说(求两艘战舰之间隔了多少艘);② 维护「与根的关系」,典型是 食物链(用 0=同类、1=吃、2=被吃三种关系做模 3 加法)。
6可撤销并查集(概念提及)

有些题目需要「加边 → 撤销加边 → 再加边」,普通并查集的路径压缩会破坏历史状态,无法回滚。解决办法是 放弃路径压缩,只用按秩合并,并用一个栈记录每次改动。

C++可撤销并查集核心思路
int parent[MAXN], sz[MAXN];
struct Op { int son, fa, szFa; };  // 记录被改动的位置
vector<Op> hist;                     // 历史栈

// ⚠️ 不写路径压缩!只做按秩合并,才能回滚
int find(int x) {
    while (parent[x] != x) x = parent[x];
    return x;                       // 复杂度 O(log n),可以接受
}

bool unite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra == rb) { hist.push_back({0,0,0}); return false; } // 占位
    if (sz[ra] < sz[rb]) swap(ra, rb);
    hist.push_back({rb, ra, sz[ra]}); // 记下本次改动
    parent[rb] = ra;
    sz[ra] += sz[rb];
    return true;
}

// 撤销一步:把栈顶记录还原
void rollback() {
    Op o = hist.back(); hist.pop_back();
    if (o.son == 0) return;
    parent[o.son] = o.son;
    sz[o.fa] = o.szFa;
}
应用场景:线段树分治(把每个元素的存在时间划分成若干区间挂到线段树上,DFS 时加边、回溯时撤销)、动态图连通性离线处理。看到「按时间顺序加边 / 删边」的题目,先想可撤销并查集 + 线段树分治。
案例案例1:并查集模板(洛谷 P3367) 模板

题目:给定 n 个元素,初始各自独立。m 次操作,每次给出 z x y:若 z=1,把 x、y 所在集合合并;若 z=2,询问 x、y 是否在同一集合,输出 Y 或 N。n ≤ 104,m ≤ 2×105。

🔍 详细分析过程

第 1 步:识别模型。「合并两个元素所在集合」+「询问是否同集合」——是并查集的定义式问题,没有任何变形。

第 2 步:优化取舍。m 高达 2×105,必须路径压缩 + 按秩合并,否则链式数据会退化。

第 3 步:实现细节。用递归版 find 最简洁;数组开 MAXN+5;输入用 scanf 或关闭同步的 cin。

第 4 步:输出。只有 z=2 时需要输出,每行一个字符。

复杂度:O(m·α(n)) ≈ O(m),2×105 次操作轻松通过。

C++P3367 并查集模板 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 10005;
int parent[MAXN], sz[MAXN];

int find(int x) {
    if (parent[x] == x) return x;
    return parent[x] = find(parent[x]);   // 路径压缩
}

void unite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra == rb) return;
    if (sz[ra] < sz[rb]) swap(ra, rb);   // 按大小合并
    parent[rb] = ra;
    sz[ra] += sz[rb];
}

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) { parent[i] = i; sz[i] = 1; }
    while (m--) {
        int z, x, y;
        cin >> z >> x >> y;
        if (z == 1) {
            unite(x, y);
        } else {
            cout << (find(x) == find(y) ? "Y" : "N") << '\n';
        }
    }
    return 0;
}
运行结果
输入: 4 7
      2 1 2
      1 1 2
      2 1 2
      1 3 4
      2 1 3
      1 2 3
      2 1 4
输出: N
      Y
      N
      Y

💡 逐步验证

① 2 1 2:1 与 2 不同根 → N

② 1 1 2:合并 {1,2}

③ 2 1 2:同根 → Y

④ 1 3 4:合并 {3,4}

⑤ 2 1 3:{1,2} 与 {3,4} 不同 → N

⑥ 1 2 3:合并成 {1,2,3,4}

⑦ 2 1 4:同根 → Y ✓

🔍 代码逐段解析 · P3367 并查集模板
1
MAXN = 10005:n ≤ 104,数组开到 10005 留一点余量;下标从 1 开始用,0 号位弃用防越界混淆。
2
find 递归出口:if (parent[x] == x) return x; —— 「谁的爹是自己谁就是根」,递归必须先写出口,否则链上死循环。
3
return parent[x] = find(parent[x]); —— 一行三件事:递归找根、把 parent[x] 改成根(路径压缩)、返回根。赋值表达式的值就是右值,所以能这么写。
4
unite 先找双根:ra = find(a), rb = find(b)。必须挂在根上,直接 parent[a]=b 会劫持 a 的整棵子树。
5
if (ra == rb) return; —— 同根说明已连通,什么都不做。漏判会在按大小合并时把 sz 加重。
6
if (sz[ra] < sz[rb]) swap(ra, rb); —— 保证 ra 是更大的树,然后 parent[rb]=ra 小挂大、sz[ra]+=sz[rb] 更新块大小。swap 后 rb 一定是「被挂的小树根」。
7
main 初始化:循环里同时给 parent[i]=i; sz[i]=1; —— 每个点自成一派且块大小为 1。忘 init 是本类题第一大错。
8
z==2 分支:输出 find(x)==find(y) ? "Y" : "N"。注意比较的是两次 find 的结果,不是 parent[x]==parent[y]。
样例 4 7 已实测:输出 N Y N Y,与运行结果块一致。
案例案例2:连通块计数 高频

题目:给定 n 个点和 m 条无向边,求最终图中有多少个连通块。n ≤ 105,m ≤ 2×105。

🔍 两种方法对比

方法一 BFS/DFS 染色:建邻接表,从每个未访问点出发跑一次遍历,遍历次数就是连通块数。时间复杂度 O(n+m),空间 O(n+m)。缺点:需要存图,若题目是「边一条条动态给出」或只需块数,存图是浪费。

方法二 并查集:把每条边视作一次 unite(u,v),维护全局 cnt 初值为 n,每次成功合并就 −1。时间 O(m·α(n)),空间 O(n)。优点:代码短、常数小、边读边算不需要存图;天然支持「动态加边」。

结论:纯计数用并查集;若还需要知道每个块里有哪些点、或需要块内 BFS 顺序,才用方法一。

C++连通块计数 — 方法二(并查集)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int parent[MAXN], sz[MAXN], cnt;

int find(int x) {
    return parent[x] == x ? x : parent[x] = find(parent[x]);
}

int main() {
    ios::sync_with_stdio(0);
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) { parent[i] = i; sz[i] = 1; }
    cnt = n;                              // 初始 n 个孤立点
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        int ru = find(u), rv = find(v);
        if (ru == rv) continue;            // 已连通,块数不变
        if (sz[ru] < sz[rv]) swap(ru, rv);
        parent[rv] = ru;
        sz[ru] += sz[rv];
        cnt--;                            // 两块合一
    }
    cout << cnt << '\n';
    return 0;
}
运行结果
输入: 6 4
      1 2
      2 3
      4 5
      1 3
输出: 3

说明: {1,2,3}、{4,5}、{6} 三个连通块。
      第 4 条边 (1,3) 两端已连通,cnt 不变。

🔍 代码逐段解析 · 连通块计数

全局区(第 5~6 行) —— cnt 是全局连通块计数器;sz 仅用于按大小合并,本题不查询块大小但保留可保证树更矮。

find(第 9 行) —— 三目运算符一行版路径压缩,与递归版完全等价,考场可用。

初始化(第 17~18 行) —— cnt = n 是本题灵魂:n 个孤立点就是 n 个连通块。

读边循环(第 19~27 行) —— 先 find 出双根:ru==rv 时 continue(块数不变,绝不能 cnt--);异根时小挂大并 cnt--,两块并一块。

易错点 —— ①ru==rv 时误减 cnt,答案偏小;②忘了 cnt=n 只输出 0。样例 6 4 已实测输出 3。

案例案例3:带权并查集简例 进阶

题目:n 个学生排成一列。共给出 m 条信息,每条形如 x y w,表示「x 站在 y 右边第 w 个位置」(即 x 的编号位置比 y 大 w)。问最多能确定多少条「有效信息」——若某条信息与之前推出的结论矛盾则丢弃,最后输出「被保留的信息条数」。w ∈ [1, 1000]。

🔍 分析:为什么需要带权并查集?

如果只关心「x 和 y 有没有关系」,用普通并查集即可;但这里每条信息都带着一个相对位置差 w,需要判断「新信息与已有信息是否自相矛盾」。

做法:维护 d[x] = x 相对其所在树根的位置偏移。当 x、y 已在同一棵树时,直接校验 d[x] − d[y] == w:

· 相等 → 信息冗余但一致,保留;
· 不等 → 矛盾,丢弃这条信息。

当 x、y 不在同一棵树时,说明这条信息带来了新约束,合并两棵树,并按向量关系更新 d[ry]。

注意:路径压缩时 d[x] 必须同步累加 d[parent[x]],否则会算错偏移量——这是带权并查集最常见的错因。

C++带权并查集 · 校验相对位置 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int parent[MAXN];
long long d[MAXN];        // d[x] = x 相对父亲的偏移

int find(int x) {
    if (parent[x] == x) return x;
    int root = find(parent[x]);   // 先让父节点压到根
    d[x] += d[parent[x]];         // 再把「父到根」的偏移加进来
    return parent[x] = root;
}

int main() {
    ios::sync_with_stdio(0);
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) { parent[i] = i; d[i] = 0; }
    int ok = 0;
    while (m--) {
        int x, y; long long w;
        cin >> x >> y >> w;          // x 比 y 大 w
        int rx = find(x), ry = find(y);
        if (rx == ry) {
            if (d[x] - d[y] == w) ok++;   // 一致,保留
            // 不一致则丢弃,不计数
        } else {
            parent[ry] = rx;
            d[ry] = d[x] - w - d[y];       // 向量推导:让 d[x]-d[y]=w
            ok++;
        }
    }
    cout << ok << '\n';
    return 0;
}
运行结果
输入: 4 4
      1 2 3
      2 3 2
      3 4 1
      1 4 5
输出: 3

验证: 前三条依次合并,推出 d[1]-d[4] = 3+2+1 = 6;
      第 4 条要求 1 比 4 大 5,与链上推出的 6 矛盾,
      故第 4 条被丢弃,保留 3 条,输出 3。

💡 推导演示(以样例为准)

① 1 2 3:合并,设 d[1]-d[2]=3。

② 2 3 2:合并,d[2]-d[3]=2。

③ 3 4 1:合并,d[3]-d[4]=1。

④ 此时 d[1]-d[4] = 3+2+1 = 6。

⑤ 1 4 5 要求 d[1]-d[4] = 5 ≠ 6,矛盾 → 丢弃。

答案 = 3。这正是「食物链」类题目的核心判定逻辑。

⚖️ 动画:带权并查集 d[] 数组逐步演化(样例 4 条信息)
1
初始状态:parent[i]=i, d[i]=0(1~4 各自独立,自己到自己偏移为 0)。
2
信息① 1 2 3:异根,合并 → parent[2]=1,d[2]=d[1]−3−d[2]=0−3−0=−3。校验:d[1]−d[2]=0−(−3)=3 ✓。
3
信息② 2 3 2:find(2)=1(d[2]=−3 不变)。parent[3]=1,d[3]=d[2]−2−0=−5。校验:d[2]−d[3]=−3−(−5)=2 ✓。
4
信息③ 3 4 1:find(3)=1。parent[4]=1,d[4]=d[3]−1−0=−6。校验:d[3]−d[4]=−5−(−6)=1 ✓。
5
信息④ 1 4 5:1 与 4 已同根!只能校验:d[1]−d[4]=0−(−6)=6 ≠ 5 → 矛盾,丢弃。
6
结论:保留 3 条,输出 3。注意 d[x] 存的是「到父亲的偏移」,跨点比较必须用「到根的偏移」d[x]−d[y]——这就是路径压缩时必须累加 d 的原因。
每一步的 d 值均已按课件代码逐条手算并用 g++ 实测复核(输出 3)。

🔍 代码逐段解析 · 带权并查集校验相对位置

第 4~5 行(数组定义) —— parent[] 存父指针;d[] 用 long long 存「x 到父亲的偏移」,偏移量链式累加可达 1011,用 int 会溢出。

find 函数(第 7~11 行) —— 三步顺序不可颠倒:①先递归 find(parent[x]) 让父节点先压到根;②再 d[x] += d[parent[x]],此时 d[parent[x]] 已是「父到根」的偏移,加上「x 到父」即得「x 到根」;③最后 return parent[x] = root 把 x 直连根。易错点:先改 parent[x] 再累加,d[parent[x]] 就变成了错误的值。

main 初始化(第 17 行) —— parent[i]=i, d[i]=0:自根偏移为 0。

同根分支(第 24~27 行) —— d[x]−d[y] 是「x 相对 y 的偏移」,与 w 相等则该信息与已有结论一致,ok++ 保留;不等则静默丢弃(不计数也不报错)。

异根分支(第 28~32 行) —— parent[ry]=rx 挂根后,由向量关系 d[y]+d[ry] = d[x]−w 解出 d[ry]=d[x]−w−d[y],保证新约束恰好成立。

易错点 —— 合并公式方向极易写错:本题约定「x 比 y 大 w」对应 d[ry]=d[x]−w−d[y];若约定方向反过来(如知识探究第 5 节的 unite),公式就是 d[ry]=d[x]+w−d[y]。考场上现推 30 秒,比背公式可靠。

模块 03

实践应用

三道完整题目,覆盖并查集的「判定型 / 计数型 / 冲突型」三种典型问法。

题1亲戚判定(洛谷 P1551 思路)
题 1并查集 · 判定

题目背景

若某个家族人数过于庞大,要判断两个人是否为亲戚确实不容易。现给出若干对亲戚关系,问任意两人是否为亲戚。

输入格式

第 1 行三个整数 n、m、p,表示 n 个人、m 对亲戚关系、p 次询问。接下来 m 行每行两个整数 x、y,表示 x 与 y 是亲戚。接下来 p 行每行两个整数,表示一次询问。

输出格式

共 p 行,每行一个 Yes 或 No。

输入样例

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 ≤ 5000(原题);加强到 n, m, p ≤ 105 时 1s / 256MB 仍可轻松通过。

🔍 分析

「亲戚关系」具有传递性:如果 x 与 y 是亲戚、y 与 z 是亲戚,则 x 与 z 也是亲戚。「亲戚」构成了一个等价关系,正好用并查集维护。

方法一 建无向图 + 每次询问 BFS:单次询问 O(n+m),p 次共 O(p(n+m)),n=m=p=105 时约 1015,必然超时。

方法二 并查集:读入关系时合并,之后每次询问 O(α(n))。总复杂度 O((m+p)·α(n)),105 规模瞬间通过。这就是并查集存在的意义。

C++亲戚判定 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int parent[MAXN], sz[MAXN];

int find(int x) {
    return parent[x] == x ? x : parent[x] = find(parent[x]);
}

void unite(int a, int b) {
    int ra = find(a), rb = find(b);
    if (ra == rb) return;
    if (sz[ra] < sz[rb]) swap(ra, rb);
    parent[rb] = ra;
    sz[ra] += sz[rb];
}

int main() {
    ios::sync_with_stdio(0);
    int n, m, p;
    cin >> n >> m >> p;
    for (int i = 1; i <= n; i++) { parent[i] = i; sz[i] = 1; }
    for (int i = 0; i < m; i++) {
        int x, y;
        cin >> x >> y;
        unite(x, y);
    }
    while (p--) {
        int x, y;
        cin >> x >> y;
        cout << (find(x) == find(y) ? "Yes" : "No") << '\n';
    }
    return 0;
}
运行结果
输入: 6 5 3
      1 2
      1 5
      3 4
      5 2
      1 3
      1 4
      2 3
      5 6
输出: Yes
      Yes
      No

💡 为什么 (1,4) 是 Yes?

合并过程:{1,2} → {1,2,5} → {3,4} → {1,2,5}(5 与 2 已同集合)→ {1,2,3,4,5}。

最后 1 与 4 同根 → Yes;2 与 3 同根 → Yes;5 与 6 不同根 → No。✓

🔍 代码逐段解析 · 亲戚判定

find / unite(第 6~16 行) —— 与模板完全一致:三目一行路径压缩 + 按大小合并。亲戚关系是等价关系(自反、对称、传递),正是并查集能维护的关系类型。

读入关系(第 25~30 行) —— 每对关系一次 unite(x,y),无需任何额外判断。

回答询问(第 31~35 行) —— 每次 find(x)==find(y),输出 Yes/No。路径压缩后绝大多数询问一步到位。

易错点 —— 输出大小写要看清题目要求(本题 Yes/No,P3367 是 Y/N);多组询问不要在中间重复 init。

题2村村通(洛谷 P1536 思路)
题 2并查集 · 连通块计数

题目背景

某市调查城镇交通状况,得到现有城镇道路统计表。目标是让全市任何两个城镇间都可以实现交通(不一定有直接道路,间接也可),问至少还需建设多少条道路。

输入格式

包含多组数据。每组第 1 行两个整数 n、m(n 为城镇数,m 为已有道路数,1 ≤ n ≤ 1000);接下来 m 行每行两个整数表示一条道路。当 n = 0 时输入结束。

输出格式

每组数据一个整数,表示最少还需建设的道路数。

输入样例

4 2
1 3
4 3
3 3
1 2
1 3
2 3
5 2
1 2
3 5
0

输出样例

1
0
2

数据范围

n ≤ 1000,m 不限(读入到 n=0 结束);多组数据总规模可视为 105 级别。1s / 256MB。

🔍 关键结论

把所有已有道路用并查集合并后,如果最终有 k 个连通块,那么最少只需再加 k−1 条道路(把 k 个块串成一条链)。

为什么是 k−1?每加一条边最多让连通块数减少 1,从 k 降到 1 至少要 k−1 次;而取每个块的代表元依次相连,恰好 k−1 次就能连通,所以下界可达。

易错 别把答案写成 k,也别在 n=0 时输出。多组数据要记得每轮重新 init。

C++村村通 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;
int parent[MAXN];

int find(int x) {
    return parent[x] == x ? x : parent[x] = find(parent[x]);
}

int main() {
    ios::sync_with_stdio(0);
    int n;
    while (cin >> n && n != 0) {
        for (int i = 1; i <= n; i++) parent[i] = i;
        int m;
        cin >> m;
        while (m--) {
            int u, v;
            cin >> u >> v;
            int ru = find(u), rv = find(v);
            if (ru != rv) parent[rv] = ru;   // 合并,不需要 size
        }
        int k = 0;
        for (int i = 1; i <= n; i++) if (find(i) == i) k++;
        cout << k - 1 << '\n';        // k 个连通块需要 k-1 条边
    }
    return 0;
}
运行结果
输入: 4 2 / 1 3 / 4 3   → 块数 2({1,3,4} 与 {2}),答案 1
      3 3 / 1 2 / 1 3 / 2 3 → 块数 1,答案 0
      5 2 / 1 2 / 3 5  → 块数 3({1,2},{3,5},{4}),答案 2
输出: 1
      0
      2

🔍 代码逐段解析 · 村村通

外层 while(第 12 行) —— while (cin >> n && n != 0) 多组数据读入,n=0 结束;每组必须重新 init(第 14 行循环),这是多测题第一易错点。

合并边(第 17~22 行) —— 直接 if (ru != rv) parent[rv] = ru;,本题不查询块大小,可以不写按大小合并(n ≤ 1000 数据弱),但写上更稳。

统计块数(第 23~24 行) —— 事后统计法:find(i)==i 的 i 就是根,数出 k 个块。

输出 k−1(第 25 行) —— k 个块最少加 k−1 条边串起来。若某组 n=1,则 k=1,输出 0,恰好也对。

易错点 —— 忘记多测清空 parent 数组(上一组的合并残留导致答案错乱);或把答案算成 k。

题3排座位冲突(带权并查集综合)
题 3带权并查集 · 矛盾检测

题目背景

教师想让 n 位同学按身高从矮到高坐成一排。同学们七嘴八舌地报来 m 条「情报」,每条形如「同学 x 比同学 y 靠右 w 个位置」。情报可能出错,需要找出第一条自相矛盾的情报编号。

输入格式

第 1 行两个整数 n、m(1 ≤ n ≤ 104,1 ≤ m ≤ 105)。接下来 m 行,每行三个整数 x、y、w,含义如上(−106 ≤ w ≤ 106)。

输出格式

若有矛盾,输出第一条矛盾情报的编号(从 1 开始);若全部一致,输出 -1。

输入样例

4 5
1 2 3
2 3 -1
1 3 2
3 4 1
1 4 1

输出样例

5

数据范围

n ≤ 104,m ≤ 105,w 可正可负;答案与偏移量必须用 long long 存储防止溢出——w 最大 106,m 条信息链式累加后偏移量可达 1011,远超 int 范围。1s / 256MB。

🔍 分析

这是「带权并查集 + 第一个矛盾位置」的标准问法,与 P2024 食物链、P1525 关押罪犯 的判定逻辑同源。

维护量:d[x] = x 相对所在树根的位置偏移。同根时 d[x] − d[y] 即为 x 相对 y 的偏移。

判定规则:若 find(x) == find(y),比较 d[x] − d[y] 与 w:相等则一致,不等则立刻输出本条编号并退出。

合并规则:异根时 parent[ry] = rx; d[ry] = d[x] − w − d[y];,使新约束恰好成立。

为什么不能只维护「同集合」?因为情报的语义是「相差 w」,是量化关系。普通并查集只能判断「有没有说过话」,说不出「差多少」,无法检测矛盾。

C++排座位冲突 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 10005;
int parent[MAXN];
long long d[MAXN];      // d[x] = x 相对父亲的偏移

int find(int x) {
    if (parent[x] == x) return x;
    int root = find(parent[x]);
    d[x] += d[parent[x]];          // 关键:同步累加偏移
    return parent[x] = root;
}

int main() {
    ios::sync_with_stdio(0);
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) { parent[i] = i; d[i] = 0; }
    for (int i = 1; i <= m; i++) {
        int x, y; long long w;
        cin >> x >> y >> w;
        int rx = find(x), ry = find(y);
        if (rx == ry) {
            if (d[x] - d[y] != w) {        // 矛盾!
                cout << i << '\n';
                return 0;
            }
        } else {
            parent[ry] = rx;
            d[ry] = d[x] - w - d[y];        // 建立新约束
        }
    }
    cout << -1 << '\n';                 // 全部一致
    return 0;
}
运行结果
输入: 4 5
      1 2 3
      2 3 -1
      1 3 2
      3 4 1
      1 4 1
输出: 5

验证: ①② → d[1]-d[3] = 3 + (-1) = 2,与第 3 条一致。
      ③④ → d[1]-d[4] = 2 + 1 = 3。
      第 5 条要求 d[1]-d[4] = 1 ≠ 3 → 矛盾,输出 5。

💡 如果改成「判断两个人是否必须是同一队」怎么办?

那就把「偏移量」换成「模 2 的关系」:d[x] = 0 表示与父亲同队,d[x] = 1 表示不同队。合并与查询的运算全部改为模 2 加法,这就是经典的 P1525 关押罪犯(二分答案 + 扩展域并查集) 与 P2024 食物链(模 3 关系) 的做法。

核心思想:并查集维护的不只是「连通性」,还可以是任意满足传递性的代数关系。

🔍 代码逐段解析 · 排座位冲突

find(第 8~12 行) —— 带权版三步曲:先递归、再 d[x] += d[parent[x]]、最后 return parent[x] = root。与易错 4 的口诀一一对应。

循环编号(第 18 行) —— for (int i = 1; i <= m; i++) 用 i 记录情报编号,矛盾时要输出的就是它。

同根校验(第 21~25 行) —— d[x] - d[y] != w 即矛盾,立刻输出 i 并 return 0——「第一条矛盾」要求找到就停,后面不必再读。

异根合并(第 26~29 行) —— d[ry] = d[x] - w - d[y]:本题约定「x 比 y 大 w」即 d[x]−d[y]=w,解出 d[ry] 让约束成立。

兜底输出(第 30 行) —— 全部读完无矛盾,输出 −1。

易错点 —— ①输出编号后忘记 return,多输出 −1;②w 与偏移量没开 long long。样例已实测:矛盾发生在第 5 条,输出 5。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心复杂度
森林表示集合 = 一棵树,根 = 代表元,parent[x]==x空间 O(n)
find沿 parent 上溯到自指节点,递归版一行搞定O(α(n))
union先求两根,同根返回 false,异根挂接O(α(n))
路径压缩parent[x] = find(parent[x])均摊 O(log n)
按秩/按大小合并小树挂大树,sz[ra] += sz[rb]树高 O(log n)
两者结合工程上等价于 O(1),α(n) ≤ 4O(m·α(n))
连通块计数初值 n,每次成功合并 −1;或统计根个数O(1) / O(n)
带权并查集压缩时 d[x] += d[parent[x]];合并 d[ry]=d[x]+w−d[y]O(α(n))
可撤销并查集不压缩 + 按秩合并 + 栈记录改动的三要素O(log n)
C++万能模板 · 普通并查集(背下来)
const int MAXN = 100005;
int parent[MAXN], sz[MAXN];

void init(int n) {
    for (int i = 1; i <= n; i++) { parent[i] = i; sz[i] = 1; }
}

int find(int x) {                       // 路径压缩
    return parent[x] == x ? x : parent[x] = find(parent[x]);
}

bool unite(int a, int b) {               // 按大小合并
    int ra = find(a), rb = find(b);
    if (ra == rb) return false;
    if (sz[ra] < sz[rb]) swap(ra, rb);
    parent[rb] = ra;
    sz[ra] += sz[rb];
    return true;
}

bool same(int a, int b) { return find(a) == find(b); }

🔍 代码逐段解析 · 普通并查集模板

init —— parent 与 sz 必须同时初始化;只初始化 parent,第一次按大小合并会拿 sz=0 做比较,等于随机挂接。

find —— 一行版:parent[x]==x ? x : parent[x]=find(parent[x]),递归出口与路径压缩合二为一,考场默写最优形式。

unite —— 返回 bool 是刻意的:调用方靠它判断「这次是否真的合并了」,连通块计数、Kruskal 选边都依赖这个返回值。

same —— 千万别写成 parent[a]==parent[b](比爹不比根)。

易错点 —— 多组数据忘 init;swap 后写反挂接方向(把大树挂到小树下)。

C++万能模板 · 带权并查集(背下来)
int parent[MAXN];
long long d[MAXN];                       // 到父亲的权值

void init(int n) {
    for (int i = 1; i <= n; i++) { parent[i] = i; d[i] = 0; }
}

int find(int x) {
    if (parent[x] == x) return x;
    int root = find(parent[x]);
    d[x] += d[parent[x]];                 // ★ 压缩必须同步累加
    return parent[x] = root;
}

// 已知 y 相对 x 的权值为 w,合并且使约束成立
void unite(int x, int y, long long w) {
    int rx = find(x), ry = find(y);
    if (rx == ry) return;               // 同根时用 d[x]-d[y] 校验
    parent[ry] = rx;
    d[ry] = d[x] + w - d[y];              // ★ 关键合并公式
}

🔍 代码逐段解析 · 带权并查集模板

find 三步曲 —— ①root = find(parent[x]) 先让父节点压到根;②d[x] += d[parent[x]] 把「x→父」加上「父→根」;③return parent[x] = root 改挂根。顺序错一步,全盘皆输。

unite 参数 w —— 语义是「y 相对 x 的权值」:公式 d[ry] = d[x] + w − d[y] 由向量关系 d[y]+d[ry] = d[x]+w 解出,用前先画 30 秒向量图确认方向。

同根时 —— unite 直接 return,调用方应改用 d[x]−d[y] 做校验(矛盾检测),见排座位 / 食物链。

易错点 —— ①d 数组类型:距离型用 long long,关系型(模 k)用 int;②压缩时先改 parent 再累加 d。

②拓展案例
拓1银河英雄传说(带权并查集经典)

题意简述:初始有 30000 艘战舰,每艘单独成一列。两种指令:M i j 把 i 所在整列接到 j 所在列之后;C i j 询问 i、j 之间隔了多少艘战舰(同列时),不同列输出 −1。

思路:这是带权并查集的「样板题」。

维护两个量:d[x] = x 到所在列队头的距离(即前面有几艘船);sz[root] = 该列总长度。

合并 M i j:把 i 的队头接到 j 的队尾之后。d[ri] = sz[rj](i 原来前面有 sz[rj] 艘),sz[rj] += sz[ri]。

查询 C i j:同根时答案 = |d[i] − d[j]| − 1(中间间隔的船数 = 距离差再减 1)。

与普通并查集的差别:合并时 i 的整列必须"整体平移",靠 d 数组在路径压缩时累加实现,等价于 O(1) 平移整列。这正是带权并查集巧妙的地方。

C++银河英雄传说 — 核心代码
const int N = 30005;
int parent[N], sz[N], d[N];   // d[x]: x 前面有几艘船

int find(int x) {
    if (parent[x] == x) return x;
    int root = find(parent[x]);
    d[x] += d[parent[x]];      // 累加到队头的距离
    return parent[x] = root;
}

void merge(int i, int j) {      // 把 i 列接到 j 列后面
    int ri = find(i), rj = find(j);
    if (ri == rj) return;
    parent[ri] = rj;
    d[ri] = sz[rj];            // i 列整体排在 j 列之后
    sz[rj] += sz[ri];
}

int query(int i, int j) {
    if (find(i) != find(j)) return -1;
    return abs(d[i] - d[j]) - 1;
}
运行结果
输入: 4
      M 2 3
      C 1 2
      M 2 4
      C 4 2
输出: -1
      1

说明: 第 2 行时 1 与 2 不同列 → -1;
      第 3 行后顺序为 [4,3,2](2 所在列整体接到 4 之后),
      4 与 2 之间隔 1 艘船 → 1。
拓2程序自动分析(离散化 + 并查集) NOI2015

题意简述:给出 n 个形如 x = y 或 x ≠ y 的约束,问是否存在一组赋值使所有约束同时成立。x、y 的取值可达 109。

思路(两阶段贪心):

阶段一:把所有 x = y 的约束用并查集合并(相等具有传递性,可以随便合)。

阶段二:再检查所有 x ≠ y 的约束。若发现某个约束的两端 find(x) == find(y),说明「又有 x=y 又有 x≠y」,矛盾,输出 NO;全部通过则 YES。

关键技巧:x、y 高达 109,但实际出现的不同取值不超过 2n 个,所以先离散化映射到 1..2n,再用大小为 2n 的并查集。

正确性直觉:「相等」是等价关系,可以随意合并;而「不等」会破坏等价类,必须最后检查——先合后验的顺序不可颠倒。

C++程序自动分析 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
int parent[MAXN];
struct Node { int x, y, e; } a[MAXN];

int find(int x) {
    return parent[x] == x ? x : parent[x] = find(parent[x]);
}

int main() {
    ios::sync_with_stdio(0);
    int T; cin >> T;
    while (T--) {
        int n; cin >> n;
        vector<int> v;
        for (int i = 0; i < n; i++) {
            cin >> a[i].x >> a[i].y >> a[i].e;
            v.push_back(a[i].x); v.push_back(a[i].y);
        }
        sort(v.begin(), v.end());
        v.erase(unique(v.begin(), v.end()), v.end());   // 离散化
        int m = v.size();
        for (int i = 1; i <= m; i++) parent[i] = i;
        auto id = [&](int val) {
            return lower_bound(v.begin(), v.end(), val) - v.begin() + 1;
        };
        // 阶段一:先处理所有「相等」
        for (int i = 0; i < n; i++)
            if (a[i].e == 1) parent[find(id(a[i].x))] = find(id(a[i].y));
        // 阶段二:再校验所有「不等」
        bool ok = true;
        for (int i = 0; i < n && ok; i++)
            if (a[i].e == 0 && find(id(a[i].x)) == find(id(a[i].y))) ok = false;
        cout << (ok ? "YES" : "NO") << '\n';
    }
    return 0;
}
运行结果
输入: 2
      2
      1 2 1
      1 2 0
      2
      1 2 1
      2 3 1
输出: NO
      YES

说明: 第一组同时要求 1=2 且 1≠2,矛盾。
      第二组只有相等约束,显然可行。

🔍 代码逐段解析 · 程序自动分析

结构体数组(第 6~7 行) —— 把 n 条约束先整体存下(x, y, e,e=1 相等 / e=0 不等),因为必须先合并完所有相等、再检查所有不等,不能边读边做。

离散化(第 16~22 行) —— 取值高达 109 但最多 2n 个不同值:排序 + unique 去重,lambda id(val) 用 lower_bound 把原值映射到 1..m。

阶段一(第 25~26 行) —— 只处理 e==1:parent[find(id(x))] = find(id(y)) 直接把两根相连。相等是等价关系,先合不亏。

阶段二(第 28~30 行) —— 逐条检查 e==0,两端同根即矛盾,置 ok=false 并用 && ok 提前结束循环。

易错点 —— ①顺序颠倒:先检查不等再合并相等,会把本可满足的数据判成 NO;②离散化数组只开 n 不够(最多 2n 个不同值)。

拓3最小生成树 Kruskal(并查集的最大舞台)

题意简述:给定 n 个点 m 条带权无向边,求连通所有点的最小总权值和(最小生成树)。

思路(Kruskal 算法):把所有边按权值从小到大排序,依次考察每条边:用并查集判断它的两个端点是否已经连通——不连通就选它并合并,已连通就跳过(选它会成环)。选满 n−1 条边即得最小生成树。

正确性直觉(反证):假设最优解不含当前这条最小可用边 e,那么把 e 加入最优解必然成环;环上必有一条不短于 e 的边(因为 e 是连通两侧的最小边),删掉它得到不更差且含 e 的解——矛盾。

复杂度:排序 O(m log m) + 并查集 O(m·α(n)),是稀疏图上最快的 MST 算法,也是并查集最经典的应用。

C++Kruskal — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 5005, MAXM = 200005;
struct Edge { int u, v, w; } e[MAXM];
int parent[MAXN], sz[MAXN];

int find(int x) {
    return parent[x] == x ? x : parent[x] = find(parent[x]);
}

int main() {
    ios::sync_with_stdio(0);
    int n, m; cin >> n >> m;
    for (int i = 0; i < m; i++) cin >> e[i].u >> e[i].v >> e[i].w;
    sort(e, e + m, [](const Edge &a, const Edge &b) { return a.w < b.w; });
    for (int i = 1; i <= n; i++) { parent[i] = i; sz[i] = 1; }
    long long sum = 0; int pick = 0;
    for (int i = 0; i < m && pick < n - 1; i++) {
        int ru = find(e[i].u), rv = find(e[i].v);
        if (ru == rv) continue;          // 成环,跳过
        if (sz[ru] < sz[rv]) swap(ru, rv);
        parent[rv] = ru; sz[ru] += sz[rv];
        sum += e[i].w; pick++;
    }
    if (pick < n - 1) cout << "orz" << '\n';  // 图不连通
    else cout << sum << '\n';
    return 0;
}
运行结果
输入: 4 5
      1 2 2
      1 3 2
      1 4 3
      2 3 4
      3 4 3
输出: 7

说明: 选 (1,2)=2、(1,3)=2、(1,4)=3,共 7,
      三条边把 4 个点连通,即为最小生成树。

🔍 代码逐段解析 · Kruskal 最小生成树

排序(第 14 行) —— lambda 按 a.w < b.w 升序;这是贪心的前提:每次都考虑当前最便宜的边。

init(第 15 行) —— parent 与 sz 同时初始化,注意在排序之后做也一样,但必须在合并循环之前。

合并循环(第 17~23 行) —— pick < n-1 是提前剪枝:选满 n−1 条即可停。两端同根 continue(选了成环);异根则小挂大、sum += e[i].w、pick++。

判不连通(第 24~25 行) —— 循环结束 pick 仍 < n−1,说明图不连通,按洛谷 P3366 惯例输出 orz。

易错点 —— ①sum 不开 long long:m 条边权各 105 级别时总和会爆 int;②把判环写在合并之后(永远同根)。

③思考与启发

💭 思考 1:路径压缩会不会破坏「历史信息」?什么时候不能压缩?

查看参考答案

会。路径压缩把树的结构改得面目全非,且改变不可逆(除非额外记录),因此需要「回滚」的场景下禁止使用路径压缩。

典型场景:① 可撤销并查集;② 线段树分治(DFS 加边 + 回溯删边);③ 需要保存并查集历史快照做二分的题目。

替代方案:只保留按秩合并,此时树高严格 O(log n),单次操作 O(log n),m 次 O(m log n),对大多数题目仍然够用。

启发:「优化」往往是在交换——用「可逆性」换「速度」。工程中如何权衡,取决于问题是否要求撤销。

💭 思考 2:并查集能不能回答「x 和 y 是否在同一集合」以外的问题?

查看参考答案

能,只要关系具有「可传递 + 可合并」的代数结构。

① 带权(距离型):维护到根的偏移,可回答「相隔多远」——银河英雄传说、排座位。

② 带权(模 k 关系型):维护与根的模 k 关系,可回答「你吃我还是我吃你」——食物链(k=3)、关押罪犯(k=2)。

③ 集合大小:维护 sz,可回答「你所在集合有多少人」——合并果子变体、朋友圈统计。

④ 集合属性聚合:维护最大值/最小值/异或和等可合并信息,用于「同集合内属性统计」类问题。

关键:并查集本质是「带合并操作的等价类维护结构」。只要你能定义出「等价类之间的可合并运算」,就能套用。

💭 思考 3:为什么「删边」比「加边」难得多?

查看参考答案

因为并查集天然只支持「合并」。加边 = 合并两个集合,一步完成;删边则要把一个集合拆回两个,而并查集根本没有记录集合内部的结构信息,无从拆起。

常见应对策略:

① 离线倒序:把「删边」倒过来看成「加边」,从最终状态出发逆序处理——适合「只有删除」的题目。

② 线段树分治:把每条边的存在时间划分成若干区间挂到线段树上,DFS 时加边、回溯时用可撤销并查集撤销,把「动态删边」转成「静态分治」。

③ 动态树 LCT:真正支持加边删边的数据结构,但代码量是并查集的 20 倍以上,通常只在 NOI 级别出现。

启发:数据结构的能力边界要记清楚。「并查集不删点、不删边、不查具体路径」——这三条红线先背下来,选型时才不会走错路。

模块 05

学习检测

先找错,再背口诀——把并查集最常见的 5 个坑消灭在考试之前。

⚡高频错误诊断
易错1

忘记初始化 parent[i] = i

❌ 错误
全局数组默认全为 0,直接使用 find(5) 会一路爬到 0 再被当成"根",所有点都被误判为同一集合,答案全错。
✅ 正确
使用前必须 for (int i = 1; i <= n; i++) parent[i] = i;,让每个点自成一派。多组数据每轮都要重新 init。
口诀:并查集第一步,人人自指才算数!
易错2

union 挂错对象——挂了普通节点而不是根

❌ 错误
parent[a] = b; 直接把 a 的父亲改成 b。若 a 不是根,a 原来管辖的整棵子树会被"劫持",结构彻底乱掉。
✅ 正确
int ra = find(a), rb = find(b); if (ra != rb) parent[rb] = ra; —— 永远只在两个根之间挂接。
口诀:合并先找根,挂根不挂人!
易错3

判断同集合写成 parent[a] == parent[b]

❌ 错误
if (parent[x] == parent[y])——只比较了"父亲是否相同"。同一棵树上的两个兄弟节点父亲相同但显然是同集合;而祖先与后代同集合却父亲不同。判定完全不成立。
✅ 正确
if (find(x) == find(y))——必须比较各自爬到根的结果。
口诀:比根不比爹,爬到顶才算数!
易错4

带权并查集忘记在路径压缩时累加权值

❌ 错误
int find(int x){ if(parent[x]==x) return x; return parent[x]=find(parent[x]); }——只改了指针,没有 d[x] += d[parent[x]],权重信息丢失,距离全部算错。
✅ 正确
先递归得到根,再 d[x] += d[parent[x]],最后 return parent[x] = root;。顺序不能颠倒:必须先让父节点压到根,它的 d 才是"到根的距离"。
口诀:带权压缩要小心,先递归、后累加、再改指针!
易错5

递归 find 在极端链上爆栈

❌ 错误
n = 106 且恰好构造成长链时,第一次 find 的递归深度达 106,超出默认栈空间导致 RE / Segmentation Fault。
✅ 正确
改用迭代版 find(两趟:先找根,再统一改指针);或加上按秩合并,从源头把树高压到 O(log n) = 20 层以内。
口诀:大数组、深递归,改成迭代保平安!
📋并查集速查表
操作 / 技术核心代码复杂度使用场景
initparent[i] = i; sz[i] = 1;O(n)每组数据开始前
findreturn parent[x]==x?x:parent[x]=find(parent[x]);O(α(n))查根、判连通
uniteif(sz[ra]<sz[rb]) swap; parent[rb]=ra; sz[ra]+=sz[rb];O(α(n))合并两集合
samefind(a) == find(b)O(α(n))同集合判定
连通块计数cnt 初值 n,成功合并则 cnt--O(1)块数统计
根个数统计for i: if(find(i)==i) k++O(n·α(n))事后统计
带权 findint r=find(p[x]); d[x]+=d[p[x]]; return p[x]=r;O(α(n))距离/关系型
带权 unitep[ry]=rx; d[ry]=d[x]+w-d[y];O(α(n))建立相对约束
可撤销不压缩 + 按秩合并 + 栈记录O(log n)线段树分治
题目问法该用什么关键代码
两人是不是亲戚 / 两点连不连通普通并查集find(x) == find(y)
图里最终有几个连通块并查集 + cnt 计数成功合并则 cnt--
至少再加几条边才连通并查集求块数 k答案 = k - 1
两人相隔多少 / 差多少带权并查集(距离型)d[x] - d[y]
是否自相矛盾 / 是否同类带权并查集(关系型)同根时校验 d[x]-d[y]
边按时间加、又要撤销可撤销并查集 + 线段树分治历史栈 rollback()
选边连通所有点且总权最小Kruskal 最小生成树排序后 find 判环
📌知识点笔记卡
🧠 必记概念
  • 集合 = 树,根 = 代表元
  • parent[x]==x ⟺ x 是根
  • find = 爬到根;union = 挂根
  • 路径压缩改结构,按秩合并控高度
  • α(n):反阿克曼函数,实际 ≤ 4
📝 必背模板
  • init:parent[i]=i; sz[i]=1;
  • find:parent[x]==x?x:parent[x]=find(parent[x])
  • unite:小挂大,sz 累加
  • 带权:d[x]+=d[parent[x]] 不可漏
  • 可撤销:不压缩 + 栈记录
⚠️ 考场提醒
  • 多组数据每轮必须重新 init
  • n 要按输入的 n 初始化,不是 MAXN
  • n = 106 时优先迭代版 find
  • 带权偏移量大时用 long long
  • 「删边」不能直接做,想清楚再动笔
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1编程题

实现并查集三件套:init / find(带路径压缩)/ unite(带按大小合并),并读入 n 个点的 m 次合并操作后输出连通块个数。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int p[100005], sz[100005], cnt;
int find(int x){ return p[x]==x?x:p[x]=find(p[x]); }
int main(){
    int n,m; cin>>n>>m;
    for(int i=1;i<=n;i++){p[i]=i;sz[i]=1;}
    cnt=n;
    while(m--){int u,v;cin>>u>>v;
        int ru=find(u),rv=find(v);
        if(ru==rv)continue;
        if(sz[ru]<sz[rv])swap(ru,rv);
        p[rv]=ru; sz[ru]+=sz[rv]; cnt--;
    }
    cout<<cnt<<endl;
}

思路:cnt 初值为 n,每成功合并一次(两个根不同)就减一,最后一次输出即得连通块数。

考点:路径压缩的写法、按大小合并的 swap 方向、cnt 的维护时机。

易错:在 ru==rv 时也执行了 cnt--,导致块数偏小;或者忘记 init 导致全局数组全零。

🔍 代码逐段解析

第 3 行(全局区) —— p、sz、cnt 三个全局量各司其职:父指针、块大小、块计数。

第 4 行(find) —— 一行版路径压缩,本题标准写法。

第 6~7 行(init) —— p[i]=i 与 sz[i]=1 必须同时做;cnt=n 是计数起点。

第 8~13 行(合并循环) —— 双根相同 continue(关键:不扣 cnt);不同则 swap 保证 ru 为大根、小挂大、cnt--。

易错点 —— cnt-- 忘写或重复写;输出用了 endl 无妨,但忘记处理最后一组。

A2概念题

手工模拟:n=6,依次执行 unite(1,2)、unite(2,3)、unite(4,5)、unite(3,4)。若合并时永远让「编号小的当根」(不按大小),请画出最终的树,并回答 find(5) 需要走几步。

查看参考答案

① unite(1,2):根为 1(编号小),parent[2]=1。

② unite(2,3):find(2)=1,find(3)=3,1<3,parent[3]=1。

③ unite(4,5):根为 4,parent[5]=4。

④ unite(3,4):find(3)=1,find(4)=4,1<4,parent[4]=1。

最终树:根为 1,孩子有 2、3、4;4 的孩子是 5。树高 3。

find(5) 路径:5 → 4 → 1,共走 2 步(两次"向上跳")。

考点:理解「编号小当根」并不等于「按大小合并」,前者在特定数据下仍会退化,后者才有树高保证。

A3编程题

写一个程序判断无向图是否存在环:n 个点 m 条边,若某条边的两个端点已经连通,说明这条边与之前的边构成了环,输出 YES,否则 NO。

查看参考答案
int p[100005];
int find(int x){ return p[x]==x?x:p[x]=find(p[x]); }
int main(){
    int n,m; cin>>n>>m;
    for(int i=1;i<=n;i++)p[i]=i;
    bool cyc=false;
    while(m--){int u,v;cin>>u>>v;
        int ru=find(u),rv=find(v);
        if(ru==rv)cyc=true;   // 再加这条边必然成环
        else p[rv]=ru;
    }
    cout<<(cyc?"YES":"NO")<<endl;
}

思路:「加边成环」的充要条件是两端点已在同一连通块。这同时也是 Kruskal 判环的逻辑。

易错:把判断写在合并之后(此时一定同根,永远输出 YES);或只看自环,忽略了间接成环。

🔍 代码逐段解析

第 1~2 行 —— 只需父指针数组 p 和 find,本题不需要 sz。

第 4~5 行(init) —— 人人自指。

第 6~10 行(读边) —— ru==rv 时置 cyc=true(注意不合并、不 break,读完剩余边防止输入错位);否则直接挂接 p[rv]=ru。

易错点 —— ①发现成环后 break 但输入没读完,多测场景会出错;②自环 u==v 的情况:两端同根自然被覆盖,无需特判。

A4概念题

判断题:① 并查集可以判断两个点之间的最短路径长度;② 路径压缩后 parent[x] 一定等于 find(x);③ 只用按秩合并不用路径压缩,均摊复杂度仍是 O(α(n))。

查看参考答案

① 错。并查集只维护「连通性」这一位信息,不记录路径长度。要最短路径请用 BFS(无权)或 Dijkstra(带权)。

② 对。路径压缩的定义就是「把路径上所有点的父亲改成根」,所以压缩后 parent[x] 就是根,即 find(x)。注意:仅限于刚被压缩过的节点,同一集合里未经过查询路径的点不受影响。

③ 错。只用按秩合并的复杂度是 O(log n)(每次操作最坏、均摊都是 O(log n)),不是 O(α(n))。α(n) 的界需要两个优化同时使用。

考点:三种实现方式的复杂度边界必须分清楚,这是选择题高频点。

🟡 B组 · 提高(4题)

B1并查集 + 贪心

维护一个 n 个点的图,支持两种操作:① 加一条边 (u,v);② 询问 u 所在连通块的大小。n, m ≤ 2×105。

查看参考答案

思路:在并查集基础上多维护一个 sz[]:只有根节点的 sz 有意义,表示该连通块的大小。查询时先 find 拿到根,再返回 sz[root]。

int p[200005], sz[200005];
int find(int x){ return p[x]==x?x:p[x]=find(p[x]); }
void merge(int u,int v){
    int ru=find(u),rv=find(v);
    if(ru==rv)return;
    if(sz[ru]<sz[rv])swap(ru,rv);
    p[rv]=ru; sz[ru]+=sz[rv];   // 只更新根的大小
}
int sizeOf(int u){ return sz[find(u)]; }

易错:读取 sz[u] 而不是 sz[find(u)]——非根节点的 sz 是陈旧数据,会得到错误答案。

B2并查集 + 排序

给定 n 个人,每人有一个「兴趣标签」a[i]。若两个人有至少一个相同标签,则他们属于同一个朋友圈(朋友圈具有传递性)。求最终朋友圈的个数和最大的朋友圈大小。n ≤ 105,标签值 ≤ 106。

查看参考答案

思路:把「标签」也看作节点!总共 n + (不同标签数) 个节点。对每个人 i,把他的编号 i 与他的每个标签节点合并。最后统计人节点(1..n)所在连通块即为朋友圈。

为什么可行:标签节点只是"中转站",用来把拥有同一标签的人连起来,传递性由并查集自动完成。

int p[300005], sz[300005];
int find(int x){ return p[x]==x?x:p[x]=find(p[x]); }
int main(){
    int n; cin>>n;
    for(int i=1;i<=n+100000;i++){p[i]=i;sz[i]=1;}
    for(int i=1;i<=n;i++){
        int k; cin>>k;
        while(k--){ int t; cin>>t; merge(i, n+t); }   // 人 → 标签节点
    }
    int cnt=0, best=0;
    for(int i=1;i<=n;i++)
        if(find(i)==i){ cnt++; best=max(best,sz[i]); }
    cout<<cnt<<" "<<best<<endl;
}

易错:数组要开到 n + 最大标签值;只统计人节点(1..n)中的根,不要把标签节点也算进朋友圈个数。

🔍 代码逐段解析

第 1 行(数组) —— 开 300005 = n(105) + 标签值(106 不够时用映射) 的并查集空间;本题用「人 1..n + 标签 n+t」直接编号。

第 4 行(init) —— 初始化到 n+100000,人和标签节点都要自成一派。

第 6~8 行(读人合并) —— 每读一个人的一个标签就 merge(i, n+t):标签节点是「中转站」,同标签的人经由它连通。

第 9~12 行(统计) —— 只扫 1..n 的人节点:find(i)==i 的就是朋友圈根,best 取 sz[i] 最大值。

易错点 —— ①统计时把标签节点也数进去,cnt 虚高;②merge 函数本体要按模板写全(含 swap 按大小)。

B3带权并查集

n 个盒子排成一排,初始每个盒子单独一个。两种操作:① M x y 把 x 所在整段搬到 y 所在整段之后;② C x y 询问 x 与 y 之间夹着的盒子个数(同段时),不同段输出 −1。

查看参考答案

思路:带权并查集维护「到队头的距离」d[x] 与「段长度」sz[root]。

int p[30005], sz[30005], d[30005];
int find(int x){
    if(p[x]==x)return x;
    int r=find(p[x]);
    d[x]+=d[p[x]];          // ★ 累加到队头的距离
    return p[x]=r;
}
void merge(int i,int j){
    int ri=find(i),rj=find(j);
    if(ri==rj)return;
    p[ri]=rj; d[ri]=sz[rj]; sz[rj]+=sz[ri];
}
int ask(int i,int j){
    if(find(i)!=find(j))return -1;
    return abs(d[i]-d[j])-1;
}

易错:find 里忘记 d[x]+=d[p[x]];或 merge 里写成 d[ri]=sz[ri](应是被接入段的长度)。

B4并查集 + 逆序

「最后一块石头」:n 个格子,操作 x y 表示把格子 x 合并到格子 y 所在段。若 x 已经是所在段的最左边,输出 NO;否则把 x 左端到 y 段合并并输出 YES。(并查集方向合并练习)

查看参考答案

变通解法(并查集维护方向):本题需要知道「x 是否是所在段的最左端」。可以在并查集中额外维护每段的「最左位置」L[root],合并时更新 L 即可。

int p[300005], L[300005];
int find(int x){ return p[x]==x?x:p[x]=find(p[x]); }
// 合并 a 段到 b 段,方向固定:a 段的左端接到 b 段
bool mergeDir(int a,int b){
    int ra=find(a), rb=find(b);
    if(ra==rb) return false;      // 已同段,无操作
    if(L[ra]==a) return false;     // a 是自己段的最左端 → 拒绝
    p[ra]=rb; L[rb]=min(L[rb],L[ra]);
    return true;
}

考点:并查集不仅能维护「连通性」,还能维护「集合的可合并属性」(这里是段的最左位置)。

易错:合并后忘记更新 L;或判断的是 L[find(a)] == a 但写成了 L[a] == a(非根的 L 已失效)。

🔴 C组 · 挑战(4题)

C1初赛真题

用并查集处理 n 个元素的合并与查询,若只使用路径压缩(不做按秩合并),则 m 次操作的均摊时间复杂度是多少?若两种优化都使用呢?

查看参考答案

答案:只用路径压缩:O(m log n);两种都用:O(m·α(n)),其中 α(n) 为反阿克曼函数,实际可视为常数(≤ 4)。

若都不用:最坏 O(mn)(链式数据)。只用按秩合并:O(m log n),单次最坏 O(log n)。

考点:Tarjan 1975 年证明的并查集复杂度理论,是提高组选择题的经典考点。三个结论要能一一对应。

易错:误认为「只用路径压缩也是 O(α(n))」;或把 α(n) 说成 O(log log n)。

C2带权并查集

食物链(洛谷 P2024):n 个动物分属 A、B、C 三类,A 吃 B、B 吃 C、C 吃 A。给出 k 条断言「x 与 y 同类」或「x 吃 y」,求其中假话的条数。

查看参考答案

核心思路(带权并查集,模 3 关系):令 d[x] 表示 x 与父亲的模 3 关系:0 = 同类,1 = 父亲吃 x 的...(实现时统一约定即可)。合并时用模 3 加法更新 d[ry]。

判定规则(以 d[x] = "x 相对根的偏移" 约定):

· 同类断言「x y 1」:同根时要求 (d[x]-d[y])%3 == 0。

· 吃断言「x y 2」:同根时要求 (d[x]-d[y]+3)%3 == 1。

· 异根时按对应公式建立约束并合并,断言为真。

· 还要单独判断「x > n」或「x 吃 x」这类必假的情况。

int p[50005], d[50005];   // d[x]: x 与父亲的关系(0/1/2)
int find(int x){
    if(p[x]==x)return x;
    int r=find(p[x]);
    d[x]=(d[x]+d[p[x]])%3;      // 模 3 累加关系
    return p[x]=r;
}
// 断言 op=1 同类 / op=2 x 吃 y
bool check(int op,int x,int y){
    if(x>n||y>n)return false;
    if(op==2&&x==y)return false;    // 自己吃自己,必假
    int rx=find(x),ry=find(y);
    if(rx==ry){
        int rel=(d[x]-d[y]+3)%3;
        return op==1 ? rel==0 : rel==1;
    }
    p[ry]=rx;
    d[ry]=((d[x]-d[y]-(op==1?0:1))%3+3)%3;   // 建立约束:保证 d[x]-d[y] 满足断言
    return true;
}

易错:C++ 中负数取模结果可能为负,必须写成 (a%3+3)%3;忘记判断 x>n 或自己吃自己。

C3可撤销并查集

给定 n 个点的图,操作序列长度为 q,操作有两种:加一条边 / 撤销上一次加边。每次操作后询问连通块个数。n, q ≤ 105。

查看参考答案

思路:撤销操作决定了不能用路径压缩,必须用「按秩合并 + 历史栈」。

关键细节:① 使用按大小合并保证树高 O(log n);② 每次合并把 (被挂的根, 新根, 新根原 size) 压栈;③ 撤销时弹出栈顶并还原;④ 如果本次加边两端已连通(没有实际合并),也要压入一个占位记录,保证撤销次数与操作次数一一对应。

struct Op{int son,fa,szFa;};
vector<Op> stk;
int p[100005], sz[100005], cnt;
int find(int x){ while(p[x]!=x)x=p[x]; return x; }  // 不压缩
void uni(int a,int b){
    int ra=find(a),rb=find(b);
    if(ra==rb){ stk.push_back({0,0,0}); return; }
    if(sz[ra]<sz[rb])swap(ra,rb);
    stk.push_back({rb,ra,sz[ra]});
    p[rb]=ra; sz[ra]+=sz[rb]; cnt--;
}
void undo(){
    Op o=stk.back(); stk.pop_back();
    if(o.son){ p[o.son]=o.son; sz[o.fa]=o.szFa; cnt++; }
}

易错:用了路径压缩导致无法还原;或没有为「无效合并」压占位记录,撤销时与操作错位。

C4思维题

并查集的「等价类」思想:给定 n 个变量和若干形如 x[i] == x[j] / x[i] != x[j] 的约束,判断是否存在赋值方案。若把「相等」换成「x[i] + x[j] = k」,还能用并查集吗?

查看参考答案

第一问:能。先合并所有「相等」约束,再逐条检查「不等」约束,若发现两端同根则矛盾。这就是 NOI2015「程序自动分析」的做法(配合离散化)。

第二问:不能直接套用,但可以扩展。x[i] + x[j] = k 不是等价关系(不满足传递性:x1+x2=k 和 x2+x3=k 不能推出 x1 与 x3 的关系),无法用「同一集合」描述。

如果一定要做:需要「带权并查集维护和」或改用扩展域并查集(把每个变量拆成两个节点表示「取值为 v」和「取值为 k−v」,但仅当取值集合有限且事先确定时才可行);一般情况下这类问题属于并查集不可判定,需要考虑图着色 / 2-SAT / 差分约束系统。

启发:判断一个约束系统能不能用并查集,先问自己:这个关系是等价关系吗?自反、对称、传递——三条都满足才行。

🏆 洛谷原题精选(12道)

以下题目均与并查集相关,按难度从入门到提高排列,建议按顺序练习。

1
P1551
亲戚入门
2
P3367
【模板】并查集入门
3
P1536
村村通入门
4
P1111
修复公路普及-
5
P2078
朋友普及-
6
P1656
炸铁路普及-
7
P1892
团伙普及/提高-
8
P1955
程序自动分析提高
9
P1196
银河英雄传说(带权)提高
10
P2024
食物链(模 3 关系)提高
11
P3366
【模板】最小生成树提高
12
P1525
关押罪犯(扩展域/二分)提高