of {$slidecount} ½ {$title} ATZJG.NET {$author}

首页






不相交集类
等价关系


Haifeng Xu


(hfxu@yzu.edu.cn)

This slide is based on the book of Mark Allen Weiss
Data Structures and Algorithm Analysis in C++
张怀勇等译.

目录

等价关系

等价关系

关系(relation), 即一个笛卡尔积的子集. 比如集合 $X=A\times B$, 若 $R\subset X$, 则称 $R$ 是一个关系.

通常令 $A=B=S$, 如果 $(a,b)\in R\subset S\times S$, 则称 $a$ 与 $b$ 有关系, 记做 $aRb$.

如果一个关系 $R$ 满足下面三个条件, 则称为等价关系(equivalence relation).

  1. 自反性: 对于所有的 $a\in S$, 有 $aRa$.
  2. 对称性: $aRb$ 当且仅当 $bRa$.
  3. 传递性: 若 $aRb$, 且 $bRc$, 则有 $aRc$.

显然, $\leq$ 不是等价关系, 因为不满足对称性. “朋友关系”也不是等价关系, 因为不满足传递性. 电气连通性(electrical connectivity)是一个等价关系.

动态等价性问题

动态等价性问题

给定一个等价关系 $\sim$, 一个自然的问题是对任意的 $a$ 和 $b$, 确定是否 $a\sim b$.

如果将等价关系存储为一个二维布尔数组, 那么当然这个工作可以以常数时间完成.

问题在于, 关系的定义通常不明显而且相当隐秘.

例如, 在集合 $\{a_1,a_2,a_3,a_4,a_5\}$ 上定义一个等价关系, 此时存在 $25$ 对元素, 其中每一对或者有关系或者没有关系, 取值为 true 或 false (1 或 0 都可以). 如果使用 $5\times 5$ 的矩阵来存储这些信息, 则有点浪费空间.

事实上, 信息 $a_1\sim a_2$, $a_3\sim a_4$, $a_5\sim a_1$, $a_4\sim a_2$ 意味着每一对元素都是有关系的.

我们希望能够迅速推断出这些关系.

元素 $a\in S$ 的等价类(equivalent class) 是 $S$ 的子集, 它包含所有与 $a$ 有(等价)关系的元素.

等价类形成对 $S$ 的一个划分: $S$ 的每一个成员恰好出现在一个等价类中. 为确定是否 $a\sim b$, 我们只需验证 $a$ 和 $b$ 是否都在同一个等价类中. (这给我们提供了解决等价问题的一个方法.)

思考

可以使用 map 容器来处理吗? 键成员是等价类中的某个代表元, 值是与之等价的成员.

算法描述

输入数据最初是 $N$ 个集合构成的集簇或类(collection), 每个集合含有一个元素. 初始的描述是所有的关系均为 false(自反的除外). 每个集合都有一个不同的元素, 从而 $S_i\cap S_j=\emptyset$; 这使得这些集合不相交(disjoint).

此时有两种操作允许进行. findunion.

find

find 返回包含给定元素的集合(即等价类)的名字

union

union 是添加新的关系成员. 如果我们想添加 $a\sim b$, 那么首先是否 $a$ 和 $b$ 已经在关系中了.

这种操作将含有 $a$ 和 $b$ 的两个等价类合并成一个新的等价类.

从集合的观点看, $\cup$ 的结果是建立一个新的集合 $S_k=S_i\cup S_j$, 去掉原来两个集合而保持所有的集合的不相交性.

由于这个原因, 我们常把这项工作的算法叫作不相交集合的求并/查找(union/find)算法.

Union 是 C++ 的一个保留字, 但很少使用. 在描述 union/find 的算法中, 我们使用 union, 但在正式编写代码时, 我们将成员函数命名为 unionSets.

对每个find 它给出的答案必须和所有执行到该find的union一致, 不过该算法在看到所有这些问题以后再给出它的所有的答案.

注意, 我们不进行任何比较元素相关的值的操作, 而是只需要知道它们的位置.

由于这个原因, 假设所有的元素均已从 $0$ 到 $N-1$ 顺序编号并且编号方法容易由某个散列方案确定. 于是, 开始时我们有 $S_i=\{i\}$, $i=0,\ldots, N-1$.

由 find 返回的集合的名字实际上是相当任意的. 真正重要的关键点在于: find(a)==find(b)true, 当且仅当 $a$ 和 $b$ 在同一个集合中.

解决动态等价问题的方案

已经证明二者不能同时以常数最坏情形运行时间执行.

第一种方案

find 能够以常数最坏情形运行时间执行

为使 find 操作快, 可以在一个数组中保存每个元素的等价类的名字. 此时, find 就是简单的 $O(1)$ 查找.

假设要执行 union(a,b), 并设 a 在等价类 i 中而 b 在等价类 j 中.

此时我们扫描该数组, 将所有的 i 都改变成 j. 不过, 这次扫描要花费 $\Theta(N)$ 时间.

于是, 连续 $N-1$ 次 union 操作(这是最大值, 因为此时每个元素都在一个集合中)就要花费 $\Theta(N^2)$ 的时间.

如果存在 $\Omega(N^2)$ 次 find 操作, 那么性能会很好, 因为在整个算法进行过程中每个 union 或 find 操作的总的运行时间为 $O(1)$. 如果 find 操作没有那么多, 那么这个界是不可接受的.

一种想法是将所有在同一等价类中的元素放在一个链表中. 这在更新的时候会节省时间, 因为我们不必搜索整个数组. 但是由于在算法过程中仍然可能执行 $\Theta(N^2)$ 次等价类的更新, 因此它本身并不能单独减少渐近运行时间.

如果我们还要跟踪每个等价类的大小, 并在执行 union 时将较小的等价类的名字改为较大的等价类的名字, 那么对于 $N-1$ 次合并的总的时间开销为 $O(N\log N)$.

其原因在于, 每个元素可能将它的等价类最多改变 $\log N$ 次, 因为每次等价类改变时它的新的等价类至少是其原来等价类的两倍大.

使用这种方法, 任意顺序的 $M$ 次 find 和直到 $N-1$ 次的 union 最多花费 $O(M+N\log N)$ 时间.

在本章的其余部分, 我们将考察 union/find 问题的一种解法, 其中 union 操作容易但 find 操作要难一些. 即便如此, 任意顺序的最多 $M$ 次 find 和直到 $N-1$ 次的 union 的运行时间只比 $O(M+N)$ 多一点.

基本数据结构

基本数据结构

回忆, 我们不要求 find 操作返回任何特定的名字, 而只要求当且仅当两个元素属于相同的集合时, 作用在这两个元素上的 find 返回相同的名字.

一种想法是可以使用来表示每一个集合, 因为上的每一个元素都有相同的根. 这样, 该根就可以用来命名所在的集合.

我们将用树表示每一个集合, 树的集合叫做森林.

这些树不一定必须是二叉树, 但是用二叉树表示起来要容易, 因为我们需要的唯一信息就是父链(parent link).

集合的名字由根处的结点给出. 由于只需要父结点的名字, 因此可以假设这棵树被非显式地存储在数组中: 数组的每个成员 s[i] 表示元素 i 的父亲. 如果 i 就是根, 那么 s[i]=-1.

在图8-1 的森林中, 对于 $0 <= i< 8$, $s[i]=-1$. 正如二叉堆中那样, 我们也将显式地画出这些树, 注意, 此时正在使用一个数组. 图8-1 表达了这种显式的表示方法, 为方便起见, 把根的父链垂直画出.

为了执行两个集合的 union 操作, 我们通过使一棵树的根的父链链接到另一棵树的根结点合并两棵树. 显然, 这种操作花费常数时间.

图8-2, 图8-3 和图8-4 分别表示在 union(4,5), union(6,7), union(4,6) 操作之后的森林. (这里 union(x,y) 指将 y 接到 x 上.)





/**
* 不相交集的类架构
*/
class DisjSets   // Disjoint Sets
{
  public:
    explicit DisjSets( int numElements );

    int find( int x ) const;
    int find( int x );
    void unionSets( int root1, int root2 );

  private:
    vector<int> s;
};
/**
* 不相交集的初始化例程
* Construct the disjoint sets object.
* numElements is the initial number of disjoint sets.
*/
DisjSets::DisjSets( int numElements ) : s( numElements )
{
	for ( int i = 0; i < s.size(); i++ )
		s[i] = -1;
}
/**
* 或者使用下面的语法初始化数组s, 使其拥有 numElements 个元素, 值均为 -1.
*/
DisjSets::DisjSets( int numElements ) : s{ numElements, -1 }
{
}

两个集合的 union 操作

/**
 * Union two disjoint sets.
 * For simplicity, we assume root1 and root2 are distinct
 * and represent set names.
 * root1 is the root of set 1.
 * root2 is the root of set 2.
 * (It is not the best method.)
 */
void DisjSets::unionSets( int root1, int root2 )
{
    s[ root2 ] = root1;
}

s[ root2 ] = root1; 表示将 root1 作为 root2 的 parent.

从代码看出, 我们通过使一棵树的根的父链链接到另一棵树的根结点, 从而合并成一棵树. 显然这种操作花费常数时间.

find 操作

/**
 * Perform a find.
 * Error checks omitted again for simplicity.
 * Return the set containing x.
 */
int DisjSets::find( int x ) const
{
    if( s[ x ] < 0 )
        return x;
    else
        return find( s[ x ] ); //递归寻找其父节点的根, 即集合的名字.
}

find 子程序的逻辑很简单, 如果向量 s 中存储的值 s[x] 小于0, 则表明 x 就是根结点. 于是就返回 x. 否则就递归调用自身, 去找该结点父结点所在的等价类.

执行 find 操作所花费的时间与结点 x 的深度成正比. 因此 find 最坏情形的运行时间是 O(N). 一般情况下, 运行时间是针对连续混合使用 M 个指令来计算的. 在这种情况下, $M$ 次连续操作在最坏情形下可能花费 $O(MN)$ 时间.

为保证程序的安全, 在运行 find 例程之前, 要执行错误检查. 比如检查元素是否在里面.

算法的平均情形分析

关于此算法的平均情形分析是相当困难的. 最基本的问题是答案依赖于如何定义平均(对union操作而言的平均). (详见课本 P.235)

灵巧求并算法

灵巧求并算法

这里的 union 操作是相当随意的, 它通过使第二棵树成为第一棵树的子树而完成合并.

对其进行简单改进是借助任意的方法打破现有的随意性, 使得总是较小的树成为较大的树的子树. 我们把这种方法叫做按大小求并(union by size). (这里“树的大小”指树所包含结点的个数, 而不是指向量 S 中指标的大小, 不要误解.)

Example

前面的例子中三次 union 的对象大小都是一样的, 因此可以认为它们都是按照大小执行 union 的. 加入下一次操作是 union(3,4), 那么结果将形成图8-10 中的森林.


倘若没有对大小(size)进行探测而直接 union, 那么结果将会形成更深的树(见图8-11).


Prop. 如果这些 union 都是按照 size 进行的, 那么任何结点的深度均不会超过 $\log N$.

Proof. 首先结点初始处于深度为 0 的位置上. 每执行一次 union, 如果影响到该结点, 则它将被置于至少是它以前所在树两倍大小的一棵树上. 因此, 它的深度最多可以增加 $\log N$ 次.

我们在快速查找算法(find)中用过这个论断. 这意味着 find 操作的运行时间是 $O(\log N)$, 连续执行 $M$ 次操作将花费 $O(M\log N)$ 的时间.

图8-12 中的树指出在 16 次 union 后有可能得到这种“最坏”情形的树, 而且如果所有的 union 操作都对相等大小的树进行, 那么这样的树是可能得到的. (“最坏”情形的树是指在第六章中讨论的二项树. 这里的“最坏”指的是最深的情形, $h=\log_2{16}=4$.)


方法一

这种方法需要记住每一棵树的大小(也即树中所含结点的个数). 这里不需要使用额外的存储, 仍然使用原来的数组. 只需将每个 root 的结点包含所在树的 size 的负值. 非 root 的结点i, s[i] 仍记录其父结点的信息.

这样初始时, 这些结点都是 root 结点, s[i]=-1, 也正好表明它们只含有一个结点. 见如下图所示.



/**
 * 按树的size求并的程序
 * Union two disjoint sets.
 * For simplicity, we assume root1 and root2 are distinct
 * and represent set names.
 * root1 is the root of set 1.
 * root2 is the root of set 2.
 */
void DisjSets::unionSetsbySize( int root1, int root2 )
{
    if( s[ root2 ] < s[ root1 ] )  // root2 has more nodes, 即 root2 更深
        s[ root1 ] = root2;        // Make root2 new root
    else
    {
		//注意下面两句代码的次序.
        s[ root1 ] += s[ root2 ];  // Update the size
        s[ root2 ] = root1;        // Make root1 new root
    }
}

方法二(按高度求并 union-by-height)

这种算法同样保证所有的树的深度最多是 $O(\log N)$.

我们跟踪每棵树的高度(height)而不是大小(size), 并执行 union 使得浅的树称为深的树的子树.

这是一种平缓的算法, 因为只有当两棵深度相等的树求并时树的高度才增加(此时树的高度增加 1). 因此按高度求并算法是按树的大小(元素个数)求并算法的简单修改.

对于 root 结点 i, $s[i]=-(height+1)$. 于是初始化时, 每个结点都是根结点, 它们的高度都是 0, 因此 $s[i]=-1$.

对于非 root 结点 i, $s[i]$ 仍记录其父节点所在向量 $S$ 中的指标. 如下所示.



/**
 * 按高度(秩)求并的程序
 * Union two disjoint sets.
 * For simplicity, we assume root1 and root2 are distinct
 * and represent set names.
 * root1 is the root of set 1.
 * root2 is the root of set 2.
 */
void DisjSets::unionSetsbyHeight( int root1, int root2 )
{
    if( s[ root2 ] < s[ root1 ] )  // root2 is deeper
        s[ root1 ] = root2;        // Make root2 new root
    else
    {
		//仅当两棵树的高度相同时, 合并后的树的高度才增加 1.
        if( s[ root1 ] == s[ root2 ] )
            s[ root1 ]--;          // Update height if they have the same heights
        s[ root2 ] = root1;        // Make root1 new root
    }
}

路径压缩

路径压缩

为什么提出路径压缩

迄今所描述的 union/find 算法对于大多数的情形都是完全可接受的. 它是非常简单的, 而且对于连续 M 个指令(在所有模型下)平均是线性的.

不过, $O(M\log N)$ 的最坏情形还是相当容易且自然地发生的.

例如, 如果我们把所有的集合放到一个队列中并重复地让前两个集合出队而让它们的并入队, 那么最坏情形就会发生.

如果 findunion 多很多, 那么其运行时间就比快速查找算法(quick-find algorithm) 的用时要多. 而且, 应该清楚, 对于 union 算法恐怕没有更多改进的可能. 这是基于这样的观察: 执行 union 操作的任何算法都将产生相同的最坏情形的树, 因为它必然会任意打破树间的均衡. 因此, 无需对整个数据结构重新加工而使算法加速的唯一方法是对 find 操作做些更巧妙的改进.

这种巧妙的改进叫做 路径压缩(path compression).

路径压缩在一次 find 操作期间执行而与用来执行 union 的方法无关.

路径压缩算法的实现

路径压缩算法是指对之前的 find 进行改进的算法. 路径压缩在一次 find操作期间执行而与用来执行 union 的方法无关.

设操作是 find(x), 此时路径压缩的效果是, 从 x 到根的路径上的每一个结点都使它的父结点变成根.

按秩求并与路径压缩的最坏情形

按秩求并与路径压缩的最坏情形

一个应用(生成一个迷宫)

一个应用(生成一个迷宫)

End






Thanks very much!

This slide is based on Jeffrey D. Ullman's work, which can be download from his website.