20251206 - 并查集 总结

并查集介绍

正常情况下我们维护一棵树,存储了每条边、每个点的具体信息,因为我们需要知道一棵树的完整面貌。

但是如果我们只想知道这棵树,或者说这个森林的连通情况,就完全没必要这么麻烦了。

假设我们只存储每个节点的父亲节点 \(fa_u\),那么该如何判断 \(u\)\(v\) 是否处在同一棵树中呢?很简单,只需要沿着 \(u\)\(v\) 的父亲 \(fa\) 不断往上爬找到根,判断根是否相等即可。

合并 \(u\)\(v\) 的时候,找到它们分别的根 \(fu\)\(fv\),让 \(fa_{fv} = fu\) 即可。

这就是并查集。

朴素实现

需要一个 Find_Father 函数去找节点对应的根节点,这边为了简写就简称 FF 函数了。以及需要一个 Merge 函数用作合并。

朴素实现的代码如下:

int FF(int u){
  if(fa[u]==u)return u;
  return FF(fa[u]);
}
void Merge(int u,int v){
  int fu=FF(u),fv=FF(v);
  if(fu!=fv)fa[fv]=fu;return;
}

时间复杂度是多少呢?发现当最坏情况下,树呈链状,一次 FF 可能就需要 \(O(n)\) 的时间。如果需要连 \(m\) 条边,时间复杂度 \(O(nm)\),太差了。这就完全失去并查集的意义了,跑 BFS 都比这快。

优化

刚才我们发现并查集的朴素实现,时间复杂度极差。需要对它进行优化。

一般有以下两种优化方案:

  • 路径压缩

    • 由于在并查集中,我们只关心连通情况,其实并不关心你具体的父节点究竟是谁,也就是不关心树的具体形态。
    • 那么我们完全可以考虑在查询根节点的同时,把路径上的节点的父节点修改成根节点。
    • 这样就可以大大加快后续的查询速度,均摊每次操作时间复杂度 \(O(\log n)\)
    • 代码实现:
      int FF(int u){
        if(fa[u]==u)return u;
        return fa[u]=FF(fa[u]);
      }
      /*
      可以使用三目运算符压行
      int FF(int u){return (fa[u]=u?u:fa[u]=FF(fa[u]));}
      */
      
    • 一般的题目只需要用到这个优化就可以过得去了。
  • 启发式合并

    • 在合并时,我们考虑把节点数量较少的树的根节点合并到节点数量较多的树的根节点下面。
    • 也可以根据树的深度来合并。
    • 这样就能使新树的每个节点到根节点的总距离尽可能小。
    • 这个优化会大大提升速度,均摊一次操作 \(O(\alpha(n))\),其中 \(\alpha(n)\) 可以看做一个不超过 \(4\) 的小常数,几乎可以忽略不计。
    • 代码实现:
      void Merge(int u,int v){
        int fu=FF(u),fv=FF(v);
        if(fu==fv)return;
        if(sz[fv]>sz[fu])swap(fu,fv);
        fa[fv]=fu,sz[fu]+=sz[fv];return;
      }
      
    • 启发式合并是一个很通用的优化方法,它的作用不仅在于并查集。

例题选讲

这次是真的选讲了喵!题太多太多了喵!

D - 朋友

在一对朋友之间连边,用两个并查集维护出两个公司分别的连通情况,多维护一个 \(sz\)。对小明和小红分别所处的集合里的人数取 \(\min\) 就是答案。

E - 营救

乍一看这个题跟并查集毫无关系,因为它是要你算什么最小的拥挤度最大值。但是,最大值最小,你没有想到二分吗?答对了就是二分!我们二分这个最大的拥挤度,然后去 check

check 怎么写?由于现在已经固定了最大的拥挤度,于是只需要判断能不能从 \(s\)\(t\) 去就可以了,考虑连边。但是由于拥挤度有最大值上限,因此拥挤度超过最大值上限的边就不能够被加入并查集中去进行维护。

由于需要搞多次并查集,一定要记得初始化喔。

G - Closing the Farm S

这个题还是算比较简单的,并查集运用。

我们读入 \(n\) 个依次被关闭的农场编号之后,就挨个枚举、累加。遍历每次 \(n\),遍历每次关的前 \(i\) 个时,就看一下所有的边,只要这边连接着两个没被关的农场,就用并查集连起来,最后判断是否形成一个大集合。一样的,记得初始化。

H - 奶酪

不算难。首先是要找到和底边或上边相交或相切的所有孔洞,把它们和起点或终点连边。对依然是并查集,起点和终点可以定成 \(n+1\)\(n+2\) 这之类的,虚点。

然后就是看每两个孔洞能不能连起来,比较显然的,如果两个孔洞的中心点的距离不超过 \(2r\),就是相交或者相切,就可以连边。

最后看起点和终点在不在并查集的同一个集合里就好了。多测记得清空。

J - 关押罪犯

扩展域并查集。

首先对所有冲突事件按照影响力从大到小排序,然后不断连边。连什么呢,连的是 \(u\)\(v+n\),连的是 \(u+n\)\(v\)

这什么意思呢,\(u\) 表示 \(u\) 自己,\(u+n\) 表示 \(u\) 的对立。即,如果把 \(v\)\(u+n\) 连一块儿了,说明 \(v\)\(u\) 没被关在一个监狱。

但是,就这么一直连边下去,要是某个时候 \(u\)\(u+n\) 跑到一个集合里了该怎么办呢?显然这种情况是不能出现的,自己怎么可能和自己的对立在一块,因此出现了这种情况就说明,到这里打止了,之后的事件都得发生。由于我们是按照影响力从大到小排序的,目前到的这个事件的影响力一定是尽可能小的。输出即可。

K - 食物链

依然扩展域并查集,但是三维。

和 J 题类似的,但是这里分三类:自己、自己吃的、吃自己的。

分别开 \(u\)\(u+n\)\(u+2n\) 三类就行。

如果什么时候错位了,比如说 \(x\)\(y\) 是同类但已有 \(x\)\(y\)\(y\)\(x\),就是假话;再比如说 \(x\)\(y\) 但已有 \(x\)\(y\) 是同类、\(y\)\(x\),那也是假话。否则就对应连边即可。

总结

并查集,是一个好用的算法,通常用来维护连通性情况(废话,人家也就能实现这功能)。朴素实现的时间复杂度很高,但是加上路径压缩或者启发式合并的优化后效率会大大提升。运用很广,有时用来处理树、图的连通性判断,有时结合二分答案作为 check 进行是否在同一集合的检测,还有扩展域并查集解决需要开虚点多维关系的情况。总之它是一个很好用的算法喵!

Thanks reading.

posted @ 2025-12-08 22:25  嘎嘎喵  阅读(63)  评论(0)    收藏  举报