并查集
本文讨论了并查集(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)
,则a
和b
恰好属于同一个集合;否则它们属于不同的集合。
正如后面将详细描述的那样,该数据结构能够在平均情况下近乎以 $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
的新实现如下:
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多平台发布
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。