This slide is based on the book of Mark Allen Weiss
张怀勇等译.
关系(relation), 即一个笛卡尔积的子集. 比如集合 $X=A\times B$, 若 $R\subset X$, 则称 $R$ 是一个关系.
通常令 $A=B=S$, 如果 $(a,b)\in R\subset S\times S$, 则称 $a$ 与 $b$ 有关系, 记做 $aRb$.
如果一个关系 $R$ 满足下面三个条件, 则称为
显然, $\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$ 的
等价类形成对 $S$ 的一个划分: $S$ 的每一个成员恰好出现在一个等价类中. 为确定是否 $a\sim b$, 我们只需验证 $a$ 和 $b$ 是否都在同一个等价类中. (这给我们提供了解决等价问题的一个方法.)
可以使用
输入数据最初是 $N$ 个集合构成的集簇或类(collection), 每个集合含有一个元素. 初始的描述是所有的关系均为 false(自反的除外). 每个集合都有一个不同的元素, 从而 $S_i\cap S_j=\emptyset$; 这使得这些集合
此时有两种操作允许进行.
find 返回包含给定元素的集合(即等价类)的名字
union 是添加新的关系成员. 如果我们想添加 $a\sim b$, 那么首先是否 $a$ 和 $b$ 已经在关系中了.
这种操作将含有 $a$ 和 $b$ 的两个等价类合并成一个新的等价类.
从集合的观点看, $\cup$ 的结果是建立一个新的集合
由于这个原因, 我们常把这项工作的算法叫作
Union 是 C++ 的一个保留字, 但很少使用. 在描述 union/find 的算法中, 我们使用 union, 但在正式编写代码时, 我们将成员函数命名为
对每个find 它给出的答案必须和所有执行到该find的union一致, 不过该算法在看到所有这些问题以后再给出它的所有的答案.
注意, 我们不进行任何比较元素相关的值的操作, 而是只需要知道它们的位置.
由于这个原因, 假设所有的元素均已从 $0$ 到 $N-1$ 顺序编号并且编号方法容易由某个散列方案确定. 于是, 开始时我们有 $S_i=\{i\}$, $i=0,\ldots, N-1$.
由 find 返回的集合的名字实际上是相当任意的. 真正重要的关键点在于:
已经证明二者不能同时以常数最坏情形运行时间执行.
为使 find 操作快, 可以在一个数组中保存每个元素的等价类的名字. 此时, find 就是简单的 $O(1)$ 查找.
假设要执行
此时我们扫描该数组, 将所有的
于是, 连续 $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).
集合的名字由根处的结点给出. 由于只需要父结点的名字, 因此可以假设这棵树被非显式地存储在数组中: 数组的每个成员
在图8-1 的森林中, 对于 $0 <= i< 8$, $s[i]=-1$. 正如二叉堆中那样, 我们也将显式地画出这些树, 注意, 此时正在使用一个数组. 图8-1 表达了这种显式的表示方法, 为方便起见, 把根的父链垂直画出.
为了执行两个集合的 union 操作, 我们通过使一棵树的根的父链链接到另一棵树的根结点合并两棵树. 显然, 这种操作花费常数时间.
图8-2, 图8-3 和图8-4 分别表示在
/** * 不相交集的类架构 */ 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 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; }
从代码看出, 我们通过使一棵树的根的父链链接到另一棵树的根结点, 从而合并成一棵树. 显然这种操作花费常数时间.
/** * 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 子程序的逻辑很简单, 如果向量
执行 find 操作所花费的时间与结点
为保证程序的安全, 在运行
关于此算法的平均情形分析是相当困难的. 最基本的问题是答案依赖于如何定义平均(对union操作而言的平均). (详见课本 P.235)
这里的 union 操作是相当随意的, 它通过使第二棵树成为第一棵树的子树而完成合并.
对其进行简单改进是借助任意的方法打破现有的随意性, 使得
前面的例子中三次 union 的对象大小都是一样的, 因此可以认为它们都是按照大小执行 union 的. 加入下一次操作是
倘若没有对大小(size)进行探测而直接 union, 那么结果将会形成更深的树(见图8-11).
Proof. 首先结点初始处于深度为
我们在快速查找算法(
图8-12 中的树指出在 16 次 union 后有可能得到这种“最坏”情形的树, 而且如果所有的 union 操作都对相等大小的树进行, 那么这样的树是可能得到的. (“最坏”情形的树是指在第六章中讨论的二项树. 这里的“最坏”指的是最深的情形, $h=\log_2{16}=4$.)
这种方法需要记住每一棵树的大小(也即树中所含结点的个数). 这里不需要使用额外的存储, 仍然使用原来的数组.
这样初始时, 这些结点都是 root 结点,
/** * 按树的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 } }
这种算法同样保证所有的树的深度最多是
我们跟踪每棵树的高度(height)而不是大小(size), 并执行 union 使得
这是一种平缓的算法, 因为只有当两棵深度相等的树求并时树的高度才增加(此时树的高度增加 1). 因此按高度求并算法是按树的大小(元素个数)求并算法的简单修改.
对于 root 结点
对于非 root 结点
/** * 按高度(秩)求并的程序 * 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 } }
迄今所描述的
不过,
例如, 如果我们把所有的集合放到一个队列中并重复地让前两个集合出队而让它们的并入队, 那么最坏情形就会发生.
如果
这种巧妙的改进叫做
路径压缩在一次 find 操作期间执行而与用来执行 union 的方法无关.
路径压缩算法是指对之前的
设操作是