【数据结构系列-1】并查集
如果大佬们发现错误请在评论区指出谢谢!
前置知识:【图论系列-1】图论基础知识。
概述
并查集是一种用于管理元素所属集合的数据结构。
例题 1 【模板】并查集
让我们设想有一个 \(n\) 个人组成的班级,最开始每个人“自主学习”,自成一个小组,那么有 \(n\) 个小组。
作为一个小组肯定要有组长,最开始每个小组都只有一个人,组长不言而喻是她自己。
接下来我们要开始合并一些小组。但是我们有一个原则:每位同学都要能找到自己的组长。
设置这个原则的目的是,如果两位同学的组长相同,那么她们就一定在同一个组内。只要能快速找到一位同学的组长,就能快速判断两位同学是否在同一组内。
现在有两位同学 Youmu 和 Yuyuko,经过讨论,她们想要将她们的小组合并!

(这是一张图。在做题过程中图论建模十分重要。)
那么经过选举随机指定,Yuyuko 被认定为新的小组组长。
这时 Youmu 需要记住 Yuyuko 今后就是她的组长了。

(如图,箭头表示一位同学内心认定的组长。这是图论建模能力必要的一环。)
那后来,Yuyuko 与一个人数庞大的小组的成员 Sakuya 认识了。她们决定合并她们的小组!

根据原则,Yuyuko 找到了她的组长 Yuyuko,Sakuya 也找到了她的组长 Remilia。经过两人的谈判还是随机指定,Remilia 成为了合并后小组的组长。
这时让 Yuyuko 记住她的组长似乎不太够,因为她身边还有很多组员不知道自己的组长是谁。但是如果开个组会认组长的话,也许太浪费时间了。
这时 Yuyuko 想到:她的组员在找组长时一定会找到她这里来,那之后她再去找她的组长,不久能让她的组员都找到组长了吗?
于是形成了这张图。

这张图中,一位同学的箭头指向的同学不一定是她真正的组长。但是她如果沿着箭头一路找上去,一定能够最终找到她的组长:没有箭头指出的就是组长。
我们同时可以模拟一下如果两个属于同一组的同学想要合组会怎么样:
Youmu 和 Patchouli 想要合并小组,Youmu 找到组长 Remilia,Patchouli 也找到组长 Remilia。
那组长都一样,说明两个人其实就在同一个小组里面啊,那还合并个毛线,维持现状。
至此我们设计出了一种解决前面给出的问题的可行算法:
- 维护每一个元素的上级代表。
- 实现一个函数,用来计算一个元素的最高级代表:不断跳上级代表直到没有上级代表为止。
- 要合并两个元素所在的集合时,求出两个元素的最高级代表,然后将其中一个代表的上级代表设为另一个。
- 查询两个元素所在的集合时,找到两个元素的最高级代表,如果相同说明在同一组内,否则说明在不同组内。
代码实现如下:
(为了避免讨论,很多大佬在写题时,会将没有上级代表的元素的上级代表设置成她自己。这样在合并时不用特判,直接令 f[y]=x 也不会发生变化。)
int f[N+5];//上级代表
void init(){
for (int i=1;i<=n;i++)f[i]=i;
}
int find(int x){//求 x 的最高级代表。
if (f[x]==x)return x;//如果没有上级代表,返回她自己。
else return find(f[x]);//否则让她的上级代表取找上级代表。
}
void merge(int x,int y){//合并 x 和 y 所在的集合。
x=find(x);y=find(y);//x 和 y 各自找到最高级代表。
f[y]=x;//两位代表谈话后选出 x 作为代表。选哪位代表是没有区别的。
}
回到我们前面的情境。所有人都能找到自己的组长,直到有一天来了 \(10^5\) 位转学生。
很快,它们成立了一个庞大的小组。A 想找到她的组长,找到她的前组长 B,B 又带她找到她的另一位前组长 C,C 又带她去找 D……
A 意识到如果这样找下去,她可能需要找几万个人才能找到她的组长。而如果她还要这样找几万次,那简直不用学习了。
其实,当 A 在一次寻找中找到了她的组长是 Z,那她不用再跑 B->C->D->... 这一程了。下一次 A,B,C,D 这些人如果还要去找组长,直接去找 Z 就可以了。
我们“压缩”了 A 找组长的路程,因此 A 将这种方法称为“状态压缩”。
直观上,这节省了大量的时间开支,有大佬计算过,在经过这样的节省时间(我们称为“优化”)的操作之后,找一次组长的平均复杂度不超过 \(O(\log n)\)。
同理,我们也可以用“状态压缩”的方式来“优化”题目中元素寻找最高级代表的过程。
这样优化过后的实现如下:
int f[N+5];//上级代表
void init(){
for (int i=1;i<=n;i++)f[i]=i;
}
int find(int x){//求 x 的最高级代表。
if (f[x]==x)return x;//如果没有上级代表,返回她自己。
else {
int ans=find(f[x]);//否则让她的上级代表取找上级代表。
f[x]=ans;//下次直接找到她就好了。
return ans;
}
}
void merge(int x,int y){//合并 x 和 y 所在的集合。
x=find(x);y=find(y);//x 和 y 各自找到最高级代表。
f[y]=x;//两位代表谈话后选出 x 作为代表。选哪位代表是没有区别的。
}
成功地解决了我们在开头中给出的问题。
应用
例题 2 修复公路
对本题进行图论建模。将村庄看成点,公路看成边,题目就变为:
有 \(N\) 个孤立点,两种操作:
- 加一条无向边;
- 询问图是否连通。
考虑图会由若干个连通块组成。添加的新无向边如果跨越了两个不同的连通块,这两个连通块就会合并,这就转化成了例题 1。
例题 3 奶酪
图论建模。
将奶酪中每一个空洞建模成为点,如果两个空洞相交或相切,则在这两个点之间连一条边。
如果两个空洞球心的距离不大于它们的半径之和,那么两个空洞相交或相切。
为了判断从下表面能不能到达上表面,有两种处理方法:
- 枚举所有和下表面相交或相切的空洞 \(x\) 和所有和上表面相交或相切的空洞 \(y\),用并查集判断 \(x\) 是否能到达 \(y\)。
- 在图中添加两个点:上表面 \(T\) 和下表面 \(S\)。所有和下表面相交或相切的空洞和 \(S\) 连边,所有和上表面相交或相切的空洞与 \(T\) 连边。最后并查集判断 \(S\) 是否能到 \(T\)。
例题 4 无线通讯网
前置知识:【基础算法系列-2】二分。
如果 \(K\) 固定,可以类似上一题那样连边处理。
当 \(K\) 增加时,已有的边不会拆掉。当 \(K\) 减小时,不会出现新的边。
因此如果答案为 \(D_0\),那么只要最小通讯距离不小于 \(D_0\),整张图就能连通。
二分答案 \(D\) 的值,并在 check 时使用并查集判断 \(D\) 是否能使整张图连通。
例题 5 搭配购买
前置知识:【动态规划系列-1】背包问题。
背包问题,但是有些物品互相捆绑。
其实这些捆绑起来的物品可以被视作一件物品,因为它像一件物品一样不能拆开选,只能一起选。
在并查集中存储更多信息
例题 6 银河英雄传说
除了判断两个星舰是否在同一列以外,还需要维护它们之间的距离。
考虑两个同在一列的点。它们之间的距离实际上等于它们到列首星舰之间的距离之差。所以只要维护一个星舰到列首星舰的距离就好了。
每个星舰被分配到“长官”的时候,记录一下它到长官的距离,也就是合并前“长官”所在连通块的大小。记录完以后,更新“长官”所在连通块的大小。
想要查询一个星舰与列首星舰的距离,只要问一下长官,长官到列首星舰的距离,再加上该星舰到长官的距离,就是该星舰到列首星舰的距离。
问完以后,由于“状态压缩”,它会将列首星舰看做新的长官,用它与列首星舰的距离更新它到长官的距离。
由本题可以看出,并查集在“并”和“查”的过程中是可以维护很多信息的。
拓展域并查集
例题 7 关押罪犯
将所有的冲突事件从小到大排序,然后遍历一遍。
遍历到第 \(i\) 个事件时,想办法求得“能不能分配罪犯使前 \(i\) 个事件都不发生”。
前面用并查集是将 \(x,y\) 合并到同一个集合,现在却要将 \(x,y\) 分配到不同的集合。
有一种做法是:如果 \(x,y\) 要分配到不同的集合,\(x,z\) 要分配到不同的集合,就意味着 \(y,z\) 要分配到相同的集合,所以合并 \(y,z\)。
根据这种思路,用数组维护每位罪犯的“上一个敌人”。当一位罪犯拥有新的敌人时,将新的敌人与“上一个敌人”合并,这样他的所有敌人都合并起来了。
这里用本题介绍拓展域并查集。因为拓展域并查集可以解决很多这种把物品分到组内的问题,例如本章练习 5。
对每位罪犯开两个点。对于罪犯 \(i\) 开的两个点,一个点表示“将罪犯分到 A 监狱”的决策(记点的编号为 \(i\)),另一个点表示“将罪犯分到 B 监狱”的决策(记点的编号为 \(i+n\))。
如果两个决策必须同时进行或同时不进行,否则就无法满足条件,则将这两个点连边。
如果两个罪犯 \(a\) 和 \(b\) 不能分到同一监狱,那就是说,如果 \(a\) 分到 A 监狱,那么 \(b\) 必须分到 B 监狱;如果 \(a\) 分到 B 监狱,那么 \(b\) 必须分到 A 监狱,反之亦然。
因此,将 \(a\) 与 \(b+n\) 连边,\(a+n\) 与 \(b\) 连边。
如果 \(a\) 和 \(a+n\) 分到一个连通块里,说明矛盾了。
可撤销并查集
并查集的启发式合并
并查集的“路径压缩”优化十分强力,但是复杂度基于均摊(就是说,有时进行一次 find 还是 \(O(n)\) 的)。而这就意味着无法对并查集进行一些更加高级的处理。
例如难以维护撤销操作,如果一次 find 是 \(O(n)\) 的,我把它进行以后又撤销,再进行再撤销,复杂度直接退化了。
我们希望使用一种不基于均摊的方式完成并查集的优化。
注意到原始的并查集速度慢的原因是:
很快,它们成立了一个庞大的小组。A 想找到她的组长,找到她的前组长 B,B 又带她找到她的另一位前组长 C,C 又带她去找 D……
A 意识到如果这样找下去,她可能需要找几万个人才能找到她的组长。而如果她还要这样找几万次,那简直不用学习了。
也就是并查集的深度过大的问题。
考虑合并操作。合并本质是将一棵树挂在了另一棵树上面。
- 如果将深度大的一棵树合并到深度小的树,那么根的深度会加一。
- 如果将深度小的树合并到深度大的树,那么根的深度不会变化。
考虑总是将深度小的树合并到深度大的树。
我们发现,这时在多数情况下根的深度都不变,只有在两棵树深度相等时深度会增加。
感性理解上这样做跑得很快,实际上有大佬证明过,这样一次合并是严格 \(O(n \log n)\) 的。
还有人证明过,将维护深度改为维护树的大小,合并也是严格 \(O(n \log n)\) 的。
例题 8 【模板】可撤销并查集
前置知识:【基础算法系列-3】链表、栈、队列 。
开一个栈,记录每次合并操作合并了哪两个“组长”。在撤销合并操作时,取出栈顶,例如是 \(x,y\),接下来需要把 \(x,y\) 的联系拆开。

重新回顾这张图。如果 Yuyuko 和 Remilia 想要分家,Yuyuko 接着当她和 Youmu 组成的小组的组长。
只需要让 Yuyuko 不再认为 Remilia 是她的组长,把她连出的边断掉就好了。
如果并查集维护了额外信息,也需要恢复原状,例如 Remilia 的子树大小需要减去 Yuyuko 的子树大小。
int f[N+5];//上级代表
int siz[N+5];//子树大小,启发式合并
void init(){
for (int i=1;i<=n;i++)f[i]=i,siz[i]=1;
}
int find(int x){//求 x 的最高级代表。
if (f[x]==x)return x;//如果没有上级代表,返回她自己。
else {
int ans=find(f[x]);//否则让她的上级代表取找上级代表。
return ans;
}
}
stack<int> opsx,opsy;
void merge(int x,int y){//合并 x 和 y 所在的集合。
x=find(x);y=find(y);//x 和 y 各自找到最高级代表。
if (siz[x]<siz[y])swap(x,y);
opsx.push(x);opsy.push(y);
f[y]=x;//两位代表谈话后选出 x 作为代表。选哪位代表是没有区别的。
siz[x]+=siz[y];//更新子树大小。
}
void back(){//撤销一次。
if (opsx.empty())return;//判断是否已经撤销完了无法撤销。
int x=opsx.top(),y=opsx.top();
f[y]=0;//取消连边。
siz[x]-=siz[y];//更新子树大小。
}
练习
练习 1 家谱
点击查看提示
令 $f_x$ 表示 $x$ 目前认为的祖先,用并查集维护。练习 2 村村通
点击查看提示
如果整个图形成了 $n$ 个连通块,那么它们还需要 $n-1$ 条边才能相连。练习 3 团伙
点击查看提示
一个人的所有敌人两两成为朋友。或者拓展域并查集。
练习 4 选学霸
点击查看提示
令 $f_i$ 表示能否选出 $i$ 名学霸。练习 5 食物链
点击查看提示
关键在于处理“X 吃 Y”这类论断。拓展域并查集。图论建模时,对每个动物 \(i\) 建立三个点,分别表示“\(i\) 是 A”“\(i\) 是 B”“\(i\) 是 C”三种论断。
将可以互推的论断连边。

浙公网安备 33010602011771号