1598 字
8 分钟
【数据结构】并查集

并查集是一种树形的数据结构,用于处理一些不相交集合的合并以及查询问题。

情景#

现在考虑一种如下的场景:

我有 N 个节点,其中一些节点通过线段进行连接,连接的节点可以通信(不需要两两连接,可以通过相邻的节点转发),现在给出两个节点,需要检查这两个节点是否可以通信。

对于这种场景,最简单的思路就是从其中某个节点开始,递归地遍历另外所有可以通信的节点,然后判断另一个给定的节点是否在所有可通信的节点中,但是实际上,遍历所有可以通信节点的代价是非常高昂的,尤其是在节点数量多,查询次数多的情况下,此时就需要引入一个新的数据结构:并查集

并查集可以很好地处理这类两个节点间是否可以通信的问题。

并查集#

并查集有两个主要的步骤:合并查找

为了详细展示这两个步骤,我们先给定如下的一个图:

dsu-1

这个图的某些节点间存在一些边,节点通过这些边互相连通

合并#

按秩合并#

按秩合并是并查集合并中的一种常见的操作,具体流程如下:

秩可以理解为当前节点在树中的最大层级

  • 首先,这 6 个节点并没有连接在一起,所以我们为 1-6 六个节点分别设定 的值为 1
  • 1 和 2 之间有边连接,于是我们将 1 和 2 合并,设定 1 是父节点,2 指向 1,然后更新节点 2 的秩为 2;
  • 2 和 3 之间有边连接,于是我们将 2 和 3 合并,设定 2 是 3 的父节点,然后更新节点 3 的秩为 3;
  • 4 和 6 之间有边连接,于是我们将 4 和 6 合并,设定 4 是 6 的父节点,然后更新节点 6 的秩为 2;
  • 5 和 6 之间有边连接,于是我们将 5 和 6 合并,因为 6 的父节点已经是 4 了,所以我们设定 5 的父节点是 6,然后更新 5 的秩为 2

当两个集合合并时,秩的概念用来决定把哪棵树作为合并后的根。我们应该将拥有较小秩的树合并到拥有较大秩的树上。这样做的目的是为了保持整体树的平衡,尽量减少树的高度,从而提高后续查找操作(Find)的效率

这样我们就可以建立一个简单的并查集了

路径压缩#

在按秩合并的思路中,我们尝试将各个节点通过秩的关系大小进行合并,但是事实上,如果我们只需要关心某些节点是否可以连通,我们可以将所有的子节点都连接到同一个父节点,组成一个最大高度为 2 的树

  • 1 和 2 之间有边连接,于是我们将 1 和 2 合并,设定 1 为父节点,2 指向 1;
  • 2 和 3 之间有边连接,2 的父节点是 1,于是我们可以压缩路径,让 3 和 1 直接连接,设定 3 是 1 的父节点;
  • 4 和 6 之间有边连接,但是 4 的父节点是自己,于是设定 4 为父节点,6 指向 4;
  • 5 和 6 之间有边连接,但是 6 的父节点是4,于是将 5 和 4 直接连接;

通过如下上述的操作,我们创建了如下的一个带有路径压缩的并查集:

dsu-2

不难看出,尽管连接的方式不同,但是实际的连通性是没有改变的。

查找#

无论是按秩合并还是路径压缩,都可以通过寻找相同根节点的方式来判断是否处于同一个并查集

按秩合并#

比如我们想要查找节点 3 和 节点 2 是否可以通信,我们先查找节点 3:

  • 首先检查 3 的父节点,可以看到是 2
  • 然后我们检查 2 的父节点,可以看到是 1
  • 最后我们检查 1 的父节点,可以看到父节点是 1,查找结束

然后我们查找节点 2:

  • 节点 2 的父节点是节点 1
  • 节点 1 的父节点是节点 1,查找结束

由于两个节点的父节点都是相同的,因此我们认为这两个节点可以通信。

路径压缩#

与 按秩合并 相同,我们来判断下 3 和 6 节点能否相互通信

首先检查节点 3:

  • 节点 3 的父节点是 节点 1
  • 节点 1 的父节点是 节点 1,查找结束

然后检查节点 6:

  • 节点 6 的父节点是节点 4
  • 节点 4 的父节点是节点 4,查找结束

由于节点 3 和节点 6 的根节点并不相同,所以节点 3 和节点 6 并不能相互通信。

实现#

我们简单实现一下上述提到的两种逻辑:

查找#

我们先实现下查找的逻辑:

/*
 * @Author: NorthCity1984
 * @LastEditTime: 2025-01-19 23:22:27
 * @Website: https://grimoire.cn
 * @NorthCity1984 All rights reserved
 */ 

// parent[x] 记录了节点 x 的父节点
int find(int x) {
    return parent[x] == x ? x : (parent[x] = find(parent[x]));
}

合并#

然后简单实现下合并逻辑

按秩合并#

/*
 * @Author: NorthCity1984
 * @LastEditTime: 2025-01-19 23:22:18
 * @Website: https://grimoire.cn
 * @NorthCity1984 All rights reserved
 */ 

void to_union(int x1, int x2) {

    // 查找两个节点的根节点
    int p1 = find(x1);
    int p2 = find(x2);

    // 两个节点已经合并
    if (p1 == p2) {
        return;
    }
    
    // 按秩合并
    if (ranks[p1] > ranks[p2]) {
        parent[p2] = p1;  // 将较小秩的树p2合并到较大秩的树p1
    } else if (ranks[p1] < ranks[p2]) {
        parent[p1] = p2;  // 将较小秩的树p1合并到较大秩的树p2
    } else {
        // 如果两个树的秩相等,任意选择一个作为新的根,并更新秩
        parent[p2] = p1; // 将p2合并到p1
        ranks[p1]++;     // 增加新的根的秩
    }
    return;
}

路径压缩#

/*
 * @Author: NorthCity1984
 * @LastEditTime: 2025-01-19 23:22:18
 * @Website: https://grimoire.cn
 * @NorthCity1984 All rights reserved
 */ 
void to_union(int x1, int x2) {
    // 路径压缩就比按秩合并简单多了...
    parent[find(x1)] = find(x2);
}

例题#

洛谷 P1536 村村通

【数据结构】并查集
https://grimoire.cn/posts/algorithm/dsu/
作者
北城
发布于
2025-01-19
许可协议
CC BY-NC-SA 4.0