并查集

本文讨论了并查集(Disjoint Set Union,简称 DSU)这一数据结构。由于其两个主要操作,它通常也被称为联合查找(Union Find)。

该数据结构提供了以下功能:给定若干元素,每个元素最初都是一个独立的集合。并查集可以合并任意两个集合,并且能够判断某个特定元素属于哪个集合。经典版本还引入了第三个操作,即可以从一个新元素创建一个集合。

因此,该数据结构的基本接口仅包含以下三个操作:

  • make_set(v):创建一个包含新元素 v 的新集合。
  • union_sets(a, b):合并两个指定的集合(包含元素 a 的集合和包含元素 b 的集合)。
  • find_set(v):返回包含元素 v 的集合的代表(也称为首领)。这个代表是其对应集合中的一个元素,由数据结构自身选择(并且可能会随着时间变化,特别是在执行 union_sets 操作后)。通过比较代表,可以判断两个元素是否属于同一个集合。如果 find_set(a) == find_set(b),则 ab 恰好属于同一个集合;否则它们属于不同的集合。

正如后面将详细描述的那样,该数据结构能够在平均情况下近乎以 $O(1)$ 的时间完成每个操作。

此外,在一个子章节中还解释了一种替代的 DSU 结构,其平均复杂度较慢,为 $O(\log n)$,但在某些情况下可能比常规的 DSU 结构更强大。

构建高效的数据结构

我们将以的形式存储集合:每棵树对应一个集合,而树的根节点则是该集合的代表/首领。

在下面的图中,你可以看到这种树的表示形式。

集合表示的示例图

最初,每个元素都是一个独立的集合,因此每个顶点都是自己的树。然后我们合并包含元素 1 的集合和包含元素 2 的集合。接着合并包含元素 3 的集合和包含元素 4 的集合。最后一步,我们合并包含元素 1 的集合和包含元素 3 的集合。

对于实现而言,这意味着我们需要维护一个数组 parent,用于存储树中每个节点的直接祖先。

朴素实现

我们现在可以编写并查集数据结构的第一个实现版本。最初它会相当低效,但稍后我们可以通过两种优化手段来改进它,使其每个函数调用的时间近乎为常数。

正如我们所说,所有关于元素集合的信息都将存储在一个数组 parent 中。

为了创建一个新集合(make_set(v) 操作),我们只需创建一个以顶点 v 为根的树,即它自身是其祖先。

为了合并两个集合(union_sets(a, b) 操作),我们首先找到包含 a 的集合的代表,以及包含 b 的集合的代表。如果代表相同,则无需任何操作,这两个集合已经合并。否则,我们可以简单地指定其中一个代表是另一个代表的父节点,从而合并这两棵树。

最后是查找代表函数的实现(find_set(v) 操作):我们只需沿着顶点 v 的祖先向上遍历,直到到达根节点,即一个祖先指向自身的节点。这个操作可以通过递归轻松实现。

void make_set(int v) {
    parent[v] = v;
}

int find_set(int v) {
    if (v == parent[v])
        return v;
    return find_set(parent[v]);
}

void union_sets(int a, int b) {
    a = find_set(a);
    b = find_set(b);
    if (a != b)
        parent[b] = a;
}

然而,这个实现是低效的。很容易构造一个例子,使得树退化为长链。在这种情况下,每次调用 find_set(v) 都可能需要 $O(n)$ 的时间。

这与我们期望的复杂度(近乎常数时间)相差甚远。因此,我们将考虑两种优化手段,以显著加快其运行速度。

路径压缩优化

这种优化是为了加速 find_set 操作。

如果我们调用 find_set(v) 来查找某个顶点 v 的代表,实际上我们在从 v 到实际代表 p 的路径上访问了所有顶点的代表 p。其技巧在于,通过将每个访问过的顶点的父节点直接设置为 p,从而缩短这些节点的路径。

你可以在下面的图中看到这个操作的效果。左边是一棵树,右边是在调用 find_set(7) 后压缩的树,它缩短了访问过的节点 7、5、3 和 2 的路径。

调用 find_set(7) 的路径压缩

find_set 的新实现如下:

int find_set(int v) {
    if (v == parent[v])
        return v;
    return parent[v] = find_set(parent[v]);
}

这个简单的实现达到了预期效果:首先找到集合的代表(根节点),然后在栈回溯过程中,将访问过的节点直接连接到代表。

这个简单的修改已经使得操作的平均时间复杂度达到 $O(\log n)$(这里不提供证明)。还有第二种修改可以使它更快。

按大小/按秩合并

在这个优化中,我们将改变 union_set 操作。具体来说,我们将改变哪棵树被连接到另一棵树上。在朴素实现中,第二棵树总是被连接到第一棵树上。在实际中,这可能导致树中出现长度为 $O(n)$ 的链。通过这种优化,我们将通过非常谨慎地选择哪棵树被连接来避免这种情况。

有许多可能的启发式方法可以使用。最常用的是以下两种方法:在第一种方法中,我们使用树的大小作为秩;在第二种方法中,我们使用树的深度(更准确地说,是树深度的上界,因为应用路径压缩时深度会减小)。

在这两种方法中,优化的核心思想是相同的:我们将秩较小的树连接到秩较大的树上。

以下是按大小合并的实现:

void make_set(int v) {
    parent[v] = v;
    size[v] = 1;
}

void union_sets(int a, int b) {
    a = find_set(a);
    b = find_set(b);
    if (a != b) {
        if (size[a] < size[b])
            swap(a, b);
        parent[b] = a;
        size[a] += size[b];
    }
}

以下是基于树深度按秩合并的实现:

void make_set(int v) {
    parent[v] = v;
    rank[v] = 0;
}

void union_sets(int a, int b) {
    a = find_set(a);
    b = find_set(b);
    if (a != b) {
        if (rank[a] < rank[b])
            swap(a, b);
        parent[b] = a;
        if (rank[a] == rank[b])
            rank[a]++;
    }
}

在时间复杂度和空间复杂度方面,这两种优化是等效的。因此在实际中,你可以使用其中的任意一种。

时间复杂度

正如前面提到的,如果我们同时使用路径压缩和按大小/按秩合并这两种优化,我们将达到近乎常数时间的查询复杂度。事实证明,最终的摊还时间复杂度是 $O(\alpha(n))$,其中 $\alpha(n)$ 是反阿克曼函数,其增长速度非常缓慢。实际上,它的增长速度如此之慢,以至于对于所有合理的 $n$(大约 $n < 10^{600}$),它都不会超过 4。

摊还复杂度是指在一系列多次操作中,每次操作的平均时间。其思想是在整个操作序列的总时间上进行保证,同时允许单次操作比摊还时间慢得多。例如,在我们的情况下,单次调用可能在最坏情况下需要 $O(\log n)$ 的时间,但如果连续进行 $m$ 次这样的调用,最终的平均时间将是 $O(\alpha(n))$。

我们也不会提供这种时间复杂度的证明,因为它相当长且复杂。

此外,值得注意的是,仅使用按大小/按秩合并而不使用路径压缩的 DSU,每次查询的时间复杂度为 $O(\log n)$。

按索引链接 / 抛硬币链接

无论是按秩合并还是按大小合并,都需要为每个集合存储额外的数据,并在每次合并操作中维护这些值。还存在一种随机化算法,可以简化合并操作:按索引链接。

我们为每个集合分配一个随机值,称为索引,并将索引较小的集合连接到索引较大的集合。通常情况下,较大的集合可能比小的集合具有更大的索引,因此这种操作与按大小合并密切相关。实际上可以证明,这种操作的时间复杂度与按大小合并相同。然而在实际应用中,它比按大小合并稍慢。

您可以在这里找到关于复杂度的证明以及更多合并技术。

void make_set(int v) {
    parent[v] = v;
    index[v] = rand();
}

void union_sets(int a, int b) {
    a = find_set(a);
    b = find_set(b);
    if (a != b) {
        if (index[a] < index[b])
            swap(a, b);
        parent[b] = a;
    }
}

一个常见的误解是,仅仅通过抛硬币来决定将哪个集合连接到另一个集合上,具有相同的复杂度。然而事实并非如此。上述链接的论文推测,抛硬币链接结合路径压缩的复杂度为$\Omega\left(n \frac{\log n}{\log \log n}\right)$。在基准测试中,它的性能比按大小/秩合并或按索引链接差得多。

void union_sets(int a, int b) {
    a = find_set(a);
    b = find_set(b);
    if (a != b) {
        if (rand() % 2)
            swap(a, b);
        parent[b] = a;
    }
}

应用与各种改进

在本节中,我们将考虑这种数据结构的几种应用,包括其简单的用途以及对数据结构的一些改进。

图中的连通分量

这是并查集的一个明显应用。

从形式上定义这个问题:最初我们有一个空图。我们需要添加顶点和无向边,并回答形如$(a, b)$的查询——“顶点$a$和$b$是否在图的同一个连通分量中?”

在这里,我们可以直接应用这种数据结构,从而得到一个解决方案,该方案在平均情况下,处理顶点或边的添加以及查询的时间复杂度接近常数。

这一应用相当重要,因为几乎相同的问题出现在Kruskal算法用于寻找最小生成树中。使用并查集,我们可以将复杂度从$O(m \log n + n^2)$改进为$O(m \log n)$。

在图像中搜索连通分量

并查集的一个应用是以下任务:有一个大小为$n \times m$像素的图像。最初所有像素都是白色的,但随后绘制了一些黑色像素。您希望确定最终图像中每个白色连通分量的大小。

对于解决方案,我们只需遍历图像中的所有白色像素,对于每个单元格遍历其四个邻居,并且如果邻居是白色的,则调用union_sets。因此,我们将得到一个包含$n m$个节点的并查集,这些节点对应于图像的像素。并查集中的结果树就是所需的连通分量。

这个问题也可以通过深度优先搜索广度优先搜索来解决,但这里描述的方法具有优势:它可以逐行处理矩阵(即,处理一行时,我们只需要前一行和当前行,并且只需要为一行的元素构建并查集),内存复杂度为$O(\min(n, m))$。

为每个集合存储额外信息

并查集允许您轻松地在集合中存储额外信息。

一个简单的例子是集合的大小:在按大小合并部分已经描述了存储大小的方法(信息存储在当前集合的代表节点中)。

同样地,通过将信息存储在代表节点上,您也可以存储关于集合的任何其他信息。

在一段区间内压缩跳跃 / 离线绘制子数组

并查集的一个常见应用是以下情况:有一组顶点,每个顶点都有一条指向另一个顶点的出边。使用并查集,您可以在几乎常数时间内找到从给定起始点沿着所有边到达的终点。

一个很好的应用示例是绘制子数组的问题。我们有一个长度为$L$的区间,每个元素最初的颜色为0。我们需要对每个查询$(l, r, c)$将子数组$[l, r]$重新绘制为颜色$c$。最后,我们希望找到每个单元格的最终颜色。我们假设已经知道所有的查询,即任务是离线的。

对于解决方案,我们可以构建一个并查集,为每个单元格存储一个指向下一个未绘制单元格的链接。因此,最初每个单元格指向自身。在绘制一个请求的区间后,该区间内的所有单元格将指向区间之后的单元格。

现在要解决这个问题,我们考虑逆序处理查询:从最后到最先。这样,当我们执行一个查询时,我们只需要精确地绘制子数组$[l, r]$中的未绘制单元格。其他单元格已经包含它们的最终颜色。为了快速遍历所有未绘制的单元格,我们使用并查集。我们找到一个区间内最左边的未绘制单元格,将其重新绘制,并通过指针移动到右边的下一个空单元格。

在这里,我们可以使用带有路径压缩的并查集,但不能使用按秩/大小合并(因为合并后谁成为领导者很重要)。因此,每次合并的复杂度为$O(\log n)$(这已经相当快了)。

实现如下:

for (int i = 0; i <= L; i++) {
    make_set(i);
}

for (int i = m-1; i >= 0; i--) {
    int l = query[i].l;
    int r = query[i].r;
    int c = query[i].c;
    for (int v = find_set(l); v <= r; v = find_set(v)) {
        answer[v] = c;
        parent[v] = v + 1;
    }
}

有一个优化方法:我们可以使用按秩合并,如果我们将下一个未绘制的单元格存储在一个额外的数组end[]中。然后我们可以根据它们的启发式规则将两个集合合并为一个,并在$O(\alpha(n))$时间内获得解决方案。

支持到代表的距离

在并查集的某些特定应用中,您需要维护一个顶点与其集合代表之间的距离(即从当前节点到树根的路径长度)。

如果我们不使用路径压缩,距离就是递归调用的次数。但这将效率低下。

然而,如果我们在每个节点上存储到父节点的距离作为额外信息,就可以实现路径压缩。

在实现中,使用一个包含对的数组parent[]会更方便,而find_set函数现在返回两个数字:集合的代表和到它的距离。

void make_set(int v) {
    parent[v] = make_pair(v, 0);
    rank[v] = 0;
}

pair<int, int> find_set(int v) {
    if (v != parent[v].first) {
        int len = parent[v].second;
        parent[v] = find_set(parent[v].first);
        parent[v].second += len;
    }
    return parent[v];
}

void union_sets(int a, int b) {
    a = find_set(a).first;
    b = find_set(b).first;
    if (a != b) {
        if (rank[a] < rank[b])
            swap(a, b);
        parent[b] = make_pair(a, 1);
        if (rank[a] == rank[b])
            rank[a]++;
    }
}

支持路径长度的奇偶性 / 在线检查二分图

与计算到领导者的路径长度一样,也可以维护路径长度的奇偶性。

为什么这个应用要单独作为一个段落呢?

存储路径奇偶性的不寻常需求出现在以下任务中:最初我们有一个空图,可以添加边,并且我们需要回答形如“包含这个顶点的连通分量是否是二分图?”的查询。

为了解决这个问题,我们构建一个并查集来存储连通分量,并为每个顶点存储到代表的路径长度的奇偶性。这样我们可以快速检查添加一条边是否会违反二分性:如果边的两个端点位于同一个连通分量,并且它们到领导者的路径长度具有相同的奇偶性,那么添加这条边将产生一个奇数长度的环,从而该连通分量将失去二分图的性质。

我们面临的唯一困难是在union_find方法中计算奇偶性。

如果我们添加一条边$(a, b)$将两个连通分量合并为一个,那么在将一棵树连接到另一棵树时,我们需要调整奇偶性。

让我们推导一个公式,用于计算附加到另一个集合的集合的领导者的奇偶性。
设 ( x ) 是从顶点 ( a ) 到其领导者 ( A ) 的路径长度的奇偶性,( y ) 是从顶点 ( b ) 到其领导者 ( B ) 的路径长度的奇偶性,( t ) 是合并后我们需要分配给 ( B ) 的期望奇偶性。
路径由三部分组成:从 ( B ) 到 ( b ),从 ( b ) 到 ( a )(通过一条边连接,因此奇偶性为 1),以及从 ( a ) 到 ( A )。
因此我们得到公式((\oplus) 表示异或运算):

[
t = x \oplus y \oplus 1
]

因此,无论我们执行多少次合并操作,边的奇偶性都会从一个领导者传递到另一个领导者。

以下是支持奇偶性的并查集(DSU)的实现。与前一节类似,我们使用一个对来存储祖先和奇偶性。此外,对于每个集合,我们在数组 bipartite[] 中存储它是否仍然是二分图。

void make_set(int v) {
    parent[v] = make_pair(v, 0);
    rank[v] = 0;
    bipartite[v] = true;
}

pair<int, int> find_set(int v) {
    if (v != parent[v].first) {
        int parity = parent[v].second;
        parent[v] = find_set(parent[v].first);
        parent[v].second ^= parity;
    }
    return parent[v];
}

void add_edge(int a, int b) {
    pair<int, int> pa = find_set(a);
    a = pa.first;
    int x = pa.second;

    pair<int, int> pb = find_set(b);
    b = pb.first;
    int y = pb.second;

    if (a == b) {
        if (x == y)
            bipartite[a] = false;
    } else {
        if (rank[a] < rank[b])
            swap(a, b);
        parent[b] = make_pair(a, x^y^1);
        bipartite[a] &= bipartite[b];
        if (rank[a] == rank[b])
            ++rank[a];
    }
}

bool is_bipartite(int v) {
    return bipartite[find_set(v).first];
}

离线 RMQ(范围最小值查询)的平均复杂度为 (O(\alpha(n))) / Arpa 的技巧 { #arpa data-toc-label="离线 RMQ / Arpa 的技巧"}

给定一个数组 a[],我们需要计算数组中给定区间的最小值。

使用并查集解决此问题的思想如下:
我们遍历数组,当我们在第 (i) 个元素时,我们将回答所有查询 ((L, R)),其中 (R == i)。
为了高效实现这一点,我们使用前 (i) 个元素构建一个并查集,其结构为:一个元素的父节点是其右侧的下一个更小的元素。
然后,利用这种结构,查询的答案将是 (a[\text{find\_set}(L)]),即 (L) 右侧的最小值。

显然,这种方法只能离线使用,即在已知所有查询的情况下。

很容易看出,我们可以应用路径压缩。
此外,如果我们将实际的领导者存储在一个单独的数组中,我们也可以使用按秩合并。

struct Query {
    int L, R, idx;
};

vector<int> answer;
vector<vector<Query>> container;

container[i] 包含所有 (R == i) 的查询。

stack<int> s;
for (int i = 0; i < n; i++) {
    while (!s.empty() && a[s.top()] > a[i]) {
        parent[s.top()] = i;
        s.pop();
    }
    s.push(i);
    for (Query q : container[i]) {
        answer[q.idx] = a[find_set(q.L)];
    }
}

如今,这种算法被称为 Arpa 的技巧。
它以 AmirReza Poorakhavan 的名字命名,他独立发现了这种技术并将其普及。尽管这种算法在他发现之前就已经存在。

离线 LCA(树的最低公共祖先)的平均复杂度为 (O(\alpha(n))) {data-toc-label="离线 LCA"}

寻找 LCA 的算法在文章 Lowest Common Ancestor - Tarjan's off-line algorithm 中进行了讨论。
与其他寻找 LCA 的算法相比,这种算法因其简单性而受到青睐(尤其是与 Farach-Colton and Bender 的最优算法相比)。

显式地将 DSU 存储在集合列表中 / 将此思想应用于合并各种数据结构

存储 DSU 的另一种替代方法是以 显式存储的元素列表 的形式保留每个集合。
同时,每个元素也存储对其集合代表的引用。

乍一看,这似乎是一种低效的数据结构:合并两个集合时,我们需要将一个列表添加到另一个列表的末尾,并更新其中一个列表中所有元素的领导权。

然而,事实证明,使用 加权启发式(类似于按大小合并)可以显著降低渐进复杂度:
在 (n) 个元素上执行 (m) 次查询的复杂度为 (O(m + n \log n))。

所谓加权启发式,即我们总是将 较小的集合添加到较大的集合中
union_sets 中实现将一个集合添加到另一个集合的操作很容易,并且其时间复杂度与被添加集合的大小成正比。
而使用这种存储方法,find_set 中查找领导者的操作将花费 (O(1)) 时间。

让我们证明执行 (m) 次查询的 时间复杂度 (O(m + n \log n))。
我们固定一个任意元素 (x),并计算它在 union_sets 合并操作中被触及的次数。
当元素 (x) 第一次被触及时,新集合的大小至少为 2。
当它第二次被触及时,结果集合的大小至少为 4,因为较小的集合被添加到较大的集合中。
依此类推。
这意味着 (x) 最多只能在 (\log n) 次合并操作中被移动。
因此,对所有顶点求和得到 (O(n \log n)),加上每次请求的 (O(1))。

以下是实现代码:

vector<int> lst[MAXN];
int parent[MAXN];

void make_set(int v) {
    lst[v] = vector<int>(1, v);
    parent[v] = v;
}

int find_set(int v) {
    return parent[v];
}

void union_sets(int a, int b) {
    a = find_set(a);
    b = find_set(b);
    if (a != b) {
        if (lst[a].size() < lst[b].size())
            swap(a, b);
        while (!lst[b].empty()) {
            int v = lst[b].back();
            lst[b].pop_back();
            parent[v] = a;
            lst[a].push_back(v);
        }
    }
}

这种将较小部分添加到较大部分的思想也可以应用于许多与 DSU 无关的解决方案中。

例如,考虑以下 问题
给定一棵树,每个叶子节点都有一个数字(相同的数字可以在不同的叶子节点上多次出现)。
我们希望计算树中每个节点的子树中不同数字的数量。

将这种思想应用于该任务,可以得到以下解决方案:
我们可以实现一个 DFS,它将返回一个指向整数集合的指针——该子树中的数字列表。
然后,为了得到当前节点的答案(除非它是叶子节点),我们对当前节点的所有子节点调用 DFS,并将所有接收到的集合合并在一起。
结果集合的大小将是当前节点的答案。
为了高效地合并多个集合,我们只需应用上述描述的方法:通过将较小的集合添加到较大的集合中来合并集合。
最终,我们得到一个 (O(n \log^2 n)) 的解决方案,因为一个数字最多只会被添加到集合中 (O(\log n)) 次。

通过维护清晰的树结构存储 DSU / 在平均时间复杂度为 $O(\alpha(n))$ 的情况下在线查找桥 {data-toc-label="通过维护清晰的树结构存储 DSU / 在线查找桥"}

DSU 最强大的应用之一是它允许你同时存储 压缩树和未压缩树
压缩形式可用于合并树以及验证两个顶点是否在同一棵树中,而未压缩形式可用于(例如)查找两个给定顶点之间的路径或其他树结构的遍历。

在实现中,这意味着除了压缩祖先数组 parent[] 之外,我们还需要维护一个未压缩祖先数组 real_parent[]
维护这个额外数组并不会使复杂度变差:对它的更改仅在合并两棵树时发生,且仅涉及一个元素。

另一方面,在实际应用中,我们通常需要通过指定的边连接树,而不是使用两个根节点。
这意味着我们别无选择,只能重新根化其中一棵树(将边的端点作为树的新根)。

乍一看,这种重新根化似乎代价高昂,会大大增加时间复杂度。
确实,为了将树根设在顶点 $v$,我们需要从顶点走到旧根,并且对路径上所有节点的 parent[]real_parent[] 改变方向。

然而实际上,情况并没有那么糟糕,我们只需像前面章节中提到的那样重新根化较小的树,从而在平均情况下达到 $O(\log n)$。

更多细节(包括时间复杂度的证明)可以在文章 在线查找桥 中找到。

历史回顾

DSU 数据结构早已为人所知。

树的森林形式 存储这种结构的方式最早由 Galler 和 Fisher 在 1964 年描述(Galler, Fisher, "An Improved Equivalence Algorithm"),然而对其时间复杂度的完整分析则是在很久之后才进行的。

路径压缩和按秩合并的优化是由 McIlroy 和 Morris 开发的,Tritter 也独立地进行了开发。

Hopcroft 和 Ullman 在 1973 年展示了时间复杂度 $O(\log^\star n)$(Hopcroft, Ullman "Set-merging algorithms")——这里的 $\log^\star$ 是 迭代对数(这是一个增长缓慢的函数,但仍然不如逆阿克曼函数增长得那么慢)。

1975 年首次证明了 $O(\alpha(n))$ 的评估(Tarjan "Efficiency of a Good But Not Linear Set Union Algorithm")。
1985 年,他与 Leeuwen 一起发表了多种不同秩启发式方法和路径压缩方式的复杂度分析(Tarjan, Leeuwen "Worst-case Analysis of Set Union Algorithms")。

最终在 1989 年,Fredman 和 Sachs 证明了在所采用的计算模型中,任何 解决不相交集合并问题的算法都必须在平均情况下至少需要 $O(\alpha(n))$ 的时间(Fredman, Saks, "The cell probe complexity of dynamic data structures")。

本文由mdnice多平台发布


cp_algorithm_zh
1 声望0 粉丝