普通并查集
Part 1 普通并查集与基本优化
并查集能干什么?
并查集,顾名思义,其实就是维护了所有元素中每一个元素的所属集合是谁
譬如设序列\(a=\){1,2,3,4,5},假设1,2属于一个集合,3,4属于一个集合,5自己一个集合
那么这个序列就可以表示成这样:{{1,2},{3,4}{5}},其中属于一个集合的元素已经用花括号括起来了
并查集的主要功能是维护一个序列和若干个集合,快速查询两个元素是不是属于同一个集合、合并两个集合
并查集的原理是什么
并查集使用了树形结构(森林)来维护,使用数组fa[i]来维护第i个元素的父亲是谁,也就代表i和fa[i]属于同一个集合,此时这棵树上的所有节点就是这个集合的所有元素
比如还是维护刚才的序列\(a\),初始时所有的元素都自己构成一个集合,那么森林大概是这样的:
(我会用Graph Editor辣!)
现在考虑合并两个元素所在的集合,假设合并\(1,2\),那么fa[1]=2;
,(或fa[2]=1;
),同理合并3和4,把这种父子关系抽象成有向边,那么森林是这样的
现在考虑合并{\(1,2\)}和{\(3,4\)}所在集合,如果还是直接fa[3]=2;
的话,那么会造成这种结果:
看到原来的fa[3]是4,此时4被覆盖掉了。这样森林中维护1,2,3属于同一集合,但实际上4也应该和1,2,3属于同一集合
此时的做法是:找到包含3的这棵树的根节点,此时根节点没有父亲(是他自己),那么可以把这个根节点的父亲指向2,这样就不会造成错误了
#include
using namespace std;
#define maxn 100005
int fa[maxn];
//寻找根节点代码
inline int Find(const int x){
if(fa[x]==x) return x;//父亲是自己,说明是根节点,返回
return Find(fa[x]);//递归继续寻找父亲的父亲
}
//合并代码
inline void Merge(const int x,const int y){
fa[Find(x)]=Find(y);//把x的根节点的父亲记为y
}
优化复杂度
恭喜你,现在已经会使用并查集了,但是你发现交到并查集的板子题上却TLE了,疑惑的你不得不考虑优化
为什么复杂度会爆炸呢?
如果使用纯粹的树上记录,我们在查询树根的时候需要从一个点一步一步向上跳,这使得我们的复杂度提高了
比如上面这个图,假设我们要查3号节点的根,那么需要跳两次,对于更极端的情况,复杂度会达到接近\(O(n)\)
那么怎么办呢?有一个最最方便的优化方法——路径压缩法,假设我们现在的并查集是这样的:
对于上面这个图,经过路径压缩后的形态就是这样
可以看到,每个儿子的父节点都被直接修改成了根节点,这样以后再访问的时候,复杂度就是\(O(1)\)了
这么做的时间复杂度在最坏情况下是\(O(logn)\)的,但是因为没有人会特意构造数据卡并查集,所以在使用时可以近似认为是一个小常数
为什么说这个方法非常方便呢?因为它实现起来根本不费吹灰之力:
以下是代码
inline int Find(const int x){
if(fa[x]==x) return x;
return /*fa[x]=*/Find(fa[x]);
//上面那句注释掉的部分就是路径压缩
//其他部分和普通并查集是一模一样的
}
Part 2:更多功能的普通并查集
如何让并查集满足更多需求?
比如我们需要满足分组,并且维护这几组中元素的关系,就需要用到多功能并查集:
例题:洛谷P2024 [NOI2001]食物链
题目中要求我们根据A->B->C->A的关系,判断给出的话是真话还是假话
因为对于每一个物种,都有且只有一类动物是天敌,有且只有一类动物是猎物,有且只有一类动物是同类
那么我们可以考虑开3倍空间的并查集,1倍存天敌是哪些动物,2倍存同类是哪些动物,3倍存猎物是哪些动物
合并的时候,就把一个动物的所有同类/天敌/猎物合并到一起,得以维护他们之间的关系
\(Code\)
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include