并查集
并查集#
等价关系是满足下列3个性质的关系R:
- 自反性,对于所有的a∈S,有aRa。
- 对称性,aRb当且仅当bRa。
- 传递性,若aRb且bRc,则aRc。
如何判断a和b是否是等价的的呢?或者说如何将两个有等价关系的元素划入一个集合中?
一个满足a∈S的元素a,其等价类是S的这样一个子集:它包含了所有与a有等价关系的元素。也就是说,等价类形成了对S的划分:S的每一个成员都恰好出现在了一个等价类中,如果a等价b(记作a~b),只需要验证a和b是否在同一个等价类中。
最开始时,所有的元素的都是独立的,或者说是每个元素的等价集合中只有自己,我们这种没有任何相同元素的两个集合为不相交的。
接下来,要判断元素之间是否是等价的,只需要执行两个操作,一个是查找find
,一个是合并union
,就是说,首先查找两个元素之间是否已经在同一个等价类中了,称为find
,如果不在且两者有某种符合条件的等价关系,就对两者进行合并操作,去掉两者各自集合,生成一个新的集合,称为union
。这项工作因此也被称为不相交集的union/find算法。
这种算法是动态的,因为随着算法的执行,集合通过union操作而发生改变。同时,该算法也必然是联机的,当find执行时,它必须给出答案,然后算法才能继续下去。另外有一种脱机的算法,需要观察全部的union和find序列。
联机和脱机:联机是指必须执行当前任务才会进行下一步,脱机是指可以获取到全部的内容,只要在规定的时间或空间范围内完成即可。类似于笔试和面试,笔试一般是脱机的——你只要在考试时间内完成试卷即可,面试一般是联机的——你只有回答了当前的问题,才能继续回答下一个提出的问题。
基本数据结构#
对于查找操作来说,我们并不需要返回任何特定的名称或其他,而只需要两个元素在相同集合时,通过find操作两者能够得到相同的结果。于是,我们可以用树来表示每一个集合(树的集合称为森林),因为同一棵树上的每个元素都能找到相同的根。
那么,我们就可以这样做,使用一个数组来表示各元素的父节点,初始化是-1表示是根节点,在union(x,y)操作时,规定将x作为了y这颗树的父节点。
union(4,5)后,5的父节点指向了4,union(6,7)后,7的父节点指向了6,再union(4,6)后,6的父节点指向了4。
简单实现如下,需要说明,这可以完成上述的操作,但不是好的实现。
class UnionSets {
public:
explicit UnionSets(int nums);
int find(int x) const;
void unionSet(int root1, int root2);
private:
vector<int> s;
};
UnionSets::UnionSets(int nums)
: s(nums, -1) { }
int UnionSets::find(int x) const {
if(s[x] < 0) {
return x;
}
return find(s[x]);
}
void UnionSets::unionSet(int root1, int root2) {
s[root2] = root1;
}
灵巧求并算法#
上述我们规定union(x,y),总是将y的根节点指向x,这是比较随意的,可想而知随着树深度的增加,递归的程度也越深。
那么,我们就要考虑如何在合并的过程中,减少树的深度的增加。
我们可以想到两种方法,一是按大小合并——总是将较小的树合并到较大的树上;二是按高度合并——总是将浅的树合并到深的树上。这并不是困难的,因为我们完全可以用记录位置的数组来记录树的大小和高度,比如一个位置上的值是-3,就可以表示这棵树的大小或高度是3,合并的过程就是负数相加的过程,初始时数组中都是-1(不过高度是从0开始,需要存储的高度减个1)。
// 假设是做过了检查操作了,入参都是合法的
void UnionSets::unionSet(int root1, int root2) {
if(s[root2] < s[root1]) { // root2的根更深
s[root1] = root2;
}
else {
if(s[root1] == s[root2]) { // 相同的话就升高1
--s[root1];
}
s[root2] = root1;
}
}
路径压缩#
我们对于union操作进行了一定程度的优化,同样,find操作也可以更聪明些。在最开始的find操作中,我们只是按照顺序地递归查找了整颗树,如果要查找的元素处于树的很深的位置,相当于每次都要查找相当长的路径。
基于空间局部性的原理,我们有理由这么做——如果要查找的元素较深,那么我们可以把它和其路径上的元素向上提一提。
这种较为聪明的操作叫做路径压缩,当我们要查找x元素时,从x到根路径上的每一个节点,都让其父节点变成根节点。
int UnionSets::find(int x) const {
if(s[x] < 0) {
return x;
}
return s[x] = find(s[x]);
}
路径压缩和按大小合并的做法是完全兼容的,因此可以结合两者做到快速的求不相交集。
一个应用#
不相交集最常见的应用就是用于生成一个迷宫,迷宫仅有唯一一条通关路线,并有许多的干扰路线,相当于x和y在且仅在一个等价集合中。于是我们可以用若干相连的方块拼接成一个矩形,从左上角的x方块开始,到右下角的y结束,每次都任意选择两个方块进行合并,直到能find到x和y。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· Apache Tomcat RCE漏洞复现(CVE-2025-24813)