Data Structure-7-Disjoint Sets
#CS225
Disjoint Sets
等价关系 (Equivalence Relation)
这是理解不相交集的一个数学前提。
-
定义: 一个在集合
上的二元关系 如果满足以下三个性质,则称为等价关系:-
自反性 (Reflexive): 对于所有
, (每个元素都与自身相关)。 -
对称性 (Symmetric): 对于所有
, 如果 ,则 (如果 s 与 t 相关,则 t 与 s 相关)。 -
传递性 (Transitive): 对于所有
, 如果 且 ,则 (如果 s 与 t 相关,t 与 u 相关,则 s 与 u 相关)。
-
自反性 (Reflexive): 对于所有
-
等价类 (Equivalence Class): 等价关系会将集合
划分成若干个互不相交的子集,每个子集称为一个等价类。同一个等价类中的所有元素都互相等价。- 例子:如果关系
定义为“两个人 和 有相同的最喜欢的选项 (A, B, C, D, E, F 中的一个)”,那么所有喜欢 A 的人组成一个等价类,所有喜欢 B 的人组成另一个等价类,以此类推。 表示元素 所在的等价类。例如,如果学生 3 和学生 4 都最喜欢 B,那么 。
- 例子:如果关系
不相交集 (Disjoint Sets)
不相交集数据结构,也常被称为 并查集 (Union-Find),用于维护一个包含
-
核心概念:
- 集合中的每个元素都只属于一个特定的子集。
- 每个子集都有一个 代表元 (Representative Member),用来唯一标识这个子集。
-
主要操作 (Operations):
-
makeSet(x)
: 创建一个新的集合,其中只包含元素x
。x
成为这个新集合的代表元。 -
find(x)
: 返回包含元素x
的集合的代表元。通过比较find(x)
和find(y)
的结果,可以判断x
和y
是否在同一个集合中。- 例如,
find(4) == find(8)
意味着元素 4 和元素 8 属于同一个集合。
- 例如,
-
union(x, y)
: 合并包含元素x
和元素y
的两个集合。如果它们已经在同一个集合中,则不进行任何操作。通常,合并操作是基于它们的代表元进行的。-
示例
1 2 3 4 5
if (find(elem1) != find(elem2)){ // If the two elements are not in the set // Union their sets through the representative element union (find(elem1), find(elem2)); }
-
-
-
不相交集抽象数据类型 (Disjoint Sets ADT):
- 维护一个集合
。 - 每个子集有一个代表元。
- API (接口):
void makeSet(const T & t);
-
void union(const T & k1, const T & k2);
(更准确地,这里应该是unionSets
,参数是两个集合中的任意元素,或者直接是两个集合的代表元) T & find(const T & k);
- 维护一个集合
实现方案 (Implementations)
实现1:朴素数组表示
-
思路: 使用一个数组,数组的索引代表元素,数组的值代表该元素所在集合的代表元。
- 例如:
元素:
0 1 2 3 4 5 6 7
代表元:0 0 2 3 0 3 3 2
- 集合1:
{0, 1, 4}
(代表元是 0) - 集合2:
{2, 7}
(代表元是 2) - 集合3:
{3, 5, 6}
(代表元是 3)
- 集合1:
- 例如:
元素:
-
操作复杂度:
-
Find(k)
: 直接返回数组在索引k
处的值。时间复杂度 (Slide 27)。- 例如
Find(4)
返回0
。
- 例如
-
Union(k1, k2)
: (假设k1
和k2
是两个集合的代表元) 要合并代表元为k1
和k2
的两个集合,例如将所有代表元为k2
的元素的代表元改为k1
。这需要遍历整个数组。时间复杂度 ,其中 是元素的总数。- 例如
Union(0, 2)
: 将所有代表元为2
的元素 (即元素 2 和 7) 的代表元改为0
。 数组变为:0 0 0 3 0 3 3 0
- 例如
-
这种实现中 Union
操作太慢,不实用。
实现2: Uptrees
这是更高效和常用的实现方式。
-
思路:
- 仍然使用数组,数组的索引是 键 (key) (即元素本身)。
- 数组的值 (value) 的含义:
- 如果值是
-1
(或者其他特殊负数,表示大小或高度时),则表示当前索引是其所在集合的代表元 (即树的根节点)。 - 如果值是非负数,则表示当前索引的 父节点的索引 (index of the parent)。
- 如果值是
- 这样,每个集合都表示为一个树形结构,树的根节点就是这个集合的代表元。所有边都从子节点指向父节点,因此称为 “UpTree”。
-
初始化 (makeSet):
- 开始时,每个元素都是一个独立的集合,即每个元素都是一个根。
- 数组
arr
: 索引:0 1 2 3
值:-1 -1 -1 -1
(所有元素都是根)
-
union(root1, root2)
操作 (简单版本):- 假设
root1
和root2
是两个不同集合的根。 - 使一个根成为另一个根的子节点。例如,
arr[root2] = root1
(使root2
指向root1
)。 - 例子:
union(1, 2)
(这里假设 1 和 2 是根) 初始:arr = [-1, -1, -1, -1]
(元素 0, 1, 2, 3)find(1)
返回 1,find(2)
返回 2。 执行arr[2] = 1
(让 2 的父节点是 1)。arr
变为[-1, -1, 1, -1]
可视化: 0, 3 还是根;1 也是根,2 指向 1。1 2 3 4
0 1 3 ^ | 2
- 例子:
union(0, 3)
arr
当前为[-1, -1, 1, -1]
find(0)
返回 0,find(3)
返回 3。 执行arr[0] = 3
(让 0 的父节点是 3)。arr
变为[3, -1, 1, -1]
可视化:1 2 3 4
3 1 ^ ^ | | 0 2
- 例子:
union(3, 1)
(此时 3 和 1 都是根)arr
当前为[3, -1, 1, -1]
find(3)
返回 3,find(1)
返回 1。 执行arr[3] = 1
(让 3 的父节点是 1)。arr
变为[3, -1, 1, 1]
可视化:1 2 3 4 5 6 7
1 / | \ / | \ 2 3 (原来1的孩子) ^ | 0 (原来3的孩子)
此时根是 1,元素 0,2,3 都最终指向 1。
- 假设
-
find(i)
操作:- 从索引
i
开始,沿着父节点指针向上查找,直到找到一个值为负数 (或特定标记) 的节点,该节点即为根。 - C++ 实现:
1 2 3 4 5 6 7
int DisjointSets::find(int i) { if ( s[i] < 0 ) { // s[i] < 0 表示 i 是根节点 return i; } else { return find( s[i] ); // 递归查找父节点的根 } }
-
运行时间 (Running time): 在最坏情况下 (例如,树退化成一个链表),
find
操作可能需要 时间。 -
理想的 UpTree (Ideal UpTree): 树应该尽可能地“扁平”或“茂密”,这样从任何节点到根的路径都很短。目标是
或接近 的查找时间。
- 从索引
优化 UpTrees (Optimizations for UpTrees)
朴素的 union
操作 (简单地将一个根指向另一个根) 可能导致树变得很高,从而使得 find
操作变慢。因此需要优化 union
操作。
1. 按高度/秩合并 (Union by Height / Union by Rank)
- 思路: 在合并两个集合时,总是将较矮的树的根连接到较高的树的根上。
-
记录高度: 每个根节点需要存储其对应树的高度。通常,我们将高度存为负值在数组中 (例如,如果根
r
的树高度为h
,则arr[r] = -h
,但为了区分根,通常是- (height + 1)
或者用一个单独的数组来存高度)。- 当合并两个高度相同的树时,新树的高度会增加 1。
- 当合并两个高度不同的树时,新树的高度等于原来较高的那个树的高度。
-
效果: 这种策略能保证树的高度最多为
。 -
幻灯片中的例子:
- 树 T1 (根7): 高度
。arr[7]
可能存-3
(表示高度2)。 - 树 T2 (根4): 高度
。arr[4]
可能存-4
(表示高度3)。 -
union(7, 4)
: 因为 T1 (高度2) 比 T2 (高度3) 矮,所以将 T1 的根 7 指向 T2 的根 4。arr[7] = 4
。合并后树的根仍然是 4,高度仍然是 3。arr[4]
保持-4
。 - 如果两个树高度相同,例如都是
,合并后新根的高度变为 。例如,arr[new_root]
变为-( (h+1) + 1 )
。
- 树 T1 (根7): 高度
2. 按大小合并 (Union by Size)
- 思路: 在合并两个集合时,总是将元素较少的树的根连接到元素较多的树的根上。
-
记录大小: 每个根节点需要存储其对应树中元素的数量 (即集合的大小)。通常,我们将大小存为负值在数组中 (例如,如果根
r
的集合大小为size
,则arr[r] = -size
)。 -
效果: 这种策略也能保证树的高度最多为
(Slide 82)。 -
幻灯片中的例子:
-
树 T1 (根7): 大小 $ T_1 =8$。 arr[7]
存-8
。 -
树 T2 (根4): 大小 $ T_2 =4$。 arr[4]
存-4
。 -
union(7, 4)
: 因为 T2 (大小4) 比 T1 (大小8) 小,所以将 T2 的根 4 指向 T1 的根 7。arr[4] = 7
。合并后树的根是 7,新大小是 。arr[7]
更新为-12
。
-
-
C++ 实现
unionBySize
:- 函数原型:
void DisjointSets::unionBySize(int root1, int root2)
-
前提:
root1
和root2
必须是它们各自集合的根。这意味着在调用unionBySize
之前,已经对原始元素执行了find
操作。
-
前提:
- 数组
arr_
:arr_[i]
如果为负,表示i
是根,且-arr_[i]
是该集合的大小。 - 步骤:
-
int newSize = arr_[root1] + arr_[root2];
(因为大小是负数存储的,所以直接相加)。 - 比较
arr_[root1]
和arr_[root2]
。值越小 (即负得越多),表示集合越大。 - 如果
arr_[root1] < arr_[root2]
(即root1
对应的集合更大):-
arr_[root2] = root1;
(将root2
指向root1
) -
arr_[root1] = newSize;
(更新root1
的大小)
-
- 否则 (即
root2
对应的集合更大或一样大):-
arr_[root1] = root2;
(将root1
指向root2
) -
arr_[root2] = newSize;
(更新root2
的大小)
-
-
-
时间复杂度: 由于
root1
和root2
已经是根,这个函数本身的操作是 。但是,调用它的union
操作还需要两次find
,所以总的union
操作时间取决于find
。
- 函数原型:
3. 路径压缩 (Path Compression)
这是对 find
操作的优化,与 union by size/height
配合使用效果极佳。
-
思路: 在执行
find(i)
操作,从i
向上走到根的路径上,将路径上的所有节点直接连接到根节点。这样可以使树变得更扁平,从而加速后续的find
操作。 -
例子:
- 假设执行
find(4)
。路径是 (10是根)。 - 在找到根 10 之后,路径压缩会修改指针,使得 4, 2, 7, 9 都直接指向 10。
-
arr[4] = 10
,arr[2] = 10
,arr[7] = 10
,arr[9] = 10
。
- 假设执行
-
C++ 实现
find
(带路径压缩):1 2 3 4 5 6 7 8 9
int DisjointSets::find(int i) { if ( arr_[i] < 0 ) { // i 是根节点 return i; } else { // 递归查找父节点的根,并将 arr_[i] 直接指向最终的根 arr_[i] = find( arr_[i] ); return arr_[i]; } }
- 效果: 路径压缩并不会改变树的实际高度或秩 (对于 Union by Height/Rank 来说),但它能显著降低平均查找时间。
时间复杂度分析 (Time Complexity Analysis)
1. 迭代对数函数 (Iterated Logarithm Function)
- 符号:
(读作 “log star n”) - 定义:
是需要对 反复取对数 (通常以 2 为底) 多少次,结果才会小于或等于 1。 - 例子:
(原始数) (第一次取 log) (第二次取 log) (第三次取 log) (第四次取 log) (第五次取 log, 结果 ) 所以, 。
是一个增长极其缓慢的函数。对于所有在宇宙中可观测到的原子数量(大约 ), 也只是一个非常小的数字 (大约是 5)。在实践中,可以认为它几乎是一个常数 (通常 )。
2. 结合优化的并查集性能
- 当同时使用 智能合并 (Smart Unions) (即 Union by Size 或 Union by Height) 和 路径压缩 (Path Compression) 时:
- 任何
次union
或find
操作序列的总时间复杂度是 ,其中 是元素数量, 是操作次数。 是 反阿克曼函数 (Inverse Ackermann function),它的增长比 还要慢,对于所有实际的 值, 不会超过 5。- 因此,单次操作的 均摊时间复杂度 (Amortized Time Complexity) 几乎是常数,即
或近似为 。 - 幻灯片中写作
,这在实践中非常接近 。
- 任何
UpTree 总结
- UpTree 用数组实现。
- 代表一个集合 (或者说,一个 UpTree 的森林代表了一系列不相交集合/等价类)。
- 通过路径压缩和按大小/秩合并,查找代表元的操作接近
(均摊)。 - 一个缺点: 不能快速找到集合中的所有元素。如果需要这个功能,需要额外的数据结构或遍历。
其他数据结构回顾
-
Array (数组):
-
Sorted Array
(有序数组): 查找 (二分查找),插入/删除 。 -
Unsorted Array
(无序数组): 查找 ,端点插入/删除 (均摊,考虑resize)。 -
Stacks
(栈 - LIFO): 基于数组实现,操作 。 -
Queues
(队列 - FIFO): 基于数组实现 (循环数组),操作 。 -
Hashing
(哈希表): 平均 查找/插入/删除。 -
Heaps
(堆): 通常用于Priority Queues
(优先队列)。插入/删除最高优先级 。 -
UpTrees
/Disjoint Sets
(不相交集)。
-
-
List (链表):
-
Doubly Linked List
(双向链表): 插入/删除 (如果知道节点) ,查找 。 -
Skip List
(跳表): 平均 查找/插入/删除。 -
Trees
(树):-
BTree
(B树): 用于磁盘存储,多路查找树。 -
Binary Tree
(二叉树): 包括二叉搜索树 (BST)。 -
Huffman Encoding
(霍夫曼编码): 用于数据压缩的树。 -
kd-Tree
(k维树): 用于多维空间数据查找。 -
AVL Tree
(AVL树): 自平衡二叉搜索树,操作 。
-
-
- Graphs (图): 广义的连接关系结构。