相关笔记

概览

本节介绍不相交集合数据结构的第一种具体实现——链表表示。每个集合用一个单链表表示,包含指向头节点(head)和尾节点(tail)的指针。MAKE-SET 和 FIND-SET 都能在 时间内完成,但简单实现的 UNION 需要 时间(因为需要更新被合并链表中所有元素的 set 指针)。通过引入加权合并启发式(weighted-union heuristic)——总是将较短的链表追加到较长的链表——可以使用聚合分析证明 次操作的总时间为 ,其中每个操作的摊还代价为


知识结构总览

flowchart TD
    A["19.2 不相交集合的链表表示"] --> B["数据结构设计"]
    A --> C["基本操作实现"]
    A --> D["简单UNION的问题"]
    A --> E["加权合并启发式"]
    A --> F["复杂度分析"]

    B --> B1["集合对象: head + tail 指针"]
    B --> B2["元素对象: set + next 指针"]
    B --> B3["head 即为代表"]

    C --> C1["MAKE-SET: O(1)"]
    C --> C2["FIND-SET: O(1)"]
    C --> C3["UNION(简单): O(n)"]

    D --> D1["需遍历更新set指针"]
    D --> D2["最坏情况: O(n²)"]

    E --> E1["短链表追加到长链表"]
    E --> E2["每次合并后集合大小至少翻倍"]

    F --> F1["聚合分析"]
    F --> F2["每个对象set指针最多更新 O(lg n) 次"]
    F --> F3["定理19.1: O(m + n lg n)"]

核心思想

核心思路

链表表示的基本想法很直观:每个集合就是一个链表,链表头就是集合的代表。FIND-SET 只需顺着 set 指针找到集合对象,再返回 head,所以是 。问题出在 UNION 上——合并两个链表时,需要更新其中一个链表中所有元素的 set 指针,使其指向新的集合对象。如果总是更新较长链表的指针,代价会很高。加权合并启发式的关键洞察是:每次合并时,让被合并链表中的元素”跳槽”到更大的集合,由于集合大小至少翻倍,每个元素最多”跳槽” 。这就像一个人每次跳槽都去更大的公司,由于公司规模至少翻倍,他最多跳 次就会到达最大的公司。

数据结构设计

链表表示的数据结构

每个集合由以下部分组成:

集合对象(set object):

  • head:指向链表中第一个元素的指针(该元素即为集合的代表
  • tail:指向链表中最后一个元素的指针

元素对象(element object):

  • set:指向该元素所在集合的集合对象的指针
  • next:指向链表中下一个元素的指针(最后一个元素的 next 为 NIL)

关键性质:链表中的第一个元素(head 所指向的元素)就是集合的代表。同一集合中的所有元素的 set 指针都指向同一个集合对象。

基本操作的实现

MAKE-SET 伪代码

MAKE-SET(x)
1  x.next = NIL
2  S = new set object
3  S.head = x
4  S.tail = x
5  x.set = S

FIND-SET 伪代码

FIND-SET(x)
1  return x.set.head

直觉理解

FIND-SET 的工作方式非常简单:从元素 出发,通过 set 指针找到它所属的集合对象,再通过集合对象的 head 指针找到代表。整个过程只需两次指针解引用,因此是 的。

简单 UNION 实现

UNION 伪代码(简单实现)

UNION(x, y)
1  Sx = x.set
2  Sy = y.set
3  if Sx ≠ Sy
4      // 将 Sy 的链表追加到 Sx 的链表末尾
5      Sx.tail.next = Sy.head
6      Sx.tail = Sy.tail
7      // 遍历 Sy 的链表,更新每个元素的 set 指针
8      z = Sy.head
9      while z ≠ NIL
10         z.set = Sx
11         z = z.next
12     free Sy

第8-11行需要遍历 的链表,将 中每个元素的 set 指针更新为 。如果 个元素,则 UNION 的代价为

最坏情况分析

最坏情况

考虑以下操作序列:先执行 次 MAKE-SET 创建元素 ,然后交替地将大集合合并到小集合中,使得每次 UNION 都需要遍历较长的链表来更新 set 指针:

  • UNION(, ):将 的链表(1个元素)追加到 的链表,更新 的 set 指针,代价
  • UNION(, ):将 的链表(含 ,共2个元素)追加到 的链表,更新 的 set 指针,代价
  • UNION(, ):将 的链表(含 ,共3个元素)追加到 的链表,更新 的 set 指针,代价
  • UNION(, ):将 的链表(个元素)追加到 的链表,代价

总代价:

这就是简单 UNION 的最坏情况——每次都将不断增长的链表追加到新的单元素集合中,导致被更新的元素数量线性增长,总代价为二次方。

加权合并启发式

加权合并启发式(Weighted-Union Heuristic)

加权合并启发式的核心规则是:在 UNION 操作中,总是将较短的链表追加到较长的链表末尾。具体来说,在 UNION(, ) 中,设 的大小为 的大小为

  • :将 的链表追加到 的链表末尾,更新 中所有元素的 set 指针
  • :将 的链表追加到 的链表末尾,更新 中所有元素的 set 指针

关键性质:被合并的链表中的每个元素,其所在集合的大小在合并后至少翻倍

使用加权合并的 UNION 伪代码

UNION(x, y)
1  Sx = x.set
2  Sy = y.set
3  if Sx ≠ Sy
4      if Sx.size ≥ Sy.size
5          // 将 Sy 追加到 Sx 末尾
6          Sx.tail.next = Sy.head
7          Sx.tail = Sy.tail
8          z = Sy.head
9          while z ≠ NIL
10             z.set = Sx
11             z = z.next
12         Sx.size = Sx.size + Sy.size
13         free Sy
14     else
15         // 将 Sx 追加到 Sy 末尾
16         Sy.tail.next = Sx.head
17         Sy.tail = Sx.tail
18         z = Sx.head
19         while z ≠ NIL
20             z.set = Sy
21             z = z.next
22         Sy.size = Sy.size + Sx.size
23         free Sx

定理19.1及其证明

定理19.1

使用加权合并启发式的一系列 个 MAKE-SET、UNION 和 FIND-SET 操作(其中包含 个 MAKE-SET 操作)可以在 时间内完成。

加权合并的直觉

加权合并启发式的核心直觉是让”小集合”的元素承担合并的代价。由于每次合并后小集合的元素所在集合大小至少翻倍,而集合大小不会超过 ,所以每个元素最多”搬家” 次。关键在于:被更新的元素数量总是”较小”的那一方,因此每次更新的代价相对于当前总规模是递减的。


补充理解与拓展

加权合并的贪心策略视角

加权合并启发式本质上是一种贪心策略:在每次 UNION 时,选择代价更小的方向——更新较少元素的指针。这种”总是选择代价更小方向”的思想在算法设计中非常普遍:

  • 并查集的加权合并:更新较短链表的指针( 而非
  • Huffman 编码(15.3节):总是合并频率最低的两个节点
  • Union-Find 的按秩合并(19.3节):总是将较浅的树挂到较深的树下

加权合并之所以有效,是因为它保证了每次操作的代价相对于总规模是递减的——这是一个非常强的结构性质,使得聚合分析能够给出紧凑的上界。

链表表示的独特优势

虽然19.3节的有根树表示在 UNION 和 FIND-SET 的效率上优于链表表示,但链表表示有一个独特的优势:支持在 时间内遍历集合中的所有元素。这是因为链表将同一集合的所有元素串在一起,只需从 head 开始沿着 next 指针遍历即可。相比之下,有根树表示(19.3节)不支持高效遍历集合中的所有元素——要遍历一个集合的所有元素,需要额外的数据结构支持。

习题19.2-5的巧妙设计:用tail作为代表

习题19.2-5提出了一个只用一个指针的集合表示方案:不使用 head 和 tail 两个指针,而是用 tail 元素作为代表

具体来说,每个元素只有一个 set 指针,指向它所在集合的 tail 元素(而非集合对象)。不再需要单独的集合对象。

  • x.set:指向 所在链表的最后一个元素(tail)
  • x.next:指向链表中 的下一个元素

MAKE-SET()

MAKE-SET(x)
1  x.next = NIL
2  x.set = x      // 单元素集合,tail 就是自己

代价:

FIND-SET()

FIND-SET(x)
1  return x.set    // 直接返回 tail 元素

代价:

UNION(, )

UNION(x, y)
1  tx = x.set      // x 所在链表的 tail
2  ty = y.set      // y 所在链表的 tail
3  if tx ≠ ty
4      tx.next = y.set.head  // 将 x 链表的 tail 连接到 y 链表的 head
5      // 但我们没有 head 指针!需要从 y 的 tail 反向找到 head

修正方案:由于 set 指针指向 tail,我们无法直接访问 head。解决方法是让每个元素额外维护一个 prev 指针形成双向链表,从 tail 可以反向遍历到 head。或者采用另一种思路:set 指针指向 head 元素(而非 tail),UNION 时遍历较短链表找到其 tail,然后将该 tail.next 指向另一链表的 head,并更新较短链表中所有元素的 set 指针。

代价分析:

  • MAKE-SET:
  • FIND-SET:(返回 x.set)
  • UNION:(遍历较短链表找 tail + 更新 set 指针)

使用加权合并后,总运行时间仍为 。这个方案只用一个 set 指针(不使用额外的集合对象),但需要遍历来找到 tail。

习题19.2-6的拼接技巧:去掉tail指针

习题19.2-6展示了如何在不维护 tail 指针的情况下实现高效的链表拼接:

核心技巧:将较小链表的第一个元素插入较大链表的第一个元素之后(而非追加到末尾)。

具体步骤(设 较小, 较大):

  1. 记录 的 head 的 next 指针为 temp
  2. 的 head 的 next 指向 的 head
  3. 找到 的 tail,将其 next 指向 temp
  4. 更新 中所有元素的 set 指针为

这个技巧避免了维护 tail 指针的开销,但代价是需要遍历较小链表来找到其 tail(不过反正也需要遍历来更新 set 指针,所以没有额外的渐近代价)。


易混淆点与辨析

UNION 中更新的是"被合并链表"的 set 指针,而非"合并后链表"的所有元素

在加权合并中,UNION 只需要更新被追加的那个较短链表中所有元素的 set 指针,而较长链表中元素的 set 指针不需要更新。这是因为较长链表仍然是”主体”,其集合对象继续使用。被追加的较短链表的元素需要更新 set 指针以指向新的集合对象。这是加权合并能保证 总更新次数的关键。

加权合并中"大小至少翻倍"是对被合并元素而言的

定理19.1证明中的关键步骤是”每次 set 指针更新后,该元素所在集合大小至少翻倍”。这个”至少翻倍”是对被合并链表中的元素而言的,不是对整个合并后的集合而言的。具体来说,如果元素 在大小为 的集合中,且该集合被追加到大小为 的集合中,则合并后 所在的集合大小为 ,即至少翻倍。

链表表示的 UNION 代价是 ,不是

使用加权合并后,每次 UNION 的代价等于较短链表的长度 ,因为我们只更新较短链表中元素的 set 指针。如果不使用加权合并(简单实现),UNION 的代价取决于实现方式——如果我们总是更新第二个参数所在链表的 set 指针,则代价为 ,其中 所在集合的大小。

vs

定理19.1的结论是 不是 。这两个上界是不同的:

  • :当 时(如连通分量问题中 ),总时间为
  • :当 时,总时间为 ,与上面相同
  • 但当 时(如 ),,而

是更紧的上界,因为 FIND-SET 每次只需 ,不贡献 因子。 因子只来自 UNION 操作中的 set 指针更新,而总更新次数为 ,与 无关。


习题精选

题号题目描述难度
19.2-1使用加权合并的 MAKE-SET/FIND-SET/UNION 伪代码
19.2-216个元素的加权合并执行过程⭐⭐
19.2-3用聚合分析证明 // 摊还时间⭐⭐
19.2-4操作序列的渐近运行时间⭐⭐
19.2-5只用一个指针的集合表示方案⭐⭐⭐
19.2-6去掉 tail 指针的拼接方案⭐⭐⭐

视频学习指南

资源主题链接说明
MIT 6.006 Lecture 12Disjoint Sets: Union-Findhttps://www.youtube.com/watch?v=0jNmHPfA_yEErik Demaine 讲解并查集的链表表示与加权合并
WilliamFisetDisjoint Sets (Linked List)https://www.youtube.com/watch?v=wU6udHRIkcc链表实现的逐步演示
GeeksforGeeksDisjoint Set Unionhttps://www.geeksforgeeks.org/disjoint-set-data-structures/链表表示与有根树表示的对比

教材原文(中文翻译)

CLRS 第4版 19.2节原文(中文翻译)

不相交集合的链表表示

本节给出不相交集合的一种简单实现,其中每个集合用一个链表来表示。第一个元素作为集合的代表。每个集合对象包含属性 head(指向代表)和 tail(指向链表中最后一个元素)。链表中的每个对象包含一个集合属性,指向集合对象,以及一个 next 属性,指向链表中的下一个对象。如果该对象是链表中的最后一个元素,则其 next 属性为 NIL。

MAKE-SET 创建一个仅包含一个元素的新链表。该元素既是链表的头部也是尾部。FIND-SET 只需跟随该对象的集合指针,然后返回 head 指针。因此,MAKE-SET 和 FIND-SET 都只需要 时间。

UNION 操作将两个链表合并为一个。例如,UNION(, ) 将包含 的链表追加到包含 的链表末尾。UNION 的代价取决于我们如何实现它。一种简单的实现是:遍历被追加的链表,更新其中每个元素的集合指针,使其指向另一个集合的集合对象。如果被追加的链表有 个元素,则 UNION 的代价为

如果我们简单地总是将第二个集合的链表追加到第一个集合的链表末尾,那么在最坏情况下, 次 UNION 操作可能需要 时间。例如,如果我们依次将单元素集合合并到一个不断增长的集合中,第 次 UNION 的代价为 ,总代价为

加权合并启发式

我们可以使用一种简单的启发式来显著改善 UNION 的性能,称为加权合并规则:总是将较短的链表追加到较长的链表末尾。使用加权合并,如果一个链表有 个元素,另一个有 个元素,且 ,则 UNION 的代价为

定理19.1:使用加权合并的一系列 个 MAKE-SET、UNION 和 FIND-SET 操作(其中包含 个 MAKE-SET 操作)可以在 时间内完成。

证明:我们使用聚合分析来证明。每个对象初始时在一个大小为 1 的集合中。当一个对象的集合指针被更新时,该对象所在的集合大小至少翻倍。由于集合大小不会超过 ,每个对象的集合指针最多被更新 次。因此,所有 个对象的集合指针的总更新次数最多为 次。此外,UNION 操作还需要 时间来拼接链表和释放集合对象,这部分总代价为 。MAKE-SET 和 FIND-SET 各需要 时间。因此, 次操作的总时间为


参见Wiki

第19章-用于不相交集合的数据结构 不相交集合的链表表示