【数据结构】并查集
并查集
对并查集及其常见的扩展的介绍,以及模板的梳理,部分模板可能没有经过实际验证?要多想一想是不是正确的。
TODO:搭建配套的测试代码和用例,用一个已知正确的暴力代码/验证过的并查集代码对拍出正确的答案,然后保存到git中用脚本进行自动化测试。
Completed in November 2022
模板题
验证链接:https://www.luogu.com.cn/problem/P3367
按size合并+路径压缩
路径压缩有递归和迭代两种实现方式。对应经典的并查集来说,可以使用迭代的方式实现,这样即使不使用按size合并,也不存在递归调用过深导致栈溢出的风险,同时可以略微节省运行时间。同时使用路径压缩和按size合并的方式的并查集,时间复杂度为alphan,由于反阿克曼函数增长极为缓慢,可以近似理解为n。
struct DisjointSetUnion {
static const int MAXN = 200000 + 10;
int fnd[MAXN]; // fnd[x]:节点x的父节点
int siz[MAXN]; // siz[x]:以x为根的子树的大小
void Init (int n) {
for (int i = 1; i <= n; i++) {
fnd[i] = i;
siz[i] = 1;
}
}
// 寻找节点x所在的树的根节点,迭代版本
int Find (int x) {
int r = x;
while (fnd[r] != r) {
r = fnd[r];
}
while (fnd[x] != r) {
int fx = fnd[x];
fnd[x] = r; // 路径压缩
x = fx;
}
return r;
}
// // 寻找节点x所在的树的根节点,递归版本
// int Find (int x) {
// if (fnd[x] == x) {
// return x;
// }
// int r = Find (fnd[x]);
// fnd[x] = r; // 路径压缩
// return r;
// }
// 判断节点x与节点y是否在同一棵树
bool Same (int x, int y) {
int rx = Find (x);
int ry = Find (y);
return rx == ry;
}
// 合并节点x和节点y所在的树
bool Merge (int x, int y) {
int rx = Find (x);
int ry = Find (y);
if (rx == ry) {
// 两个节点原本就在同一棵树
return false;
}
// 令节点x所在的树大小不小于节点y所在的树
if (siz[rx] < siz[ry]) {
swap (rx, ry);
}
// 将ry节点所在的树合并入rx节点所在的树
fnd[ry] = rx;
siz[rx] += siz[ry];
return true;
}
} dsu;
按size合并
按size合并能保证每次merge只修改两个点的信息,那就是节点x和节点y所在的树的树根,并且由于是启发式合并,能保证单次Find的复杂度为logn,这个logn是比较真实的。
优点:
只修改两个点的信息,便于可持久化
只修改两个点的信息,便于扩展维护其他的信息,比如边带权
缺点:
多消耗了1倍空间
时间复杂度是真的log
破坏了两个节点的合并关系,要额外维护
如何修改?将Find函数中路径压缩相关的部分移除即可。
路径压缩
相对于按size合并,节省了一半内存,可能在数据范围很大的时候会有用;并且在合并时不会破坏谁合并入谁的先后关系,也就是最终呈现的连通分量的根就是那个一直都没有合并入其他集合的节点。由某篇很难理解的论文可得出Find的均摊复杂度为logn,但由于数据集非常难构造,所以这个log是根本跑不满的,应该与树状数组的log相近,都是远小于平衡树之类的重量级数据结构的log。
优点:
递归写法很简单,迭代写法麻烦一点但是不会爆栈
不需要按size合并也可以跑出接近常数的时间复杂度
不需要额外的空间
缺点:
维护边带权等信息的时候比较复杂,要考虑先后顺序
维护边带权等信息的时候迭代写法非常复杂
其实就是如果你不搞额外信息的话,路径压缩是完全没有缺点的。
如何修改?将上述代码中,siz相关的部分,以及交换rx、ry的部分移除即可。
并查集维护可删除链表
Codeforces中时不时会出现类似的题目,需要去快速查找一个元素的前驱和后继,并且只需要删除操作。如果有插入操作的话就需要用平衡树,但如果只需要删除操作,简单的并查集可以运行得更快,并且不容易出现链表的断链、死循环等问题。
struct DisjointSet {
static const int MAXN = 200000 + 10;
int prev[MAXN];
int next[MAXN];
void Init (int n) {
for (int i = 0; i <= n + 1; ++i) {
prev[i] = i;
next[i] = i;
}
}
int Prev (int x) {
if (prev[x] == x) {
return x;
}
prev[x] = Prev (prev[x]); // 路径压缩
return prev[x];
}
int Next (int x) {
if (next[x] == x) {
return x;
}
next[x] = Next (next[x]); // 路径压缩
return next[x];
}
void Delete (int x) {
prev[x] = Prev (x - 1);
next[x] = Next (x + 1);
}
} dsu;
边带权并查集
边带权并查集是并查集的一种非常重要的变种。例题参考食物链那道题。在实现的时候用dis数组标注当前节点和父节点之间的距离(实际的边权之和),在路径压缩的时候维护边权之和,由于这种版本递归更好实现,故只使用递归的版本。
例题?https://codeforces.com/contest/1758/problem/E
按size合并+路径压缩
按size合并的版本,好像有点复杂,最后合并的时候的dep要画个图才能理解。原本x到fx的距离是dx,y到fy的距离是dy。现在要求在y上增加一条边指向x,边权为d,由于并查集只能在树根合并,所以变成由fy合并到fx之中,假设合并的边权应该是df,那么有等式:dx+x=df+dy,解出df。如果要反向连边的话应该连接一条负权边就可以了。搞的时候发现其实可以让每个点直连挂在根节点下面,如果后面要求两个节点的距离,则让他们的深度作差就行。
struct DisjointSetUnion {
static const int MAXN = 200000 + 10;
int fnd[MAXN];
int siz[MAXN];
ll dep[MAXN];
void Init (int n) {
for (int i = 1; i <= n; i++) {
fnd[i] = i;
siz[i] = 1;
dep[i] = 0;
}
}
// 寻找节点x所在的树的根节点,迭代版本
int Find (int x) {
int r = x;
ll dep_x_r = 0;
while (fnd[r] != r) {
dep_x_r += dep[x];
r = fnd[r];
}
while (fnd [x] != r) {
int fx = fnd[x];
ll dep_x_fx = dep[x];
fnd[x] = r; // 路径压缩
dep[x] = dep_x_r;
x = fx;
dep_x_r -= dep_x_fx;
}
return r;
}
// // 寻找节点x所在的树的根节点,递归版本
// int Find (int x) {
// if (fnd[x] == x) {
// return x;
// }
// int r = Find (fnd[x]);
// ll dep_fx_r = dep[fnd[x]];
// fnd[x] = r; // 路径压缩
// dep[x] += dep_fx_r;
// return r;
// }
// 判断节点x与节点y是否在同一棵树
bool Same (int x, int y) {
int rx = Find (x);
int ry = Find (y);
return rx == ry;
}
int Dep (int x) {
Find (x); // 路径压缩
return dep[x];
}
// 合并节点x和节点y所在的树,其中节点y是节点x的子节点且边权为d
bool Merge (int x, int y, int d) {
int rx = Find (x);
int ry = Find (y);
// 查询深度时不要直接访问dep[x],避免不小心调整逻辑后出错
int dep_x_rx = Dep (x);
int dep_y_ry = Dep (y);
if (rx == ry) {
// 两个节点原本就在同一棵树
assert (dep_x_rx == dep_y_ry + d);
return false;
}
// 令节点x所在的树大小不小于节点y所在的树
if (siz[rx] < siz[ry]) {
swap (x, y);
swap (rx, ry);
swap (dep_x_rx, dep_y_ry);
d = -d; // 通过连接相反权值的边来维持原本的大小关系
}
// 将ry节点所在的树合并入rx节点所在的树
fnd[ry] = rx;
dep[ry] = dep_x_rx - dep_y_ry + d;
siz[rx] += siz[ry];
return true;
}
} dsu;
去除按size合并:按size合并尽量不要移除,应该不存在这样的使用场景,不清楚出于何种考虑才会移除按size合并,调试通过的模板写的啰嗦一点没啥。可以直接去除size相关的代码,使得代码大幅度简化。
去除路径压缩:由于这里Dep函数的计算方式依赖于Find函数进行的路径压缩,所以如果要去除路径压缩,则要同步修改dep的实现,其实就是把Find函数复制一下让他返回dep_x_r就好。也会使得代码大幅度简化。
扩展域并查集
扩展域并查集也是并查集的一种非常重要的变种。目前看来是边带权并查集的一种易于实现但新增了限制的下位替代。(因为边带权并查集可以通过边权之和取模来表示此节点应该处于哪个状态,就是搞起来会比较复杂)
题目的类型一般是这样:有n个节点,每个节点会处于A、B、C等少量状态其中之一,若某个节点u处于A状态“等价于”导致另一个节点v处于B状态,那么用并查集连接这些状态。最后检查是否存在一个节点u同时处于多个状态之中,若存在则说明这里出现了矛盾,问题无解。
题目的延伸:有时候会要求某些节点初始时就处于A、B、C其中一些状态,跟随着并查集中的连通分量,可以确定与此节点相连的节点必须处于哪些状态,这个状态明显是唯一确定的。若某个连通分量中不存在初始节点,那么它的取值就可以任意取值,但注意连通分量之内的一个节点的取值确定后,其他节点的取值也随之确定。总的方案数为各个连通分量独立取值的方案数的乘积,通常而言节点可处的状态数都是一样的。答案就是power(每个节点的状态数, 完全自由的连通分量的个数)。
使用方法:
- 例如有黑白两个状态,则分别用[1,n]和[n+1,2n]来表示黑白状态,初始化为2n,注意数组空间。
- 白色点的坐标全部偏移+n。
- 若x点为黑色等价于y点为白色,则合并 Merge(x, y+n)
- 遍历整个2*n,全部Find一次使其直接连接到连通分量的根。
- fnd数组就是题目的等价条件所代表的关系,若存在Same(i, i+n),则由于一个节点不能同时存在于两种状态无解。
- 将同一个连通分量中的节点全部拉出来到一个数组里面,同时记录反向的信息,也就是每个节点所处哪个连通分量
- 对于取值确定的节点。遍历其所在的连通分量,并且设置vis=true防止重复遍历。根据节点的编号反向推出每个点的颜色。
- 对于取值不确定的点,若题目是求其中一解/字典序最小解,按题目所需要的方式对某个节点优先赋值,然后遍历其所在的连通分量。
- 若题目要求是解的数量,则将自由的连通分量的每种取值相乘。
可撤销并查集
并查集只提供了一种修改操作,就是合并操作,所以这里“可撤销”指的就是撤销最后一次合并操作。由于路径压缩会带来巨量的修改,所以这个版本的并查集不可使用路径压缩。在按size合并的时候,记录此次操作对size和对联通分量的影响,撤销的时候把这个pop回来。由于不使用路径压缩,所以每次合并操作实际上只是在森林中添加了一条边,最后把这条边给撤销回来就可以了。
实现的时候,可以用一个栈去保存历史的版本。元素存一个pair,第一个位置存引用/指针,第二个位置存修改前的值,回滚的时候就直接把第二个值赋值给第一个就能回滚。也可以只记录y(y所在的树根向x所在的树根合并时,只修改了fnd[y]=x和siz[x]+=siz[y],而回退的时候只需要fnd[y]=y, siz[x]-=siz[y],因为按size合并的并查集每次修改只发生在每棵树的根节点,树y合并进去之后就不会被修改了)。这里选择了第一种写法作为实现,被注释掉的是第二种写法。看起来第二种写法的扩展性应该更强,但是维护的时候可能要多想想,不如第一种写法那么无脑。
struct DisjointSetUnion {
static const int MAXN = 200000 + 10;
int fnd[MAXN]; // fnd[x]:节点x的父节点
int siz[MAXN]; // siz[x]:以x为根的子树的大小
vector<pair<int&, int>> his_fnd;
vector<pair<int&, int>> his_siz;
// vector<pair<int, int>> his_rx_ry;
void Init (int n) {
for (int i = 1; i <= n; i++) {
fnd[i] = i;
siz[i] = 1;
}
his_fnd.clear();
his_siz.clear();
// his_rx_ry.clear();
}
// 寻找节点x所在的树的根节点,迭代版本
int Find (int x) {
while (fnd[x] != x) {
x = fnd[x];
}
// 不能使用路径压缩
return x;
}
// // 寻找节点x所在的树的根节点,递归版本
// int Find (int x) {
// if (fnd[x] == x) {
// return x;
// }
// // 不能使用路径压缩
// return Find (fnd[x]);
// }
// 判断节点x与节点y是否在同一棵树
bool Same (int x, int y) {
int rx = Find (x);
int ry = Find (y);
return rx == ry;
}
// 合并节点x和节点y所在的树
bool Merge (int x, int y) {
int rx = Find (x);
int ry = Find (y);
if (rx == ry) {
// 两个节点原本就在同一棵树
return false;
}
// 令节点x所在的树大小不小于节点y所在的树
if (siz[rx] < siz[ry]) {
swap (rx, ry);
}
his_fnd.push_back ({fnd[ry], fnd[ry]});
his_siz.push_back ({siz[rx], siz[rx]});
// his_rx_ry.push_back ({rx, ry});
// 将ry节点所在的树合并入rx节点所在的树
fnd[ry] = rx;
siz[rx] += siz[ry];
return true;
}
void RollbackMerge() {
if (his_fnd.empty() || his_siz.empty()) {
return;
}
his_fnd.back().first = his_fnd.back().second;
his_fnd.pop_back();
his_siz.back().first = his_siz.back().second;
his_siz.pop_back();
}
// void RollbackMerge() {
// if (his_rx_ry.empty()) {
// return;
// }
// int rx = his_rx_ry.back().first;
// int ry = his_rx_ry.back().second;
// his_rx_ry.pop_back();
// fnd[ry] = ry;
// siz[rx] -= siz[ry];
// }
} dsu;
可持久化并查集
跟可撤销并查集不一样,可撤销并查集非常简单。而可持久化并查集是要根据某一个随机历史的版本经过修改生成一个新版本,而这之后的其他历史版本不能丢弃(听起来很***钻,事实上就是这样,为了恶心而恶心的数据结构)。先大概描述一下思路:
与可持久化平衡树(无旋Treap)和可持久化线段树等类似,可持久化数据结构的关键是要单次操作发生的修改必须是近似log或者严格log,而不能是均摊log。均摊log的数据结构在可持久化时会遇到某些恶心的数据反复触发大规模的修改导致时间和空间都超标。常见的均摊log的算法是:并查集的路径压缩、Splay的双旋(高度压缩)。顺带一提,有旋Treap不能进行可持久化的原因是什么呢?
所以这里很自然而然地只使用按size合并的策略。
按size合并的策略,每次只需要修改两个树根节点,非常方便。所以只需要将这两个树根节点所在的数组用可持久化数组代替即可。(注:可持久化数组的其中一种最坏复杂度最优的实现是可持久化线段树,也可以根据实际的频率做一些checkPoint+日志重放的策略,但是在算法竞赛中可持久化线段树是最实际的实现)
链接:https://www.cnblogs.com/purinliang/p/14285206.html
可修改并查集
跟撤销操作不同,修改操作意味着可以把某条边不按时间顺序强行删除,这种需要Link Cut Tree来维护,不属于并查集范畴的内容。TODO:LCT