【知识点】集合与并查集

在前几篇文章当中,我们已经讨论了许多有关数论的知识点了,因此 Macw 决定写几篇数据结构的文章缓一缓。(整天写数论相关的内容容易自闭(bushi))。

今天我们将会围绕一个新的数据结构,并查集(Disjoint Set Union)来展开。

集合与集合的常见操作

在谈论到并查集的时候,首先讨论一个前置知识点,即 集合(Set)的概念。集合是一种无序且不重复的元素集,用数学可以表示为 \(S = \{a, b, c, \dots\}\),其中 \(S\) 是这个集合的名字,\(a, b, c\) 等就是这个集合中的元素。

集合的应用非常的广泛。举一个大家生活中都比较熟悉的例子,运动会报名人数统计。假设目前有两个集合,分别表示参加篮球比赛的选手和参加羽毛球比赛的选手,写作:

\(Basketball = \{\text{Alice}, \text{Bob}, \text{Cindy}, \text{Richard}, \text{Tom}, \text{Fred}\} | \text{参加篮球比赛的选手}\)
\(Badminton = \{\text{Vincent}, \text{Cindy}, \text{Tom}, \text{Richard}, \text{Selina}, \text{Hans}\} | \text{参加羽毛球比赛的选手}\)

对于多个集合而言,设 \(A\)\(B\) 是两个集合,常见的有以下操作

  1. 交集(Intersection):求出两个集合中共同包含的元素,写作 \(A \cap B\)
  2. 并集(Union):求出两个集合中所有的元素并去重,写作 \(A \cup B\)
  3. 差集(Difference):求出在一个集合 \(A\) 中但不在另一个集合 \(B\) 中的元素,写作 \(A \setminus B\)

举例而言:

  • 如果我们想要求出既参加了篮球比赛,又参加了羽毛球比赛的同学,可以使用交集操作 \(Basketball \cap Badminton = \{\text{Cindy}, \text{Tom}, \text{Richard}\}\)。表示 Cindy, Tom 和 Richard 三个人既参加了羽毛球比赛,又同时参加了篮球比赛。
  • 同理,如果我们想要求出所有参赛的同学,可以使用并集的操作 \(Basketball \cup Badminton = \{\text{Alice}, \text{Bob}, \text{Cindy}, \text{Richard}, \text{Tom}, \text{Fred}, \text{Vincent}, \text{Selina}, \text{Hans}\}\)。表示这些同学是所有参赛的选手。
  • 如果我们想要求出参加了篮球比赛,但没有参加羽毛球比赛的同学,可以使用差集的操作 \(Basketball \setminus Badminton = \{\text{Alice}, \text{Bob}, \text{Fred}\}\)。表示 Alice, Bob 和 Fred 三人只参加了篮球比赛,没有参加羽毛球比赛。

在介绍完集合的基本操作后,我们将目光着重放在计算 并集 的过程中,这也是今天所要讲的并查集算法的重要组成部分之一。

并查集算法

并查集是一种树形的数据结构,用于处理一些不相交的集合(指的是两个集合的交集为空,即两个集合没有共同的元素),并支持 合并 和 查询 的操作。

  • 查询(Find):查找某个元素所属的集合。通常通过路径压缩(Path Compression)优化,以加快查找速度。
  • 合并(Union):合并两个集合。通常通过按秩合并(Union by Rank)优化,以保证树的高度较低,提高效率。

并查集广泛应用于解决动态连通性问题,如图的连通分量、网络连接、最小生成树等。

并查集代码的实现

在并查集中,每一个集合都可以被看作成一棵树。而集合的标识符也可以被表示为这一棵树的根节点。与此同时,如果在一个场景当中有许多的树,那么这些树将会构成一个森林。

当我们想要找到某一个节点在哪个集合当中,本质上就是在寻找这个节点的根节点编号。若我们想要合并两个集合,本质上就是将一颗树的根节点指向另一棵树的根节点。这样子两棵树就被合并成了一棵树,集合的合并操作也因此完成了。

推荐算法可视化网站:Visualgo.net,各位可以自行访问网站观看并查集算法逻辑的可视化演示。

第一步:定义变量和初始化。

首先,我们需要定义并初始化以个向量/数组,一个用于存储每个元素的父节点。一开始将所有元素的父节点都设置为该节点本身。

vector<int> parent;

void initialize(int n) {
    parent.resize(n);
    for (int i = 0; i < n; ++i) {
        parent[i] = i;
    }
}

第二步:实现查询操作。

接下来,我们实现并查集的 find 操作,用于查找某个元素所属的集合。在查找元素所属的集合时,本质上就是在查找这个集合的父元素。该方法可以通过递归的方式来实现。其中,递归的终止条件就是某一个节点的父节点是该节点本身,代表这个节点是这个集合(树)的根节点。

int find(int x) {
    if (parent[x] != x) {
        return find(parent[x]);
    }
    return parent[x];
}

在此基础上,我们还可以对算法进行时间上的优化,也就是对搜寻路径进行压缩。路径压缩的基本思想是在执行查找操作时,将路径上的每个节点都直接连接到根节点上,从而降低整个树的高度。

int find(int x) {
    if (parent[x] != x) {
        parent[x] = find(parent[x]); // 路径压缩
    }
    return parent[x];
}

路径压缩可以显著减少树的高度,使得查找操作的时间复杂度接近于常数级别,提高了并查集的效率。

第三步:实现合并操作。

然后,我们实现并查集的 union 操作,用于合并两个集合。如果两个元素所指向的父元素是同一个,那么可以说明这两个元素存在于同一个集合当中,不需要进行合并操作。否则就将一个元素添加到另一个集合之中。

void unionSets(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    
    if (rootX != rootY) {
        parent[rootY] = rootX;
    }
}

并查集的时间复杂度

并查集的时间复杂度取决于其两个主要操作:查找 和 合并。

查找操作(Find)

在普通的并查集中,如果不进行路径压缩优化,find 操作的时间复杂度与树的高度成正比。最坏情况下,树的高度可以达到 \(O(n)\),其中 \(n\) 是并查集中元素的数量。

但是,在应用了路径压缩优化的情况下,find 操作的均摊时间复杂度接近于常数级别。具体来说,经过路径压缩的并查集的 find 操作的时间复杂度可以表示为 \(O(\alpha(n))\),其中 \(\alpha(n)\) 是阿克曼函数的反函数,增长极其缓慢。对于所有实际应用而言,可以将其约等于为常数时间。

合并操作(Union)

合并操作涉及到查找两个元素所在集合的根节点,并将其中一个根节点连接到另一个根节点上。由于查找过程是常数级别的,而合并两棵树的根节点也是常熟级别的,因此合并操作的时间复杂度也近似于常数时间。

总体复杂度

综上所述,经过路径压缩和按秩合并优化后的并查集,在实际应用中具有非常高效的时间复杂度。对于包含 \(n\) 个元素的并查集,其 findunion 操作的均摊时间复杂度都接近于常数级别,即 \(O(\alpha(n))\)。因此,并查集在解决大规模数据集合动态连通性问题时表现非常出色。

相关引用

  1. GeeksforGeeks. "Introduction to Disjoint Set Data Structure or Union-Find Algorithm." GeeksforGeeks, 12 May 2023, https://www.geeksforgeeks.org/introduction-to-disjoint-set-data-structure-or-union-find-algorithm/.
  2. CP-Algorithms. "Disjoint Set Union." CP-Algorithms, https://cp-algorithms.com/data_structures/disjoint_set_union.html.
  3. USA Computing Olympiad. "DSU." USA Computing Olympiad, https://usaco.guide/gold/dsu?lang=cpp.
  4. Halim, Steven, et al. "Union-Find Disjoint Sets (UFDS) - VisuAlgo." VisuAlgo.net
posted @ 2024-05-22 13:55  Macw  阅读(22)  评论(0编辑  收藏  举报