第六部分:有效表示集合
Scott Mitchell
4GuysFromRolla.com
2004 年 4 月
摘要:Scott Mitchell 讨论用于实现通用和不相交集合的数据结构。集合是唯一项的无序集合,它可以枚举并以多种方法与其他集合进行比较。(20 页打印页)
本页内容
简介 | |
集合的基本方面 | |
实现有效的集合数据结构 | |
维护一组不相交的集合 | |
参考资料 | |
相关书籍 |
简介
最基本的数学构造之一是集合,它是唯一对象的无序集合。集合中包含的对象称为集合的元素。正式情况下,集合用大写、斜体字母表示,其元素显示在大括号 ({...}) 中。该表示法的示例如下:
S = { 1, 3, 5, 7, 9 } T = { Scott, Jisun, Sam } U = { -4, 3.14159, Todd, x }
在数学中,集合通常由数字构成,例如上面的集合 S,它包含小于 10 的正整奇数。但是,请注意集合的元素可以是任何事物 — 数字、人、字符串、字母、变量等。例如,集合 T 包含人名,集合 U 包含数字、名称和变量的混合。
在本文中,我们从集合的基本介绍开始,包括通用表示法和可以在集合上执行的操作。接着将研究如何使用已经定义的域有效的实现集合数据结构。本文最后研究不相交集合以及使用的最佳数据结构。
集合的基本方面
记得集合只是元素的集合。元素的“属于.”运算符,记作 xS,表示 x 是集合 S 中的一个元素。例如,如果集合 S 包含小于 10 的正整奇数,那么 1 S。这种表示方法应读为“1 是 S 的一个元素。”除了 1 是 S 的一个元素,还有 3S、5S、7S 和 9S。“不是......的元素”运算符,记作 xS,意味着 x 不是集合 S 的元素。
集合中唯一元素的数量是集合的基数。集合 {1, 2, 3} 的基数为 3,与集合 {1, 1, 1, 1, 1, 1, 1, 2, 3} 的基数一样(因为它只有三个唯一元素)。集合中可以根本没有元素。这样的集合称为空集,记作 {} 或 ,其基数为 0。
第一次了解集合时,许多开发人员假定它们与集合 (collection) 相同,例如 ArrayList。但是,它们之间有一些细微的差异。ArrayList 是元素的有序 集合 — ArrayList 中的每个元素都有相关的顺序索引,表示顺序。而且,ArrayList 中可以有重复元素。
另一方面,集合是无序 的,并且包含唯一 项。因为集合是无序的,所以集合中的元素可以按任何顺序列出。也就是说,集合 {1, 2, 3} 和 {3, 1, 2} 被认为是相等的。同时,集合中的任意重复都被认为是多余的。集合 {1, 1, 1, 2, 3} 和集合 {1, 2, 3} 是相等的。如果两个集合有相同的元素,那么它们是相等的。(相等用 = 符号表示;如果 S 和 T 是相等的,那么把它们写成 S = T。)
注 在数学中,允许重复元素的有序 元素集合 (collection) 称为列表。两个列表 L1 和 L2,对于从 1 循环到列表中元素个数 i,当且仅当 L1 中的第 i 个元素和 L2 中的第 i 个元素相等时,认为 L1 和 L2 相等。
通常,集合中能出现的元素被限制于某个域。域是能出现在一个集合中的所有可能值的集合。例如,我们可能只对域是整数的集合感兴趣。通过把域限制为整数,我们可以使集合中不包含非整数元素,例如 8.125 或 Sam。(域表示为集合 U。)
集合的关系运算符
有一组通常与数字一起使用的关系运算符。其中更常用的一些(尤其是在编程语言中)包括 <, <=, =, !=, >, 和 >=。根据关系运算符定义的标准,关系运算符确定左边的操作数是否和右边的操作数相关。关系运算符返回“true”或“false”值,表示操作数之间是否有关系。例如,如果 x 小于 y,那么 x < y 返回 true,否则返回 false。(当然,“小于”的含义取决于 x 和 y 的数据类型。)
关系运算符,例如 <, <=, =, !=, >, 和 >= 通常和数字一起使用。正如我们看到的一样,集合使用 = 关系运算符来表示两个集合相等(同样可以使用 != 来表示两个集合不相等),但是没有为集合定义 <, <=, >, 和 >=。终究如何确定集合 {1, 2, 3} 是否小于集合 {Scott, 3.14159} 呢?
作为 < 和 <= 概念的替代,集合使用关系运算符子集 和真子集,分别记作和。(某些旧文本使用表示子集,表示真子集。)S 是 T 的子集 — 记作 ST — 如果 S 中的每个元素都在 T 中。也就是说,如果 S包含在T 中,那么 S 是 T 的子集。如果 S = {1, 2, 3},T = {0, 1, 2, 3, 4, 5},那么 ST,因为 S 中的每个元素 — 1、2 和 3 — 都是 T 中的元素。S 是 T 的真子集 — 记作 ST — 如果 ST 并且 ST。也就是说,如果 S = {1, 2, 3} 并且 T = {1, 2, 3},那么 ST,因为 S 中的每个元素都是 T 的元素,但是 ST,因为 S = T。(请注意,在用于数字的关系运算符 < 和 <= 以及用于集合的关系运算符和之间有相似性。)
使用新的子集运算符,我们可以更正式地定义集合相等。给定集合 S 和 T,当且仅当 ST 并且 TS 时 S = T。也就是说,当且仅当 S 中的每个元素都在 T 中,而且 T 中的每个元素都在 S 中时,S 和 T 是相等的。
注 由于与 <= 类似,所以应该存在一个与 >= 类似的集合关系运算符。这个关系运算符称为超集,记作 ;表示一个真超集。就像 <= 和 >= 一样,ST 当且仅当 TS。
集合运算
就像关系运算符一样,许多为数字定义的运算不能很好地适用于集合。用于数字的常用运算包括加法、乘法、减法、求幂等等。对于集合,有四个基本运算:
1. | 并 — 两个集合的合并,记作 ST,与数字的加法类似。并运算符返回一个集合,它包含 S 中的所有元素和 T 中的所有元素。例如,{1, 2, 3}{2, 4, 6} 等于 {1, 2, 3, 2, 4, 6}。(重复的 2 可以删除以提供更简洁的答案,得到 {1, 2, 3, 4, 6}。)形式地写法为,ST = {x :xS 或 xT}。也就是说,S 并 T 的结果是一个集合,如果元素 x 在 S 或 T 中,那么 x 包含在结果集合中。 |
2. | 交 — 两个集合的交集,记作 ST,是 S 和 T 共有元素的集合。例如,{1, 2, 3}{2, 4, 6} 等于 {2},因为它是 {1, 2, 3} 和 {2, 4, 6} 共同拥有的唯一元素。形式地写法为,ST = {x :xS 并且 xT}.也就是说,S 交 T 的结果是一个集合,如果元素 x 在 S 中并且在 T 中,那么 x 包含在结果集合中。 |
3. | 差 — 两个集合的差,记作 S - T,是所有在 S 中但不在 T 中的元素。例如,{1, 2, 3} - {2, 4, 6} 等于 {1, 3},因为 1 和 3 是 S 中的元素,但不是 T 中的元素。形式地写法为,S - T = {x :xS 并且 xT}。也就是说,集合 S 与集合 T 的差是一个集合,如果元素 x 在 S 中且不在 T 中,那么 x 包含在结果集合中。 |
4. | 补 — 前面我们讨论了通常如何把集合限制到可能值的已知域,例如整数。集合的补(记作 S')是 U - S。(记得 U 是域集。)如果我们的域是 1 到 10 的整数,并且 S = {1, 4, 9, 10},那么 S' = {2, 3, 5, 6, 7, 8}。(对集合求补与对数字求负类似。就像对数字求负两次将得到原数字一样 — 也就是说,--x = x — 对集合求补两次将得到原集合 — S'' = S。) |
在研究新运算时,牢固掌握运算的本质总是很重要。在学习任何运算(不论是为数字定义还是为集合定义)时,要问自己的一些问题是:
? | 该运算是否可交换?如果 x op y 等价于 y op x ,那么运算符 op 可交换。在数字领域里,加法是可交换运算符的一个示例,而减法不可交换。 |
? | 该运算是否可结合?也就是说,运算的顺序是否重要。如果运算符 op 是可结合的,那么 x op (y op z) 等价于 (x op y) op z。同样,在数字领域里,加法是可结合的,但减法不是。 |
对于集合来说,并和交运算都是可交换和可结合的。ST 等于 TS,并且 S (TV) 等于 (ST)V。但是,集合的差运算既不可交换,也不可结合。(要验证集合的差运算是不可交换的,考虑 {1, 2, 3} - {3, 4, 5} = {1, 2},但是 {3, 4, 5} - {1, 2, 3} = {4, 5}。)
有限集和无限集
到目前为止,我们看过的所有集合示例处理的都是有限集。有限集是具有有限个元素的集合。虽然初看起来好像违反直觉,但集合可以包含无限数量的元素。例如,正整数集合是一个无限集,因为集合中元素的数量没有边界。
在数学中,有几个无限集很常用,因此可以用特殊符号表示它们。这些符号包括:
? | N = {0, 1, 2, ...} |
? | Z = {..., -2, -1, 0, 1, 2, ...} |
? | Q = {a/b:aZ,bZ,并且 b0} |
? | R = 实数集合 |
N 是自然数 集合,或者是大于或等于 0 的正整数集合。Z 是整数 集合。Q 是有理数 集合,有理数是可以表示成两个整数的分数的数字。最后,R 是实数 集合,实数是指所有的有理数以及无理数(不能表示成两个整数的分数的数字,例如 pi 和 2 的平方根)。
当然,无限集不能全部写下来,因为您永远不能写完这些元素,但是反而可以更简单地用数学表示法表示,例如:
S = {x : xN and x > 100}
这里 S 是所有大于 100 的自然数的集合。
在本文中,我们将探讨表示有限集的数据结构。虽然无限集肯定在数学中有用武之地,但是在计算机程序中我们很少需要使用无限集。无限集的表示和运算也有特殊的困难,因为无限集的内容不能完全在数据结构中存储或枚举。
注 计算有限集的基数很简单 — 只是计算出元素的数量即可。但是如何计算无限集的基数呢?这个讨论超出了本文的范围,但要意识到无限集有不同类型的基数。有趣的是,正整数的“数量”和所有整数的数量一样,但是实数的数量比整数多。
编程语言中的集合
C++、C#、Visual Basic .NET 和 Java 没有为使用集合提供内建语言功能。如果您想使用集合,需要使用适当的方法、属性和逻辑创建您自己的集合类。(我们将在下一部分准确地完成它!)尽管过去曾经有编程语言把集合作为语言中的基本生成块提供。例如,Pascal 提供了集合结构,可用于使用明确定义的域创建集合。要使用集合,Pascal 提供了 in 操作符,以确定一个元素是否在给定集合中。运算符 +、* 和 – 用于并、交和集合的差。下面的 Pascal 代码描述了使用集合的语法:
/* declares a variable named possibleNumbers, a set whose universe is the set of integers between 1 and 100... */ var possibleNumbers = set of 1..100; ... /* Assigns the set {1, 45, 23, 87, 14} to possibleNumbers */ possibleNumbers := [1, 45, 23, 87, 14]; /* Sets possibleNumbers to the union of possibleNumbers and {3, 98} */ possibleNumbers := possibleNumbers + [3, 98]; /* Checks to see if 4 is an element of possible numbers... */ if 4 in possibleNumbers then write("4 is in the set!");
其它以前的语言允许更强大的集合语义和语法。一种称为 SETL 的语言(SET Language 的首字母缩写)创建于 70 年代,把集合作为头等大事提出。与 Pascal 不同,在使用 SETL 中的集合时,您不会被限制指定集合的域。
实现有效的集合数据结构
在这一部分,我们将探讨创建提供集合功能和特性的类。当创建这样的数据结构时,首要事情之一是我们需要决定如何存储集合的元素。这个决定可以极大地影响在集合数据结构上执行的运算的渐近效率。(请牢记们需要在集合数据结构上执行的运算包括:并、交、集合差、子集和属于。)
为了描述存储集合元素如何影响运行时间,想象一下我们创建的集合类使用基础 ArrayList 来保存集合的元素。如果我们想要合并两个集合 S1 和 S2(S1 有 m 个元素,S2 有 n 个元素),我们必须执行如下步骤:
1. | 创建一个新的集合类 T,它保存 S1 和 S2 的并 |
2. | 循环访问 S1 中的元素,将其添加到 T 中。 |
3. | 循环访问 S2 中的元素。如果该元素还没有在 T 中,那么将其添加到 T 中。 |
执行并运算需要多少步骤?步骤 (2) 将需要 m 步访问 S1 中的 m 个元素。步骤 (3) 将需要 n 步,对于 S2 中的每个元素,我们必须确定该元素是否在 T 中。使用无序的 ArrayLists 确定元素是否在 ArrayList 中,必须线性枚举整个 ArrayList。所以,对于 S2 中的 n 个元素,我们必须搜索访问 T 中的 m 个元素。这将导致并运算的二次方的运行时间 O(m * n)。
使用 ArrayList 的并运算需要二次方的时间,其原因在于确定一个元素是否存在于一个集合中需要线性时间。也就是说,要确定一个元素是否存在于一个集合中,必须彻底搜索集合的 ArrayList。如果我们可以将元素的“属于”运算的运行时间减少到一个常数,就可以把并运算的运行时间减少为线性的 O(m + n)。记得在该文章系列的第 2 部分中,哈希表提供了常数运行时间以确定项是在哈希表中存在。因此,如果用于存储集合中的元素,哈希表将是比 ArrayList 更好的选择。
如果我们要求集合的域已知,那么可以使用位数组实现更有效的集合数据结构。假设域包含元素 e1、e2、...、ek。然后,我们可以用 k 个元素的位数组表示集合;如果第 i 位是 1,那么元素 ei 在集合中;另一方面,如果第 i 位是 0,那么元素 ei 不在集合中。将集合表示成位数组不仅极大地节省了空间,还有助于有效的集合运算,因为这些基于集合的运算可以使用简单的位指令完成。例如,确定元素 ei 是否存在于集合中需要花费常数时间,因为只需要检查位数组中的第 i 位。两个集合的并只是集合的位数组的位 OR;两个集合的交是集合的位数组的位 AND。集合的差和子集也可以简化为位运算。
注 位数组是压缩的数组,由 1 和 0 组成,通常实现为整数数组。由于 Microsoft .NET 框架中的整数有 32 位,因此一个位数组可以在整数数组的一个元素中存储 32 位值(而不是需要 32 个数组元素)。
位运算是在整数的单个位上执行的运算。既有二元位运算符也有一元位运算符。位 AND 和位 OR 运算符是二元的,每个运算符需要两个位,并返回一个位。仅当两个输入是 1 时,位 AND 返回 1,否则返回 0。仅当两个输入是 0 时,位 OR 返回 0,否则返回 1。
关于更多 C# 位运算的深入探讨,请务必阅读:Bit-Wise Operators in C#。
让我们探讨一下如何实现使用 C# 位运算的集合类。
创建 PascalSet 类
需要了解:使用有效的位运算符实现集合类必须已知集合的域。这与 Pascal 使用集合的方式类似,所以为了纪念 Pascal 编程语言,决定把该集合类命名为 PascalSet 类。PascalSet 将域限制为整数或字符的范围(就像 Pascal 编程语言一样)。这个范围可以在 PascalSet 的构造函数中指定。
public class PascalSet : ICloneable, ICollection { // Private member variables private int lowerBound, upperBound; private BitArray data; public PascalSet(int lowerBound, int upperBound) { // make sure lowerbound is less than or equal to upperbound if (lowerBound > upperBound) throw new ArgumentException("The set's lower bound cannot be greater than its upper bound."); this.lowerBound = lowerBound; this.upperBound = upperBound; // Create the BitArray data = new BitArray(upperBound - lowerBound + 1); } ... }
所以,要创建域是 -100 和 250 之间的整数集合的 PascalSet,可以使用如下语法:
PascalSet mySet = new PascalSet(-100, 250);
实现集合运算
PascalSet 实现标准集合运算 — 并、交和集合差 — 以及标准关系运算符 — 子集、真子集、超集和真超集。集合运算并、交和集合差都返回一个新的 PascalSet 实例,它包含并、交或集合差的结果。以下 Union(PascalSet) 方法的代码描述了此行为:
public virtual PascalSet Union(PascalSet s) { if (!AreSimilar(s)) throw new ArgumentException("Attempting to union two dissimilar sets. Union can only occur between two sets with the same universe."); // do a bit-wise OR to union together this.data and s.data PascalSet result = new PascalSet(this.lowerBound, this.upperBound); result.data = this.data.Or(s.data); return result; } public static PascalSet operator +(PascalSet s, PascalSet t) { return s.Union(t); }
AreSimilar(PascalSet) 方法确定传递的 PascalSet 是否有与 PascalSet 实例相同的下限和上限。因此,并(以及交和集合差)只适用于域相同的两个集合。(您可以对这里的代码进行修改,使返回 PascalSet 的域是两个域集合的并,从而允许对具有不相交域的集合进行并运算。)如果两个 PascalSet 有相同的域,那么新的 PascalSet — Result — 被创建为具有相同的域,并且它的 BitArray 成员变量 — data — 被赋值为两个 PascalSet 的 BitArray 的位 OR。请注意,PascalSet 类还为并运算重载了 + 运算符(就像 Pascal 编程语言一样)。
枚举 PascalSet 的成员
由于集合是元素的无序 集合,因此让 PascalSet 实现 IList 是没有意义的,因为实现 IList 的集合表示列表有某种顺序。由于 PascalSet 是元素的集合,因此让它实现 ICollection 是有意义的。由于 ICollection 实现 IEnumerable,所以 PascalSet 需要提供 GetEnumerator() 方法,它返回一个 IEnumerator 实例,允许开发人员遍历集合的元素。
在使用一些其他基础集合类保存数据创建专门的集合类时,用于专门类的 GetEnumerator() 方法常常只是返回来自基础集合的 GetEnumerator() 方法的 IEnumerator。由于 PascalSet 使用 BitArray 表示集合中有什么元素,初看起来似乎应该让 PascalSet 的 GetEnumerator() 方法返回来自内部 BitArray 的 GetEnumerator() 方法的 IEnumerator。但是,BitArray 的 GetEnumerator() 返回的 IEnumerator 枚举 BitArray 中的所有 位,它为每个位返回一个布尔值 — 如果该位是 1 则返回 true,如果该位是 0 则返回 false。
但是,PascalSet 中的元素只是 BitArray 的位是 1 的元素。因此,我们需要创建实现 IEnumerator 的自定义类,它智能地遍历 BitArray,只返回那些在 BitArray 中的对应位是 1 的元素。为处理这些,在 PascalSet 类内部创建了名为 PascalSetEnumerator 的类。该类的构造函数接受当前 PascalSet 实例作为唯一输入参数。在 MoveNext() 方法中,它依次访问 BitArray 的每一位直到找到值为 1 的位。
class PascalSetEnumerator : IEnumerator { private PascalSet pSet; private int position; public PascalSetEnumerator(PascalSet pSet) { this.pSet = pSet; position = -1; } ... public bool MoveNext() { // increment position position++; // see if there is another element greater than position for (int i = position; i < pSet.data.Length; i++) { if (pSet.data.Get(i)) { position = i; return true; } } // no element found return false; } }
PascalSet 类的完整代码包含在本文附带的下载中。和该类一起有一个交互式 WinForms 测试应用程序 SetTester,从它可以创建 PascalSet 实例并执行不同的集合运算,并查看结果集合。
维护一组不相交的集合
下次您在 Google 上搜索时请注意每个结果有一个标题为“类似网页”的链接。如果单击该链接,Google 显示一个 URL 列表,该列表与您单击“类似网页”链接的项相关。虽然不知道 Google 具体如何确定网页是如何相关的,但是如下是一种方法:
? | 使 x 成为我们想要查找的相关页面的 Web 页面。 |
? | 使 S1 成为 x 链接到的 Web 页面的集合。 |
? | 使 S2 成为 S1 中的 Web 页面链接到的 Web 页面的集合。 |
? | 使 S3 成为 S2 中的 Web 页面链接到的 Web 页面的集合。 |
? | … |
? | 使 Sk 成为 Sk-1 中的 Web 页面链接到的 Web 页面的集合。 |
S1、S2 直到 Sk 中的所有 Web 页面是 x 的相关的页面。我们不是按照需要计算相关 Web 页面,而可能选择为所有 Web 页面一次性创建相关页面的集合,并把这些关系存储在数据库或某些其它永久性存储器中。然后,当用户单击搜索项的“类似网页”链接时,我们只是要求显示与该页面相关的链接。
Google 有某种数据库,其中它知道的所有 Web 页面。这些 Web 页面中的每一个都有一个链接的集合。我们可以使用如下算法计算相关 Web 页面的集合:
1. | 对于数据库中的每个 Web 页面创建一个集合,将单独的 Web 页面放在集合中。(完成这一步后,如果我们在数据库中有 n 个 Web 页面,那么我们将有 n 个一个元素的集合。) |
2. | 对于数据库中的 Web 页面 x,查找所有那些它直接链接到的 Web 页面。将这些链接到的页面称为 S。对于 S 中的每个元素 p,将包含 p 的集合与 x 的集合进行并操作。 |
3. | 对于数据库中的所有 Web 页面重复步骤 2。 |
完成步骤 3 后,数据库中的 Web 页面将分隔成相关组。要查看该算法工作的图形化表示,请参考图 1。
图 1. 链接 web 页面分组算法的图形化表示。
研究图 1,请注意在最后有三个相关部分:
? | w0 |
? | w1、w2、w3 和 w4 |
? | w5 和 w6 |
所以,当用户单击 w2 的“类似网页”链接时,他们将看到 w1、w3 和 w4 的链接;单击 w6 的“类似网页”链接时应该只显示到 w6 的链接。
请注意,对于这一特定问题,只执行了一个集合运算 — 并。而且,所有 Web 页面都属于不相交集合。对于给定任意数量的集合,如果它们没有公用元素,那么这些集合被称为是不相交的。例如,{1,2,3} 和 {4,5,6} 是不相交的,而 {1,2,3} 和 {2,4,6} 则不是,因为它们共享公共元素 {2}。在图 1 中显示的所有步骤中,每个包含 Web 页面的集合都是不相交的。也就是说,一个 Web 页面永远不会同时存在于多个集合中。
以这种方式使用不相交集合时,我们常常需要知道给定元素属于哪个特定不相交集合。为了标识每个集合,我们任意选取一个作为代表。代表是一个来自不相交集合的元素,它唯一标识整个不相交集合。使用代表的概念,可以通过查看两个给定元素是否有相同的代表来确定它们是否在同一集合中。
一个不相交集合数据结构需要提供两个方法:
? | GetRepresentative(element) — 该方法接受一个元素作为输入参数,并返回该元素的代表元素。 |
? | Union(element, element) — 该方法接受两个元素。如果这两个元素来自相同的不相交集合,那么 Union() 什么都不做。但是,如果这两个元素来自不同的不相交集合,那么 Union() 将这两个不相交集合合并为一个集合。 |
现在我们面临的挑战是如何有效维护不相交集合的数量,而这些不相交集合常常从两个合并为一个?有两个基本数据结构可以用于处理该问题:一个使用一系列链接表,另一个使用树的集合。
使用链接表维护不相交集合
在这一文章系列的第 4 部分中,我们花了一点时间探讨链接表的基本知识。记得链接表是一系列节点,它们通常有一个到其下一邻居的单独引用。图 2 显示了有四个元素的链接表。
图 2. 有四个元素的链接表
对于不相交集合数据结构,使用修改的链接表代表集合。不是仅仅有到邻居的引用,不相交集合链接表中的每个节点有一个到集合代表的引用。如图 3 所示,链接表中的所有 节点指向与其代表相同的节点,按照惯例,该节点是链接表的头。(图 3 显示了不相交集合的链接表表示,该不相交集合来自从图 1 分隔出的算法的最后阶段。请注意,对于每个不相交集合存在一个链接表,并且链接表的节点包含特定不相交集合的元素。)
图 3. 不相交集合的链接表表示,该不相交集合来自从图 1 分隔出的算法的最后阶段。
由于集合中的每个元素有一个返回到集合代表的直接引用,所以 GetRepresentative(element) 方法花费常数时间。(要了解其原因,请考虑不管集合中有多少个元素,都将需要一个运算来查找给定元素的代表,因为它只需要检查元素的代表引用。)
使用链接表方法,将两个不相交集合合并为一个需要将一个链接表添加到另外一个末尾,并更新每个追加节点中的代表引用。合并两个不相交集合的过程如图 4 所示。
图 4. 合并两个不相交集合的过程
在合并两个不相交集合时,将两个集合中的哪一个追加到另外一个的末尾并不影响算法的正确性。但是,会影响运行时间。假设我们的合并算法随机选择两个链接表中的一个,把它追加到另外一个的末尾。按照最坏运气打算,假设我们总是选择两个链接表中较长的进行追加。这会对并运算的运行时间造成负面影响,因为我们必须枚举追加链接表中的所有节点,以更新它们的代表引用。也就是说,假设我们有 n 个不相交集合,S1 到 Sn。每个集合有一个元素。那么我们要做 n - 1 次并操作,将所有 n 个集合合并成一个有 n 个元素的大集合。假设第一次并操作合并了 S1 和 S2,并使 S1 成为这个包含两个元素的合并集合的代表。由于 S2 只有一个元素,所以只有一个代表引用需要更新。现在,假设 S1 — 它有两个元素 — 与 S3 进行并操作,并且 S3 作为代表。这一次有两个代表引用 — S1 的和 S2 的 — 需要更新。类似地,当合并 S3 和 S4 时,如果将 S4 作为新集合的代表,那么需要更新三个代表引用(S1、S2 和 S3)。在第 (n-1) 个并中,n-2 个代表引用需要更新。
累计每个步骤必须执行的运算的次数,我们发现整个步骤的顺序 — n 次集合运算和 n-1 次并操作 — 需要二次的时间 — O(n2)。
这个最坏情况的运行时间可能会发生,因为并运算可能选择较长集合追加到较短集合。追加较长集合需要更新更多节点的代表引用。一个更好的做法是跟踪每个集合的大小,然后,在合并两个集合时,追加两个链接表中较短的那个。使用这一改进方法时的运行时间减少到 O(n log2n)。彻底的时间分析有点超出本文的范围,为简明起见,在此省略。关于时间分析的形式证明,请参阅参考资料部分中的读物。
为理解从 O(n2) 到 O(n log2n) 的改进,观察图 5,该图用蓝色显示 n2 的增长率,用粉红色显示 n log2n 的增长率。对于较小的 n 值,这两个结果类似,但是当 n 超过 32 时,n log2n 比 n2 增长得慢得多。例如,使用原始链接表实现执行 64 次并运算将需要超过 4,000 次运算,而对于优化的链接表实现只需要 384 次运算。这些差异在 n 变得更大时会变得更加明显。
图 5. n2 和 n log2 的增长率
使用森林维护不相交集合
也可以使用森林 维护不相交集合。森林是树的集合(明白吗?)。 记得使用链接表实现,集合的代表是列表的头。使用森林实现,每个集合实现为树,并且集合的代表是树的根。(如果您不熟悉树的概念,考虑阅读这一文章系列中的第 3 部分,其中我们讨论了数、二叉树和二叉查找树。)
使用链接表方法,给定一个元素,查找其集合的代表很快,因为每个节点有一个到其代表的直接引用。但是,使用链接表方法,并运算会花费更长的时间,因为它需要追加一个链接表到另外一个链接表,这需要更新追加节点的代表引用。森林方法的目标是使并运算更快,代价是在集合中给定元素的情况下查找集合的代表。
森林方法将每个不相交集合实现为一棵树,并将根作为代表。要将两个集合进行并运算,可以将一棵树追加为另一棵树的孩子。图 6 用图形描述了这一概念。
图 6. 两个集合的并
合并两个集合需要常数时间,因为只有一个节点需要更新其代表引用。(在图 6 中,要合并 w1 和 w3 集合,我们只需将 w3 的引用更新为 w1 — 节点 w4 和 w5 不需要任何修改。)
与链接表实现相比,森林方法改善了用于将两个不相交集合进行并运算所需要的时间,但是用于查找集合代表的时间却变长了。给定一个元素,我们确定集合的代表的唯一方法就是遍历集合的树直到找到根。假设我们想要查找 w5 的代表(在合并了集合 w1 和 w3 之后)。我们要遍历树直到到达根 — 首先到 w3,然后到 w1。因此,查找集合的代表需要花费的时间与树的深度相关,而不是象链接表表示一样的常数时间。
森林方法提供了两个优化,当二者同时使用时,在执行 n 个不相交集合的运算时得到线性运行时间,这意味着每个单独的运算有平均的常数运行时间。这两个优化称为按阶并和路程压缩。使用这两个优化,我们要尽量避免使一系列并产生又高又瘦的树。就像本文章系列的第 3 部分讨论的一样,树的高度和宽度的比例通常影响它的运行时间。理想的情况是,树尽可能地呈扇形散开,而不是又高又窄。
按阶并优化
按阶并与链接表将短列表追加到长列表这一优化类似。具体地说,按阶并维护每个集合根的阶,它提供了树的高度的上限。在合并两个集合时,具有较小阶的集合被追加为具有较大阶的根的孩子。按阶并有助于确保树的宽度。但是,即使使用按阶并,我们仍然可能最终得到尽管很宽但很高的树。图 7 显示了一棵树图,它可能是通过一系列仅使用按阶并优化的并运算形成的。问题是右边的叶子节点仍然必须执行许多运算以查找其集合的代表。.
图 7. 一棵可能通过一系列仅使用按阶并优化的并运算形成的树
注 森林方法只有在实现按阶并优化时具有和优化链接表实现相同的运行时间。
路程压缩优化
由于高树使得查找集合的代表代价很昂贵,所以理想情况下我们希望我们的树又宽又平。路程压缩优化可以使树变平。像我们前面讨论的一样,每当查询元素的集合代表时,算法就遍历树到根。路程压缩优化工作的方法在该算法中;在向根进行访问的过程中,节点把它们的父引用更新为根。
要了解这种扁平化如何工作,考虑图 7 中的树。现在,假设我们需要查找 w13 的集合代表。算法将从 w13 开始,到 w12,然后到 w8,并最终到达 w1,返回 w1 作为代表。使用路程压缩,当将 w13 和 w12 的父亲更新为根 w1 时,该算法也有副作用。图 8 显示了这种路程压缩发生之后树的屏幕显示。
图 8. 路程压缩之后的树
路程压缩在第一次查找代表时付出少量的开销,但在将来的代表查找中受益。也就是说,这种路程压缩发生之后,查找 w13 的集合代表需要一个步骤,因为 w13 是根的孩子。在图 7 中,在路程压缩之前,查找 w13 的代表将需要三个步骤。这里的思想是为得到改善而付出一次,然后将来每次执行检查时从改进中受益。
当使用按阶并和路程压缩算法时,在不相交集合上执行 n 次运算需要的时间是线性的。也就是说,使用两个优化,森林方法的运行时间为 O(n)。您一定要相信我的话,因为时间复杂度的形式证明非常长而且复杂,很容易写满几页打印页。但是,如果您有兴趣阅读该多页的时间分析,请参阅参考资料中列出的“Introduction to Algorithms”中的文字。
参考资料
? | Alur, Rajeev."Disjoint Sets" |
? | Cormen, Thomas H.、Charles E. Leiserson 和 Ronald L. Rivest. "Introduction to Algorithms."MIT Press. 1990. |
? | Devroye, Luc."Disjoint Set Structures". |
相关书籍
? | Thomas H. Cormen 编写的 Introduction to Algorithms |
Scott Mitchell,著有五本书籍,是 4GuysFromRolla.com 网站的创始人,过去五年来一直从事 Microsoft Web 技术方面的研究。Scott 是一名独立顾问、教员以及作家,他最近在圣地亚哥加利福尼亚大学完成了计算机科学硕士学位。可发送电子邮件至 mitchell@4guysfromrolla.com 或访问他的网络日记 http://www.ScottOnWriting.NET 与他取得联系。