并查集——概念、模板及例题


1. 概念

  并查集是一种树形的数据结构,顾名思义,就是用于处理一些不交集合的合并及查询的问题,其支持两种操作:

  • 查找(find):确定某个元素处于哪个子集;
  • 合并(union/merge):将两个子集合并成一个集合;
    在这里插入图片描述

  注意:并查集不支持集合的分离,但是并查集在经过修改后可以支持集合中单个元素的删除操作)。使用动态开点线段树还可以实现可持久化并查集。


2. 模板实现

2.1 查找(find)实现

  讲一个故事:几个家族进行宴会,但是家族普遍长寿,所以人数众多。由于长时间的分离以及年龄的增长,这些人逐渐忘掉了自己的亲人,只记得自己的爸爸是谁了,而最长者(称为「祖先」)的父亲已经去世,他只知道自己是祖先。为了确定自己是哪个家族,他们想出了一个办法,只要问自己的爸爸是不是祖先,一层一层的向上问,直到问到祖先。如果要判断两人是否在同一家族,只要看两人的祖先是不是同一人就可以了。

在这样的思想下,并查集的查找算法诞生了。

直接进行查找,代码如下:

int parent[MAXN];  // 记录某个人的爸爸是谁,特别规定,祖先的爸爸是他自己
int find(int x) {
    
    
  // 寻找x的祖先
  // 如果x是祖先则返回
  if (parent[x] == x) return x;
  // 如果不是则x的爸爸问x的爷爷
  else return find(parent[x]);  
}

显然最终可以返回 x x x的祖先/根节点;

  可是如上代码的最坏时间复杂度为 O ( d e p t h ) \mathcal{O}(depth) O(depth)的, d e p t h depth depth指的是集合树的深度。在其中使用了递归询问的形式。实际上,每个人的祖先(根节点)与每个人在合并操作中所对应的父节点是没有半毛钱关系的,为何不在找的过程中,将每个节点都都直接指向其祖宗节点,也即是把在路径上的每个节点都直接连接到根上,这就是路径压缩。进行过路径压缩后,查询时间复杂度就变为 O ( 1 ) \mathcal{O}(1) O(1)了。

如下图所示:
在这里插入图片描述
代码模板如下:

    int parent[N]; //存储每个点的祖宗节点
    int find(int x) {
    
    
        if (parent[x] != x) parent[x] = find(p[x]);
        return parent[x];     // 返回x的祖宗节点
    }

2.2 合并(union/merge)实现

  宴会上,一个家族的祖先突然对另一个家族说:我们两个家族交情这么好,不如合成一家好了。另一个家族也欣然接受了。
  我们之前说过,并不在意祖先究竟是谁,所以只要其中一个祖先变成另一个祖先的儿子就可以了。

void unionSet(int x, int y) {
    
    
  // x 与 y 所在家族合并
  x = find(x);
  y = find(y);
  parent[x] = y;  // 把 x 的祖先变成 y 的祖先的儿子
}

启发式合并(按秩合并)

  一个祖先突然抖了个机灵:你们家族人比较少,搬家到我们家族里比较方便,我们要是搬过去的话太费事了。
  由于需要我们支持的只有集合的合并、查询操作,当我们需要将两个集合合二为一时,无论将哪一个集合连接到另一个集合的下面,都能得到正确的结果。但不同的连接方法存在时间复杂度的差异。具体来说,如果我们将一棵点数与深度都较小的集合树连接到一棵更大的集合树下,显然相比于另一种连接方案,接下来执行查找操作的用时更小(也会带来更优的最坏时间复杂度)。
  当然,我们不总能遇到恰好如上所述的集合——点数与深度都更小。鉴于点数与深度这两个特征都很容易维护,我们常常从中择一,作为估价函数。而无论选择哪一个,时间复杂度都为 O ( m α ( m , n ) ) \mathcal{O}(m\alpha(m,n)) O(mα(m,n))

std::vector<int> size(N, 1);  // 记录并初始化子树的大小为 1,选择点数size作为估价函数
void unionSet(int x, int y) {
    
    
  int xx = find(x), yy = find(y);
  if (xx == yy) return;
  if (size[xx] > size[yy])  // 保证小的合到大的里
    swap(xx, yy);
  parent[xx] = yy;
  size[yy] += size[xx];
}

2.3 复杂度

2.3.1 时间复杂度

  在 Tarjan 的论文[1]中,证明了不使用启发式合并、只使用路径压缩的最坏时间复杂度是 O ( m l o g n ) \mathcal{O}(mlogn) O(mlogn)。在姚期智的论文[2]中,证明了不使用启发式合并、只使用路径压缩,在平均情况下,时间复杂度依然是 O ( m α ( m , n ) ) \mathcal{O}(m\alpha(m,n)) O(mα(m,n))
  如果只使用启发式合并,而不使用路径压缩,时间复杂度为 O ( m l o g n ) \mathcal{O}(mlogn) O(mlogn) 。由于路径压缩单次合并可能造成大量修改,有时路径压缩并不适合使用。例如,在可持久化并查集、线段树分治+并查集中,一般只使用启发式合并的并查集。

优化 平均时间复杂度 最坏时间复杂度
无优化 O ( log ⁡ n ) O(\log n) O(logn) O ( n ) O(n) O(n)
路径压缩 O ( α ( n ) ) O(\alpha(n)) O(α(n)) O ( log ⁡ n ) O(\log n) O(logn)
按秩合并 O ( log ⁡ n ) O(\log n) O(logn) O ( log ⁡ n ) O(\log n) O(logn)
路径压缩 + 按秩合并 O ( α ( n ) ) O(\alpha(n)) O(α(n)) O ( α ( n ) ) O(\alpha(n)) O(α(n))

  这里 α \alpha α 表示阿克曼函数的反函数,在宇宙可观测的 n n n 内(例如宇宙中包含的粒子总数), α ( n ) \alpha(n) α(n)不会超过 55,也即是常数时间复杂度。

2.3.2 空间复杂度

显然是 O ( n ) O(n) O(n)

2.4 模板

(1)朴素并查集:

    int p[N]; //存储每个点的祖宗节点

    // 返回x的祖宗节点
    int find(int x)
    {
    
    
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }

    // 初始化,假定节点编号是1~n
    for (int i = 1; i <= n; i ++ ) p[i] = i;

    // 合并a和b所在的两个集合:
    p[find(a)] = find(b);


(2)维护size的并查集:

    int p[N], size[N];
    //p[]存储每个点的祖宗节点, size[]只有祖宗节点的有意义,表示祖宗节点所在集合中的点的数量

    // 返回x的祖宗节点
    int find(int x)
    {
    
    
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }

    // 初始化,假定节点编号是1~n
    for (int i = 1; i <= n; i ++ )
    {
    
    
        p[i] = i;
        size[i] = 1;
    }

    // 合并a和b所在的两个集合:
    size[find(b)] += size[find(a)];
    p[find(a)] = find(b);


(3)维护到祖宗节点距离的并查集:

    int p[N], d[N];
    //p[]存储每个点的祖宗节点, d[x]存储x到p[x]的距离

    // 返回x的祖宗节点
    int find(int x)
    {
    
    
        if (p[x] != x)
        {
    
    
            int u = find(p[x]);
            d[x] += d[p[x]];
            p[x] = u;
        }
        return p[x];
    }

    // 初始化,假定节点编号是1~n
    for (int i = 1; i <= n; i ++ )
    {
    
    
        p[i] = i;
        d[i] = 0;
    }

    // 合并a和b所在的两个集合:
    p[find(a)] = find(b);
    d[find(a)] = distance; // 根据具体问题,初始化find(a)的偏移量

3. 相关问题

LeetCode:

题目 解题思路
547. 省份数量 朴素查并集
765. 情侣牵手 可贪心,也可查并集,答案为总对数-连通分量数

AcWing:

题目 解题思路
237. 程序自动分析 查并集,擅长动态维护许多具有传递性的关系,能在无向图中维护节点之间的连通性
238. 银河英雄传说 维护节点深度的并查集,即统一维护当前战舰到排头距离
239. 奇偶游戏 前缀和思想:至求前缀序列奇偶性,由并查集维护连通分支数为2(对应奇偶)的图进行判断(带边权或是带邻域并查集)
1250. 格子游戏 朴素查并集
1252. 搭配购买 查并集+01背包

4. 参考资料

猜你喜欢

转载自blog.csdn.net/yueguangmuyu/article/details/113818115