【数据结构】- 并查集

并查集


简介

并查集是可以维护 元素集合 的数据结构。并查集通过把一个集合作为一棵树的方式,维护一个 森林(这暗含并查集 可以维护连通块个数,如在 kruskal 中,通过并查集维护连通块个数就能快速判断循环退出条件),并使用树的 根节点代表各集合。这样一棵树的节点就对应该集合中的元素。可以方便地支持元素(合)并查(询)操作,我们会维护每个点指向的父亲节点。当然,根节点只能指向自己了。

image

有带权并查集、扩展域并查集、可撤销并查集和可持久化并查集等变体。

普通并查集

结构

维护一个数组记录父亲,并在初始化时指向自己。必要时记录连通块个数或连通块大小。

int fa[N], tot, sz[N];

操作

查询

询问元素所在集合的根节点,通过递归不断上跳至父亲节点实现。不难发现当出现自环时我们就抵达了顶点,可以返回。

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

合并

既然直接维护的是集合从属关系,要合并两个集合时直接将一棵树的根的父亲设为另一棵树的根节点即可。

void unite(int x, int y) {fa[find(x)] = find(y);}

并查集优化

使用最为广泛的是并查集的路径压缩技巧。大多数时候我们使用并查集并不关心元素间 严格的父子关系,只是想知道它们是否属于同一集合,此时普通并查集的时间复杂度就不可观了。想象一下,从一条链的底端爬到顶端后才能返回代表这一集合的元素,实在是太过耗时了。而一朵美妙的菊花图才是效率之王!针对查询的优化——路径压缩由此而来:把一条链的路径压成一条“直通边”岂不美哉?

image

注意:不难看出,在需要判定加入顺序等关心严格父子关系的情景时,路径压缩会致错。如:[可撤销并查集](P5787 二分图 /【模板】线段树分治 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn))。同样,在节点维护了与路径有关信息时(如树高),路径压缩同样不能完全兼容,所以才有了按秩合并,详见知乎讨论

结构

我们完全可以改变此时并查集记录的信息:在加入时记录其父节点,而后对每个节点 在可能时 尽快更新成记录其所在集合的根节点,那么之后的查询我们就可以快速得到答案,省去一次次“爬楼”的过程了。

int rt[N];

操作

路径压缩

前文说到要尽可能更新,那么在查询时就 一路更新 节点信息自然是不二之选——反正这次经过的链上所有的点都可以,也需要更新,那一并维护即可。在代码层面,具体地,在递归后将返回值顺道更新为当前节点指向的根节点。

int find(int x) {return rt[x] == x ? rt[x] = find(rt[x]);}
//不压行的,
int find(int x) {
if (x == rt[x]) return x;
rt[x] = find(rt[x]);
return rt[x];
}

启发式合并

合并操作也有其优化方法:按权重合并、按秩合并。启发式合并是一种常见的优化,对合并过程人为干涉,符合直觉地,一般将大小更小的并入更大的集合,这样未来查询时需要更新rt[]数组的节点自然更少。同理,也有按深度(树高)更矮的并入更高的集合。但是我们前面提到,使用路径压缩时会改变树高,故根节点存储的高度信息在路径压缩后干脆不进行更新,直接使用这个称为“秩”的近似值(上限)进行求并优化。不过合并时遇到两棵树秩相同时仍应将新的根的秩更新+1。事实证明这样也能提升效率。

void unite(int x, int y) { //按大小合并
x = find(x), y = find(y);
if (x == y) return;
if (sz[x] < sz[y]) swap(x, y);
rt[y] = x; sz[x] += sz[y];
}
void unite(int x, int y) { //按秩合并
x = find(x); y = find(y);
if (x == y) continue;
if (dep[x] < dep[y]) swap(x, y);
rt[y] = x; dep[x] += (dep[x] == dep[y]);
}

trick——关于合并的思考:我们都知道并查集支持的是合并而非删除操作,而当只有删边和询问时我们有个经典的处理方式:时光逆流,从而将删边改为加边。

例题

【模板】并查集

维护连通性:修复公路

时光回溯法:[JSOI2008] 星球大战

综合练习:[USACO16OPEN] Closing the Farm S、[USACO16OPEN] Closing the Farm G

运用在生成树/贪心中:[NOIP2013 提高组] 货车运输[HAOI2006] 旅行

带权并查集

有时候,我们需要维护 类似前缀和 的值,即加入点时给出其与父亲之间的 相对关系(通常可以转化成 边权),并实时维护从根节点到此节点的(异或)和等可以使用前缀和得到的相对信息。常见地,将一条链接到另一条链后,并维护链内点的信息。或者,有多个链接到一起形成树形结构时,想要知道任意两个同集合的点之间的关系就可以通过从根到这两点的相对信息得到。举个简单例子:每个人都有成绩,每次给出两个人的成绩之差。比如有关系 ab=xbc=ybd=z ,那么可以将 a 作为根,维护其他人与他成绩的差值 val(b)=xval(c)=xyval(d)=xz。通过 val(d)val(c) 就能逆向得到 dcyz 分。相似地,比例、差值一类信息都可以如此比较。

有时,我们只是在根节点维护该连通块的信息(如连通块大小、直径等),也可看做带的权。

这在普通并查集中不难理解和实现,收集树上根节点到此点的链的信息即可。

结构

int rt[N], val[N];

操作

普通并查集 合并时维护位次为例。这属于单链+单链。当然此时会额外维护每个连通块长度(大小)以确定加入新元素时的位次。

int find(int x) {
if (fa[x] == x) return x;
int rt = find(fa[x]);
rnk[x] += rnk[fa[x]];
return rt; //看过下文再想想,其实这里改为 fa[x] = rt; 就完成了路径压缩改造
}
void united(int x, int y) {
int fx = find(x), fy = find(y);
rnk[fx] += sz[fy];
fa[fx] = fy;
sz[fy] += sz[fx];
sz[fx] = 0;
}

那么 find() 时的路径压缩可否用于这种带权并查集呢?在路径压缩后感觉混乱无比,是不是直接接到根节点就会略过路径上的其他点?其实我们剖析一下路径压缩的过程:在 find() 函数中一定是 先一路递归更新了父亲节点到根节点 的信息,并将其父亲接在根上后 再更新自身到父节点 信息,与正常 Dfs 顺序无异。所以我们依然可以直接在父亲节点的基础上加上本点影响。不过注意,一定是在 进入递归前 就记录下父亲节点编号,不然赋过新值后通过 rt[] 找到的就是根节点了。以支持将一个点所在链接到另一点所在链之后并维护路径 点权和 / 异或和 为例:

int find(int x) {
if (x == rt[x]) return x;
int fa = rt[x];
rt[x] = find(rt[x]);
val[x] ^= val[fa]; || val[x] += val[fa];
return rt[x];
}
void united(int x, int y) {
int fx = find(x), fy = find(y);
val[fx] ^= all[fy]; || val[fx] += all[fy];
fa[fx] = fy;
all[fy] ^= all[fx]; || all[fy] += all[fx];
all[fx] = 0;
}

不难发现,这种只存在于单链上的信息是能方便地做到维护和合并的,但在某些信息的合并时,我们遇到了棘手的问题:比如合并 xy 所在集合时给出的条件是使 real[x] = real[y] + w (因为带权并查集内部维护的是相对信息,为了便于区分,这里使用"real"表示真实值,譬如 real 是一次考试分数,val 就的该同学与某一同学的分差),但我们合并时是将 x 的根 fx=find(x) 接到 y 的根 fy=find(y) 上。

因此我们需要求出一个合适的新值(fx 相对 fy 的值)赋给 val(fx),以满足 fx 原本集合内各元素相对值仍不变,且更新原 fx 集合内元素的 valfx 值 为 与 fy 的相对值 valfy 后, val(x)val(y) 做差能得到 w。 因为 find() 后分别得到了 xfxyfy 的相对值 valfx(x)valfy(y)。那么我们得到:

w=valfy(x)valfy(y)=valfx(x)+valfy(fx)valfy(y)valfy(fx)=valfy(y)+wvalfx(x)

所以使用解出的这个值,在之后更新原 fx 集合内元素的 val() 时就能满足条件。

void unite(int x, int y, int w) {
int fx = find(x), fy = find(y);
if (fx == fy) continue;
rt[fx] = fy;
val[fx] = val[y] + w - val[x];
}

直观地,将相对值反应成距离,find()后我们可以得到:

image

x 经由两条路径都到达了 fy,因此两条路径长度也应相等,尽管我们只会保留绿 红而非蓝 绿一线,但也能验证上面等式的成立。

在比例中这同样成立,默认根节点的值初始为 1,现需合并 xy,并给出其比值,那么推导如下:

{realfx:realx=1:valx,realfy:realy=1:valy,realx:realy=b:a.realy:realfy:realx:realfx=valy:1:bavaly:bvalyavalx

所以同样能得到 fx 的新值。

例题

维护可合并/推导信息:[NOI2001] 食物链(维护%3值)、[HNOI2005] 狡猾的商人(维护前缀和)、[NOI2002] 银河英雄传说(维护排名)、[SDOI2016] 齿轮(维护比值)、[蓝桥杯 2020 省 AB1] 网络分析](https://www.luogu.com.cn/problem/P8710)(维护路径和)

维护连通块自身信息:[USACO18JAN] MooTube G(大小)、HXY造公园(直径)

扩展域并查集

结构

扩展域并查集使用了与 2SAT 类似的建反集思想。这时依然维护每个元素所属集合,但现在的集合之间有阵营之分,可能两个集合毫无关系,也可能互为敌人,集合内部则一定同为朋友。而所谓“反集”,就是假想出一个与元素 x “作对” 的影子 x,这在处理敌人关系时可以方便地将敌对阵营都与 x 连接,使得 x 会联合一切 x 的敌人阵营,整合形成一个连通块,整个图就近似二分图了。实现时使用 x+n 可以方便地表示 x

trick:合并时应注意顺序,一般只选择真实的元素(1n)作为集合代表(树的根)方便统计。

image

比如题目给出的是 xy,yz 互为敌人,那么 xz 就会被 y 拉拢,形成同一阵营,实现快速整合连通块。

int rt[N*2];

操作

扩展域并查集合并时秉持的原则就是:1.朋友的朋友还是朋友;2.敌人的敌人就是朋友。当然,有些时候 也会要求 朋友的敌人也是敌人 (紧密绑定的 种族 并查集),特别注意题目要求。

对于两个朋友关系的 ab,将他们的阵营直接合并就行,unite(a,b)。而对于两个敌人关系的 ab,他们的反集 ab 阵营会分别拉拢 ba,所以要 unite(a+n, b)、unite(b+n,a) 。朋友关系时用不用 unite(a+n,b+n) 取决于说没说朋友的敌人也是敌人。如果两者毫无瓜葛,这样合并了就相当于无故对其他人宣战,额外树敌使两者敌人联合只是减少了阵营数。

image

譬如上例中我们加入了 wz 的友好关系,是否意味着 z 要对 w 保持敌对关系?不一定,题目没有允许朋友的敌人也是敌人时,同一阵营(绿)的不同元素(zw)有各自的敌人阵营(橙、蓝)也是合法的。

比如在团伙一题中,[不允许这种情况出现](关于种族并查集 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)),但在食物链中,因为种族概念的影响,同一种族有着共同的天敌,就一定需要合并敌人。又比如程序自动分析中,给出的条件只有相等或不等,但仅靠这种关系, xy,xzy=z 显然不成立,也就是说不能简单地认为“敌人的敌人是朋友”就将他们归为一类。此时只有等式满足传递性,即“朋友的朋友是朋友”生效,故而应该将所有等式条件处理后再判断不等条件是否满足。

例题

阵营关系:[NOI2015] 程序自动分析[BOI2003] 团伙

维护二分图中关系:[NOIP2010 提高组] 关押罪犯

种族关系:[NOI2001] 食物链[COCI2016-2017#5] Ronald

可撤销并查集

前文我们才说过并查集仅支持合并,怎么现在就可以撤销了?其实,已经一再强调:路径压缩会破坏原有父子关系,无法复原加入顺序,但普通并查集记录的是原来加入时的父亲,通过记录一个栈维护新加入集合的那个(原)根节点,我们可以方便地在退栈时将它的父亲重定向回自己,实现两个集合的分离。此时失去了路径压缩优化,就一定注意使用按秩合并保证复杂度(毕竟也好不容易完全兼容一次)。经典的使用场景是配合线段树分治判定二分图。

结构

因为会用到按秩合并,所以需要记录加入这棵子树后树高的改变值,stk要开一个pair。

stack<pair<int, int> > stk[N];
int fa[N], h[N];

操作

查询

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

合并

void unite(int x, int y) {
x = find(x), y = find(y);
if (x == y) return;
if (dep[x] < dep[y]) swap(x, y);
stk.push({y, (dep[x] == dep[y])});
fa[y] = x; dep[x] += (dep[x] == dep[y]);
}

撤销

在进行加入操作前记录要回退到的位置,在合并并操作完后回溯。回溯途中记得撤回加入时对父节点集合的树高贡献。

void work() {
int tmp = stk.size();
/* unite(...) */
while (stk.size() > tmp) {
int x = stk.top().first, derta = stk.top().second;
dep[fa[x]] -= derta; fa[x] = x; stk.pop();
}
}

例题

二分图 /【模板】线段树分治

可持久化并查集

通过主席树可以实现可持久化并查集,先挖坑。

posted @   b1t_sad  阅读(56)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示