算法学习笔记(18)——并查集(Disjoint-Set)

并查集(Disjoint-Set)

并查集(Disjoint-Set)是一种可以动态维护若干个不重叠的集合,并支持合并与查询的数据结构。详细地说,并查集包括如下两个基本操作:

  1. Get,查询一个元素属于哪个集合。
  2. Merge,把两个集合合并成一个大集合。

为了具体实现并查集这种数据结构,我们首先需要定义集合的表示方法。在并查集中,我们采用“代表元”法,即为每个集合选择一个固定的元素,作为整个集合的“代表”
其次,我们需要定义归属关系的表示方法。第一种思路是维护一个数组 \(f\) ,用 \(f[x]\) 保存元素 \(x\) 所在集合的“代表”。这种方法可以快速查询元素的归属集合,但在合并时需要修改大量元素的 \(f\) 值,效率很低。第二种思路是使用一个树形结构存储每个集合,树上的每个节点都是一个元素,树根是集合的代表元素。整个并查集实际上是一个森林(若干棵树)。我们仍然可以维护一个数组 \(p\) 来记录这个森林,用 \(p[x]\) 保存 \(x\) 的父节点。特别地,令树根的 \(p\) 值为它自己。这样一来,在合并两个集合时,只需连接两个树根(令其中一个树根为另一个树根的子节点,即 \(p[root_1] = root_2\) )。不过在查询元素的归属时,需要从该元素开始通过 \(p\) 存储的值不断递归访问父节点,直至达到树根。为了提高查询效率,并查集引入了路径压缩与按秩合并两种思想。由于按秩合并的优化效果并不明显,本文仅讨论路径压缩这一优化策略。

我们注意到,第一种思路(直接用数组 f 保存代表)的查询效率很高,第二种思路的合并效率很高,所以不妨将两者结合起来。实际上,我们只关心每个集合对应的“树形结构”的根节点是什么,并不关心这棵树的具体形态,这意味着下面两棵树是等价的。

img

因此,我们可以在每次执行 Get 操作的同时,把访问过的每个节点(也就是查询过的元素的全部祖先)都直接指向树根,即把上图中左边那棵树变成右边那棵。这种优化方法被称为路径压缩。采用路径压缩优化的并查集,每次 Get 操作的均摊复杂度为 \(O(\log n)\)

模板代码:

  1. 并查集的存储:使用一个数组 p 保存父节点(根的父节点设为自己)。
    int p[N];
    
  2. 并查集的初始化:设有n个元素,起初所有元素各自构成一个独立的集合,即有 n 棵 1 个点的树。
    for (int i = 1; i <= n; i ++ ) p[i] = i;
    
  3. 并查集的 Get 操作:若 x 是树根,则 x 就是集合代表,否则递归访问 p[x] 直至根节点。
    int get(int x) {
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }
    
  4. 并查集的 Merge 操作:合并元素 x 和元素 y 所在的集合,等价于让 x 的树根作为 y 的树根的子节点。
    void merge(int x, int y) {
        p[find(x)] = find(y);
    }
    

例题链接:AcWing 836. 合并集合

#include <iostream>

using namespace std;

const int N = 1e5 + 10;

int n, m;
int p[N];

int get(int x)
{
    if (p[x] != x) p[x] = get(p[x]);
    return p[x];
}

int main()
{
    cin >> n >> m;
    
    for (int i = 1; i <= n; i ++ ) p[i] = i;
    
    while (m -- ) {
        char op;
        int a, b;
        cin >> op >> a >> b;
        if (op == 'M') p[get(a)] = get(b);
        else
            if (get(a) == get(b)) puts("Yes");
            else puts("No");
    }
    
    return 0;
}
posted @ 2022-12-09 22:07  S!no  阅读(167)  评论(0编辑  收藏  举报