slide:cs61b 2020 lec14 ds1 disjoint sets - Google 幻灯片

Reading:9.1 Introduction · Hug61B (gitbooks.io)

code:Case Study: Union-Find (princeton.edu)

Disjoint Sets简介

如果两个集合没有共同元素,则称它们为不相交集合。一个不相交集合(或者称为并查集)数据结构用于追踪固定数量的元素,这些元素被划分为多个不相交集合。该数据结构具有两个操作:

  1. connect(x, y): 连接 xy。也称为 union
  2. isConnected(x, y): 如果 xy 连接(即属于同一集合),则返回true。

不相交集合数据结构有一定数量的元素,每个元素最初都位于自己的子集中。通过对某些元素 xy 调用 connect(x, y),我们可以将子集合并在一起。

例如,假设我们有四个元素,我们将它们称为 A、B、C、D。初始时,每个元素都在自己的集合中:

img

调用 connect(A, B) 后:

img

请注意,子集 A 和 B 被合并。让我们来检查一些 isConnected 调用的输出:

1
2
CodeisConnected(A, B) -> true
isConnected(A, C) -> false

调用 connect(A, D) 后:

img

我们找到了 A 所属的集合,并将其与 D 所属的集合合并,形成了一个大的 A、B、D 集合。C 保持不变。 isConnected(A, D) -> true isConnected(A, C) -> false

有了这个直观理解,让我们正式定义一下我们的不相交集合接口是什么样子的。作为提醒,一个 接口 确定了一个数据结构应该具有的行为(但不包括实现方法)。现在,我们只处理非负整数集合。这并不是一个限制,因为在实际应用中,我们可以为我们想要表示的任何内容分配整数值。

1
2
3
4
5
6
7
public interface DisjointSets {
/** 连接两个项目 P 和 Q */
void connect(int p, int q);

/** 检查两个项目是否连接 */
boolean isConnected(int p, int q);
}

除了学习如何实现一个迷人的数据结构之外,这一章还将是一个了解数据结构实现如何演变的机会。我们将讨论四个不相交集合设计的迭代过程:Quick Find → Quick Union → Weighted Quick Union (WQU) → WQU with Path Compression我们将看到设计决策如何极大地影响渐近运行时间和代码复杂度。

快速查找Quick Find

现在我们来解决如何实现我们的 DisjointSets 接口所需的行为。我们的挑战是跟踪集合成员关系。

集合列表

直观地,我们可能首先考虑将不相交集合表示为一个集合列表,例如 List<Set<Integer>>

例如,如果我们有 N=6 个元素,并且还没有连接任何元素,我们的集合列表看起来像:[{0}, {1}, {2}, {3}, {4}, {5}, {6}]。看起来不错。然而,考虑如何完成像 connect(5, 6) 这样的操作。我们将不得不遍历多达 N 个集合来找到5,以及多达 N 个集合来找到6。我们的运行时间变为 O(N)。而且,如果你试图实现这个,代码会非常复杂。

要记住的教训是 初始设计决策决定了我们的代码复杂度和运行时间。

快速查找

让我们考虑另一种方法,使用一个 整数数组

  • 数组的索引表示我们集合的元素。
  • 索引处的是它所属的集合编号。

例如,我们将 {0, 1, 2, 4}, {3, 5}, {6} 表示为:

img

数组索引(0…6)是元素。id[i] 处的值是它所属的集合。具体的集合编号并不重要,只要同一集合中的所有元素共享相同的id。

connect(x, y)

让我们看看连接操作如何工作。当前,id[2] = 4id[3] = 5。调用 connect(2, 3) 后,所有id为4和5的元素应该具有相同的id。现在暂时将它们全部赋值为5:

img

isConnected(x, y)

要检查 isConnected(x, y),我们只需检查 id[x] == id[y]。请注意,这是一个常数时间操作!

我们将此实现称为 “快速查找”,因为查找元素是否相连需要常数时间。

总结和代码

实现方式 构造函数 connect isConnected
集合列表 Θ(N) O(N) O(N)
快速查找 Θ(N) Θ(N) Θ(1)

N = 不相交集合数据结构中的元素数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class QuickFindDS implements DisjointSets {

private int[] id;

/* Θ(N) */
public QuickFindDS(int N){
id = new int[N];
for (int i = 0; i < N; i++){
id[i] = i;
}
}

/* 需要遍历数组 => Θ(N) */
public void connect(int p, int q){
int pid = id[p];
int qid = id[q];
for (int i = 0; i < id.length; i++){
if (id[i] == pid){
id[i] = qid;
}
}
}

/* Θ(1) */
public boolean isConnected(int p, int q){
return (id[p] == id[q]);
}
}

快速合并Quick Union

假设我们优先考虑使 connect 操作快速。我们仍然将使用数组来表示我们的集合。但是,与其使用一个id,我们将每个项目分配给其父项的索引。如果一个项目没有父项,那么它就是一个 ‘根’,我们为其分配一个负值。

这种方法使我们可以将我们的每个集合想象成一棵树。例如,我们将 {0, 1, 2, 4}, {3, 5}, {6} 表示为:

img

请注意,我们使用只有一个数组来表示集合。我们自己将其视为树。

对于快速合并,我们定义一个辅助函数 find(int item),它返回项目所在树的根。例如,对于上面的集合,find(4) == 0find(1) == 0find(5) == 3 等。每个元素都有一个唯一的根。

connect(x, y)

要连接两个项目,我们找到每个项目所属的集合(它们各自树的根),并将其中一个作为另一个的子项。例如:

connect(5, 2)

  1. find(5) -> 3
  2. find(2) -> 0
  3. 设置 find(5) 的值为 find(2),也就是 parent[3] = 0

img

请注意,元素3现在指向元素0,将两棵树/集合合并为一棵树。

在最佳情况下,如果 xy 都是它们树的根,那么 connect(x, y) 就会简单地使 x 指向 y,这是一个 Θ(1) 的操作!(因此称为快速合并)

isConnected(x, y)

如果两个元素属于同一个集合,那么它们将在同一棵树中。因此,它们将具有相同的根。因此对于 isConnected(x, y),我们只需检查 find(x) == find(y)

性能

快速合并存在潜在的性能问题:树可能变得非常长。在这种情况下,找到一个项目的根 (find(item)) 就变得非常昂贵。考虑下面的树:

9.3.3

在最坏的情况下,我们必须遍历所有项目才能到达根,这是一个 Θ(N) 的运行时间。由于我们必须对 connectisConnected 都调用 find,所以它们的运行时间都由 O(N) 上限约束。

总结和代码

实现方式 构造函数 connect isConnected
集合列表 Θ(N) O(N) O(N)
快速合并 Θ(N) O(N) O(N)
快速查找 Θ(N) Θ(N) Θ(1)

N = 不相交集合数据结构中的元素数量

从运行时间表中,快速合并似乎比快速查找更差!但请注意,O(N) 是一个 上限。当我们的树是平衡的时候,connectisConnected 都表现得相当好。在下一节中,我们将看到如何 保证 它们的性能良好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class QuickUnionDS implements DisjointSets {
private int[] parent;
public QuickUnionDS(int num) {
parent = new int[num];
for (int i = 0; i < num; i++) {
parent[i] = i;
}
}

private int find(int p) {
while (parent[p] >= 0) {
p = parent[p];
}
return p;
}

@Override
public void connect(int p, int q) {
int i = find(p);
int j= find(q);
parent[i] = j;
}

@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
}

加权快速联合Weighted Quick Uion(WQU)

Quick Union 的改进依赖于一个关键的见解:每当我们调用 find 时,我们都必须爬到树的根部。因此,树越短,速度就越快!

新规则:每当我们调用 connect 时,我们总是将较小树的根链接到较大树

遵循此规则将为您的树提供最大高度 logN ,其中 N 是不相交集中的元素数量。这如何影响 connectisConnected 的运行时?

让我们通过一个例子来说明这样做的好处。考虑连接下面两组 T1 和 T2:

img

我们有两种连接它们的选项:

img 第一个选项我们将 T1 链接到 T2。在第二个中,我们将 T2 链接到 T1。

第二个选项更可取,因为它的高度只有 2,而不是 3。根据我们的新规则,我们也会选择第二个选项,因为 T2 小于 T1(尺寸为 3 与 6)。

我们根据树中的项目数量来确定更小/更大。因此,当连接两棵树时,我们需要知道它们的大小(或重量)。我们可以通过用 -(size of tree) 替换 -1 来将此信息存储在树的根中。您将在Lab 6中实现这一点。

最大高度:Log N

遵循上述规则可确保任何树的最大高度为 θ(log N)。 N 是不相交集中的元素数量。通过扩展, connectisConnected 的运行时间以 O(log N) 为界。

image-20240207091146331

image-20240207091744666

您可能想知道为什么我们不根据高度而是重量来链接树木。事实证明,这实现起来更复杂,并且给了我们相同的 θ(log N) 高度限制。

image-20240207091815565

总结和代码

Implementation Constructor connect isConnected
ListOfSets Θ(N) O(N) O(N)
QuickFind Θ(N) Θ(N) Θ(1)
QuickUnion Θ(N) O(N) O(N)
Weighted Quick Union Θ(N) O(log N) O(log N)

N = number of elements in our DisjointSets data structure

代码:Lab 6中实现 or WeightedQuickUnionUF.java (princeton.edu)

带路径压缩的加权快速联合WQU with Path Compression

每当我们调用 find(x) 时,我们都必须遍历从 x 到 root 的路径。因此,在此过程中,我们可以将我们访问的所有项目连接到它们的根,而无需额外的渐近成本。

将沿途的所有项目连接到根将有助于每次调用 find 时使我们的树更短。

回想一下, connect(x, y)isConnected(x, y) 总是调用 find(x)find(y) 。因此,在足够多地调用 connectisConnected 之后,基本上所有元素都将直接指向它们的根。

通过扩展, connectisConnected 的平均运行时间从长远来看几乎保持不变!这称为摊销运行时间(来自摊销分析,第 8.4 章)。

更具体地说,对于 N 个元素上的 M 次操作,具有路径压缩的 WQU 为 O(N + M (lg* N))lg* 是迭代对数,对于任何现实世界的输入都小于 5。

lg*:

image-20240207091913787

概括

N:不相交集中的元素数量

执行 isConnected connect
快速查找 Θ(N) Θ(1)
快联 O(N) O(N)
加权快速联合 (WQU) O(log N) O(log N)
带路径压缩的 WQU O(α(N))* O(α(N))*

*长期表现稳定。

代码:WeightedQuickUnionPathCompressionUF.java (princeton.edu)

总结

image-20240207091954317