引入

并查集是一种用于管理元素所属集合的数据结构,实现为一个森林,其中每棵树表示一个集合,树中的节点表示对应集合中的元素。

顾名思义,并查集支持两种操作:

  • 合并(Unite):合并两个元素所属集合(合并对应的树)。
  • 查询(Find):查询某个元素所属集合(查询对应的树的根节点),这可以用于判断两个元素是否属于同一集合。

并查集在经过修改后可以支持单个元素的删除、移动或维护树上的边权。使用动态开点线段树还可以实现 可持久化并查集

Warning

并查集无法以较低复杂度实现集合的分离。

初始化

初始时,每个元素都位于一个单独的集合,表示为一棵只有根节点的树。方便起见,我们将根节点的父亲设为自己。

实现

[list2tab]

  • C++

    struct dsu {
      vector<size_t> pa;
     
      explicit dsu(size_t size) : pa(size) { iota(pa.begin(), pa.end(), 0); }
    };
  • Python

    class Dsu:
        def __init__(self, size):
            self.pa = list(range(size))

查询

我们需要沿着树向上移动,直至找到根节点。

实现

[list2tab]

  • C++

    size_t dsu::find(size_t x) { return pa[x] == x ? x : find(pa[x]); }
  • Python

    def find(self, x):
        return x if self.pa[x] == x else self.find(self.pa[x])

路径压缩

查询过程中经过的每个元素都属于该集合,我们可以将其直接连到根节点以加快后续查询。

实现

[list2tab]

  • C++

    size_t dsu::find(size_t x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
  • Python

    def find(self, x):
        if self.pa[x] != x:
            self.pa[x] = self.find(self.pa[x])
        return self.pa[x]

合并

要合并两棵树,我们只需要将一棵树的根节点连到另一棵树的根节点。

实现

[list2tab]

  • C++

    void dsu::unite(size_t x, size_t y) { pa[find(x)] = find(y); }
  • Python

    def unite(self, x, y):
        self.pa[self.find(x)] = self.find(y)

启发式合并

合并时,选择哪棵树的根节点作为新树的根节点会影响未来操作的复杂度。我们可以将节点较少或深度较小的树连到另一棵,以免发生退化。

按节点数合并的参考实现:(注意需要调整初始化方法)

实现

[list2tab]

  • C++

    struct dsu {
      vector<size_t> pa, size;
     
      explicit dsu(size_t size_) : pa(size_), size(size_, 1) {
        iota(pa.begin(), pa.end(), 0);
      }
     
      void unite(size_t x, size_t y) {
        x = find(x), y = find(y);
        if (x == y) return;
        if (size[x] < size[y]) swap(x, y);
        pa[y] = x;
        size[x] += size[y];
      }
    };
  • Python

    class Dsu:
        def __init__(self, size):
            self.pa = list(range(size))
            self.size = [1] * size
     
        def unite(self, x, y):
            x, y = self.find(x), self.find(y)
            if x == y:
                return
            if self.size[x] < self.size[y]:
                x, y = y, x
            self.pa[y] = x
            self.size[x] += self.size[y]

参考实现

带有路径压缩、按节点数合并的并查集的完整实现如下所示:

复杂度

同时使用路径压缩和启发式合并之后,并查集的每个操作平均时间仅为 。其中, 为阿克曼函数的反函数,增长极其缓慢。也就是说,并查集单次操作的平均运行时间可以认为是一个很小的常数。时间复杂度的证明在 这个页面 中。

反 Ackermann 函数

Ackermann 函数 的定义是这样的:

而反 Ackermann 函数 的定义是 Ackermann 函数的反函数,即为最大的整数 使得

并查集的空间复杂度显然为

拓展操作

在普通的并查集的基础上,还可以做一系列修改使之支持更多的操作或维护更复杂的信息。

带删除并查集

普通的并查集无法支持删除操作,是因为删除一个节点的时候,不可避免地会将以它为根的子树上所有节点都删除。为了解决这一问题,在带删除操作的并查集中,可以通过建立虚点的方法保证所有实际存储数据的节点总是叶子节点。为此,需要在初始化时,就为每个数据节点都建立一个虚点,并将数据节点的父节点设置为该虚点。由于每次合并两个集合时,都只会将两个集合的树根连接,所以,从始至终只有虚点会有子节点。这就保证了删除一个节点时,不会误删其他节点。

注意,删除单个节点后,需要重新为该节点建立一个虚点作为其父节点;否则,无法正确执行后续的合并和删除操作。

类似的方法还可以用于实现在集合间移动单个元素。实现细节详见例题。

带权并查集

我们还可以在并查集的边上定义某种权值和这种权值在路径压缩时产生的运算,从而解决更多的问题。比如对于经典的「NOI2001」食物链,我们可以在边权上维护模 意义下的加法群。对于这类维护模意义下边权且模数很小的问题,还可以通过将并查集的单个点拆分为多个状态的方式来解决。这种特殊情形下的技巧,也称为「种类并查集」或「拓展域并查集」。后文会通过例题来说明这些做法。

为了维护并查集中的边权,需要将边权下放到子节点中存储。因此,每个节点存储的都是它到它的父节点之间的边权。只有当一个节点的父节点发生变化时,才需要相应地调整边权。一般情形中,这可能发生在路径压缩和合并两个节点时。例如,如果边权是当前节点与父节点之间的距离,那么,在路径压缩时,每次将当前节点的父节点替换为根节点,都需要将父节点到根节点的距离加到当前节点存储的边权上;类似地,在合并两个节点所在集合时,需要计算两个根节点之间新连接的边的权值。

例题

算法竞赛中,直接考察并查集的题目大多都需要针对题目设计特殊的结构。

实现类似并查集的数据结构,支持以下操作:

  1. 合并两个元素所属集合。
  2. 将单个元素移动到另一个元素所在的集合。
  3. 查询某个元素所属集合的大小及元素和。

习题

其他应用

最小生成树算法 中的 Kruskal 和 最近公共祖先 中的 Tarjan 算法是基于并查集的算法。

相关专题见 并查集应用

参考资料与拓展阅读

  1. 知乎回答:是否在并查集中真的有二分路径压缩优化?
  2. Gabow, H. N., & Tarjan, R. E. (1985). A Linear-Time Algorithm for a Special Case of Disjoint Set Union. JOURNAL OF COMPUTER AND SYSTEM SCIENCES, 30, 209-221.PDF
  3. CSDN:扩展域并查集 & 带权并查集

Footnotes

  1. Tarjan, R. E., & Van Leeuwen, J. (1984). Worst-case analysis of set union algorithms. Journal of the ACM (JACM), 31(2), 245-281.ResearchGate PDF

  2. Yao, A. C. (1985). On the expected performance of path compression algorithms.SIAM Journal on Computing, 14(1), 129-133.