#CS225

Disjoint Sets

等价关系 (Equivalence Relation)

这是理解不相交集的一个数学前提。

  • 定义: 一个在集合 S 上的二元关系 R 如果满足以下三个性质,则称为等价关系:
    1. 自反性 (Reflexive): 对于所有 sS, (s,s)R (每个元素都与自身相关)。
    2. 对称性 (Symmetric): 对于所有 s,tS, 如果 (s,t)R,则 (t,s)R (如果 s 与 t 相关,则 t 与 s 相关)。
    3. 传递性 (Transitive): 对于所有 s,t,uS, 如果 (s,t)R(t,u)R,则 (s,u)R (如果 s 与 t 相关,t 与 u 相关,则 s 与 u 相关)。
  • 等价类 (Equivalence Class): 等价关系会将集合 S 划分成若干个互不相交的子集,每个子集称为一个等价类。同一个等价类中的所有元素都互相等价。

    • 例子:如果关系 R 定义为“两个人 st 有相同的最喜欢的选项 (A, B, C, D, E, F 中的一个)”,那么所有喜欢 A 的人组成一个等价类,所有喜欢 B 的人组成另一个等价类,以此类推。
    • [s]R 表示元素 s 所在的等价类。例如,如果学生 3 和学生 4 都最喜欢 B,那么 [3]R=[4]R

不相交集 (Disjoint Sets)

不相交集数据结构,也常被称为 并查集 (Union-Find),用于维护一个包含 n 个元素的集合,并将这些元素划分成若干个不相交的子集。

  • 核心概念:
    • 集合中的每个元素都只属于一个特定的子集。
    • 每个子集都有一个 代表元 (Representative Member),用来唯一标识这个子集。
  • 主要操作 (Operations):
    1. makeSet(x): 创建一个新的集合,其中只包含元素 xx 成为这个新集合的代表元。
    2. find(x): 返回包含元素 x 的集合的代表元。通过比较 find(x)find(y) 的结果,可以判断 xy 是否在同一个集合中。
      • 例如,find(4) == find(8) 意味着元素 4 和元素 8 属于同一个集合。
    3. 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):
    • 维护一个集合 S=s0,s1,,sk
    • 每个子集有一个代表元。
    • 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)
  • 操作复杂度:
    • Find(k): 直接返回数组在索引 k 处的值。时间复杂度 O(1) (Slide 27)。
      • 例如 Find(4) 返回 0
    • Union(k1, k2): (假设 k1k2 是两个集合的代表元) 要合并代表元为 k1k2 的两个集合,例如将所有代表元为 k2 的元素的代表元改为 k1。这需要遍历整个数组。时间复杂度 O(N),其中 N 是元素的总数。
      • 例如 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) 操作 (简单版本):
    • 假设 root1root2 是两个不同集合的根。
    • 使一个根成为另一个根的子节点。例如,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 操作可能需要 O(N) 时间。
    • 理想的 UpTree (Ideal UpTree): 树应该尽可能地“扁平”或“茂密”,这样从任何节点到根的路径都很短。目标是 O(1) 或接近 O(1) 的查找时间。

优化 UpTrees (Optimizations for UpTrees)

朴素的 union 操作 (简单地将一个根指向另一个根) 可能导致树变得很高,从而使得 find 操作变慢。因此需要优化 union 操作。

1. 按高度/秩合并 (Union by Height / Union by Rank)

  • 思路: 在合并两个集合时,总是将较矮的树的根连接到较高的树的根上
  • 记录高度: 每个根节点需要存储其对应树的高度。通常,我们将高度存为负值在数组中 (例如,如果根 r 的树高度为 h,则 arr[r] = -h,但为了区分根,通常是 - (height + 1) 或者用一个单独的数组来存高度)。
    • 当合并两个高度相同的树时,新树的高度会增加 1。
    • 当合并两个高度不同的树时,新树的高度等于原来较高的那个树的高度。
  • 效果: 这种策略能保证树的高度最多为 O(logN)
  • 幻灯片中的例子:
    • 树 T1 (根7): 高度 h=2arr[7] 可能存 -3 (表示高度2)。
    • 树 T2 (根4): 高度 h=3arr[4] 可能存 -4 (表示高度3)。
    • union(7, 4): 因为 T1 (高度2) 比 T2 (高度3) 矮,所以将 T1 的根 7 指向 T2 的根 4。arr[7] = 4。合并后树的根仍然是 4,高度仍然是 3。arr[4] 保持 -4
    • 如果两个树高度相同,例如都是 h,合并后新根的高度变为 h+1。例如,arr[new_root] 变为 -( (h+1) + 1 )

2. 按大小合并 (Union by Size)

  • 思路: 在合并两个集合时,总是将元素较少的树的根连接到元素较多的树的根上
  • 记录大小: 每个根节点需要存储其对应树中元素的数量 (即集合的大小)。通常,我们将大小存为负值在数组中 (例如,如果根 r 的集合大小为 size,则 arr[r] = -size)。
  • 效果: 这种策略也能保证树的高度最多为 O(logN) (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,新大小是 8+4=12arr[7] 更新为 -12
  • C++ 实现 unionBySize:
    • 函数原型: void DisjointSets::unionBySize(int root1, int root2)
      • 前提: root1root2 必须是它们各自集合的根。这意味着在调用 unionBySize 之前,已经对原始元素执行了 find 操作。
    • 数组 arr_: arr_[i] 如果为负,表示 i 是根,且 -arr_[i] 是该集合的大小。
    • 步骤:
      1. int newSize = arr_[root1] + arr_[root2]; (因为大小是负数存储的,所以直接相加)。
      2. 比较 arr_[root1]arr_[root2]。值越小 (即负得越多),表示集合越大。
      3. 如果 arr_[root1] < arr_[root2] (即 root1 对应的集合更大):
        • arr_[root2] = root1; (将 root2 指向 root1)
        • arr_[root1] = newSize; (更新 root1 的大小)
      4. 否则 (即 root2 对应的集合更大或一样大):
        • arr_[root1] = root2; (将 root1 指向 root2)
        • arr_[root2] = newSize; (更新 root2 的大小)
    • 时间复杂度: 由于 root1root2 已经是根,这个函数本身的操作是 O(1) 。但是,调用它的 union 操作还需要两次 find,所以总的 union 操作时间取决于 find

3. 路径压缩 (Path Compression)

这是对 find 操作的优化,与 union by size/height 配合使用效果极佳。

  • 思路: 在执行 find(i) 操作,从 i 向上走到根的路径上,将路径上的所有节点直接连接到根节点。这样可以使树变得更扁平,从而加速后续的 find 操作。
  • 例子:
    • 假设执行 find(4)。路径是 427910 (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)

  • 符号: logn (读作 “log star n”)
  • 定义: logn 是需要对 n 反复取对数 (通常以 2 为底) 多少次,结果才会小于或等于 1。 logn={0if n11+log(logn)if n>1
  • 例子: log(265536)
    1. 265536 (原始数)
    2. log(265536)=65536 (第一次取 log)
    3. log(65536)=log(216)=16 (第二次取 log)
    4. log(16)=log(24)=4 (第三次取 log)
    5. log(4)=log(22)=2 (第四次取 log)
    6. log(2)=1 (第五次取 log, 结果 1) 所以,log(265536)=5
  • logn 是一个增长极其缓慢的函数。对于所有在宇宙中可观测到的原子数量(大约 1080),log(1080) 也只是一个非常小的数字 (大约是 5)。在实践中,可以认为它几乎是一个常数 (通常 5)。

2. 结合优化的并查集性能

  • 当同时使用 智能合并 (Smart Unions) (即 Union by Size 或 Union by Height) 和 路径压缩 (Path Compression) 时:
    • 任何 munionfind 操作序列的总时间复杂度是 O(mα(N)),其中 N 是元素数量,m 是操作次数。
    • α(N)反阿克曼函数 (Inverse Ackermann function),它的增长比 logN 还要慢,对于所有实际的 N 值,α(N) 不会超过 5。
    • 因此,单次操作的 均摊时间复杂度 (Amortized Time Complexity) 几乎是常数,即 O(α(N)) 或近似为 O(logN)
    • 幻灯片中写作 O(mlgN),这在实践中非常接近 O(m)

UpTree 总结

  • UpTree 用数组实现。
  • 代表一个集合 (或者说,一个 UpTree 的森林代表了一系列不相交集合/等价类)。
  • 通过路径压缩和按大小/秩合并,查找代表元的操作接近 O(1) (均摊)。
  • 一个缺点: 不能快速找到集合中的所有元素。如果需要这个功能,需要额外的数据结构或遍历。

其他数据结构回顾

  • Array (数组):
    • Sorted Array (有序数组): 查找 O(logN) (二分查找),插入/删除 O(N)
    • Unsorted Array (无序数组): 查找 O(N),端点插入/删除 O(1) (均摊,考虑resize)。
    • Stacks (栈 - LIFO): 基于数组实现,操作 O(1)
    • Queues (队列 - FIFO): 基于数组实现 (循环数组),操作 O(1)
    • Hashing (哈希表): 平均 O(1) 查找/插入/删除。
    • Heaps (堆): 通常用于 Priority Queues (优先队列)。插入/删除最高优先级 O(logN)
    • UpTrees / Disjoint Sets (不相交集)。
  • List (链表):
    • Doubly Linked List (双向链表): 插入/删除 (如果知道节点) O(1),查找 O(N)
    • Skip List (跳表): 平均 O(logN) 查找/插入/删除。
    • Trees (树):
      • BTree (B树): 用于磁盘存储,多路查找树。
      • Binary Tree (二叉树): 包括二叉搜索树 (BST)。
      • Huffman Encoding (霍夫曼编码): 用于数据压缩的树。
      • kd-Tree (k维树): 用于多维空间数据查找。
      • AVL Tree (AVL树): 自平衡二叉搜索树,操作 O(logN)
  • Graphs (图): 广义的连接关系结构。