【数据结构与算法分析——C语言描述】第八章:不相交ADT

【数据结构与算法分析——C语言描述】第八章:不相交ADT

标签(空格分隔):【数据结构与算法】


第八章:不相交ADT

8.1 等价关系

  • 关系(relation) R:如果对于每一对元素 ( a , b ) , a S , b S , a R b 或者为 t r u e 或者为 f a l s e ,则称在集合 S 上定义 关系(relation) R.如果 ( a , b ) t r u e ,那么我们说 a b 有关系。
  • 等价关系(equivalence relation)是满足以下三条性质的等价关系 R:

    1. 自反性:对于所有的 a S ,有 a R a .
    2. 对称性: a R b 当且仅当 b R a .
    3. 传递性:若 a R b b R c a R c

      • 举例:
        1.关系 “ ” 不是等价关系,虽然它是自反的, a a , 可传递的, a b , b c a c ,但是它不是对称的,因为依据 a b 不能得到 b a
        2.电器连通性(electrical connectivity):是一个等价关系,其中所有的连接都是通过金属导线完成的。该关系显然是自反的,因为任何元件都是以自身相连的。如果 a 电气连接到 b 上,那么 b 一定也可以连接到 a 上。最后,如果 a 连接 b,b 连接 c,那么 a 连接到 c。因此,电器连接是一个等价关系。
        3.如果两个城市位于一个国家之内,那么定义它们是有关系的,容易验证这是一个等价关系。
        4.如果能够通过公路从城镇 a 到城镇 b,那么假设二者存在关系,假设道路可以双向行驶,那么这种关系也是一种等价关系。

8.2 动态等价关系

  • 给定一个等价关系 ” ~ ” ,一个自然的问题是,给定 a 和 b 是否有 a ~ b.如果将等价关系存储为一个二维的布尔数组,那么这个工作便可以在常数时间内完成。问题在于,这种等价关系的定义通常不明显,甚至是相当隐蔽的。

  • 举例:
    假设在 5 个元素的集合 { a 1 , a 2 , a 3 , a 4 , a 5 } 定义一个等价关系,此时,便存在 25 对元素,它们每一对或者有关系,或者没有关系。然而 ,信息 a 1 ~ a 2 , a 3 ~ a 4 , a 1 ~ a 5 , a 4 ~ a 2 意味着每一对元素都是存在关系的。我们希望能够快速推断出这些消息。

  • 等价类(equivalence class):一个元素 a S 的等价类是 S 的子集,它包含所有与 a 有关系的元素。
    等价类形成对 S 的一个划分: S 中的每一个成员恰好只出现在一个等价类中。为了确定是否 a ~ b . 我们只需要验证 a 和 b 是否都在一个等价类中。

  • 输入数据最初是 N 个集合的类(collection),每个集合含有一个元素。初始的描述是所有的关系均为 false(自反的关系除外).每一个集合都有不同的元素,从而 S i S j = . 这使得这些集合不相交(disjoint).

  • 允许有两种运算。

    扫描二维码关注公众号,回复: 2178573 查看本文章
    1. Find,它返回包含给定元素的集合(即等价类)的名字。
    2. 添加关系,如果我们想要添加关系 a ~ b, 那么我们首先要看是否 a 和 b 已经存在关系,这可以通过对 a 和 b 分别执行 Find 操作并检查它们是否在同一个等价类中来实现。如果它们不在一个等价类中,那么我们使用求并 运算 Union ,这种运算把含有 a 和 b 的两个等价类合并成一个新的等价类。从集合的观点来看, 的结果是建立在一个新集合 S k = S i + S j ,去掉原来的两个集合而保持所有集合的不相交性。由于这个原因,常常把这项工作的算法称为不相交集合的 Union/Find 算法。
      这个算法是动态的(dynamic),因为在执行过程中,集合可以通过 Union 运算而发生改变。这个算法还必须是联机(on-line)操作:当 Find 执行的时候,它必须给出答案算法才能继续进行。
      另一种可能是脱机(off-line)算法,该算法需要观察全部的 Union 和 Find 序列。它对每一个 Find 给出答案必须和所有执行到该 Find 和 Union 一致,因而该算法看到的所有问题以后再给出它所有的答案。
      以上两种这种差别类似于参加一次笔试,它是脱机的,只有在考完之后才直到答案,参加一次口试,它是联机的,因为必须回答完当前的问题,才能继续下一个问题。
  • 注意,我们不进行任何比较元素相关的值的操作,而是只需要直到它们的为止。由于这个原因,我们假设所有的元素均从 1 到 N 顺序编号并且编号方法容易由某个散列方案确定。于是,开始的时候我们有 S i = { i }       i = 1 , . . . , N .

  • 第二个需要注意的一点,由 Find 返回的集合的 名字实际上是十分随意的,真正重要的关键在于: Find(a) = Find(b) 当且仅当 a 和 b 在同一个集合中。
  • 解决动态问题的方案有两种:
    1. 保证指令 Find 能够以常数最坏情形运行时间执行。
    2. 保证 Union 能够以常数最坏情形运行时间执行。
      但有人指出这二者不能同时做到。

我们讨论第一种处理方法,为了使得 Find 操作更快,可以在一个数组中保存每个元素的等价类的名字。此时,Find 就是简单的 O ( 1 ) 查找。设想我们想要执行 Union(a,b),并设 a 在等价类 i 中而 b 在等价类 j 中。然后我们扫描该数组,将所有的 i 变成 j,不过,这次扫描需要花费 Θ ( N ) 时间。于是 N 1 次 Union 操作(这是最大值,因为此时每个元素都在一个集合中) 就要花费 Θ ( N 2 ) 时间。如果存在 Ω ( N 2 ) 次 Find 运算,那么性能会更好,因为在整个算法进行过程中每个 Find 或者 Union 运算的总运行时间为 O ( 1 ) .如果 Find 运算没有那么多,那么这个界是不可接受的。

一个想法是将所有在一个等价类中的元素放到一个链表中,这在更新的时候会节省时间,因为我们不必搜索整个数组。但是由于它在算法过程中可能执行 Θ ( N 2 ) 次等价类的更新,因此它本身并不能单独减少渐进运行时间。
如果我们还需要跟踪每个等价类的大小,并在执行 Union 时将较小的等价类的名字的改成较大的等价类的名字,那么对于 N 1 次合并合并的总的时间开销为 O ( N l o g N ) .其原因在于,每个元素可能将它的等价类最多改变 l o g N 次,因为每次它的等价类改变时它的新的等价类至少是它原来等价类的两倍大。使用这种方法,任意顺序的 M 次 Find 和直到 N - 1 次的 Union 操作最多花费 O ( M + N l o g N ) 时间.

8.3 基本数据结构

首先要注意的一点,我们的问题不要求 Find 操作返回任何特定的名字,而只是要求当且仅当两个元素属于相同集合时,最用在这两个元素上的 Find 返回相同的名字。

  • 一种想法可以使用树来表示一个集合,因为树上的每个元素都有相同的根。这样,该根就可以用来命名所在的集合。我们将用树表示每一个集合。开始时每个集合含有一个元素,这个树不一定必须是二叉树,但是表示它们的方法必须容易,因为哦我们需要的唯一信息就是一个父亲指针。集合的名字由根处的节点给出。由于只需要直到父节点的名字,因此我们可以假设树被非显式地存储在一个数组中:数组地每一个成员 P [ i ] 表示元素 i 的父亲。如果 i 是根,那么 P [ i ] = 0 .

  • 举个例子,如下图:为了方便起见,我们把根的父亲指针垂直画出。
    ![image.png-49.8kB][1]
    对于 1 i 8 ,有 P [ i ] = 0
    为了执行 Union 操作,我们使一个节点的根指针指向另一棵树的节点。显然,这个操作花费常数时间。
    下图是依次执行 Union( 5, 6), Union( 7, 8), Union( 5, 7) 后的森林,其中,我们采纳了在 Union( X, Y) 之后新的的根是 X.
    ![1.PNG-40.9kB][2]
    ![image.png-104.7kB][3]
    这个森林的非显式表示如下如图:
    ![image.png-28.5kB][4]

    • 对元素 X 的一次 Find(X) 操作通过返回包含 X 的树的根而完成。执行该操作花费的时间与 X 节点的深度成正比,当然这要假设我们以常数时间表示 X 的节点。使用上面的方法,能够建立一棵深度为 N 1 的树,使得一次 Find 的最坏情形运行时间是 O ( N ) .一般情形下,运行时间是对连续混合使用 M 个指令来计算的。在这种情况下, M 次连续操作在最坏的情形下可能花费 O ( M N ) 时间.
typedef int DisjSet[ NumSets + 1];
typedef int SetType;
typedef int ElementType;

void Initilialize( DisjSet S);
void SetUnion( DisjSet S, SetType Root1, SetType Root2);
SetType Find( ElementType X, DisjSet S);

void Initilialize( DisjSet S){
    int i;
    for( i = NumSet; i > 0; i--)
        S[i] = 0;
}

void SetUnion( DisjSet S, SetType Root1, SetType Root2){
    S[Root2] = Root1;
}

SetType Find( ElementType X, DisjSet S){
    if( S[X] <= 0)
        return X;
    else
        return Find( S[X], S);
}
  • 平均时间分析是相当困难的。最基本的问题是答案依赖于如何定义(对Union操作而言)平均,且看下面两种模型。
    1. 如果在上述例子中,由于有 5 棵树,因此下一个 Union 操作存在 5 * 4 = 20 个等可能的结果(因为任意两颗不同的树都有可能被Union).当然这个模型的含义在于,只存在 ( 4 / 20 ) * 2 的机会使得下一次 Union 涉及到大树。
    2. 另一种模型可能会认为在不同树上任意两个元素之间的所有 Union 都是等可能的,因此大树比小树更有可能在下一次 Union 中涉及到。在上述的例子中,有 8 / 11 的机会大树在下一次 Union 中可能被涉及到,忽略对称性的情况下,因为存在 6 中方法合并 { 1, 2, 3, 4} 中的两个元素以及 16 种方法将{ 5, 6,7,8} 中的一个元素与 { 1, 2, 3, 4} 中的一个元素合并。
    3. 当然还存在更多的模型,而在哪个模型更为优秀的问题上并没有一般性的见解。

平均时间依赖与模型,对于不同的模型,时间界为 Θ ( M ) , Θ ( M l o g N ) , Θ ( M N ) .

8.4 灵巧求并算法

  • 按大小求并(union-by-siz):总让较小的树称为较大的树的子树。这样做会使得任何节点的深度不超过 l o g N .为此,首先注意节点初始处于深度 0 的位置。当它的深度随着一次 Union 的结果而增加的时候,该节点则被置于至少是它以前所在树的两倍大的树上。因此,它的深度最多增加 l o g N .这意味着 Find 操作的时间复杂度是 O ( l o g N ) ,而连续操作 M 次的花费为 O ( M l o g N ) .
    还是上述例子,假如下依次运算是 Union( 4, 5),则会出现下图结果:
    ![image.png-61.9kB][5]
    倘若没有对大小进行探测而直接进行 Union 操作,那么会出现以下结果:
    ![image.png-64.1kB][6]

    为了实现这种方法,我们需要记住每一棵树的大小。由于我们实际上值使用了一个数组,因此可以让每个根的数组元素包含它的树的大小的负值。这样一来,初始时树的数组就表示成 -1 了。当执行一次 Union 操作的时候,要检查树的大小,新树是老树的大小的和,这样一来,按照大小求并的实现根本不存在困难,并且不需要额外的空间,速度平均也很快。对于真正所有合理的模型,也已经证明,如果使用按照大小求并且连续 M 次的运算需要 O ( M ) 平均时间。这是因为当随机的诸 Union 执行时整个算法一般只有一些很小的集合(通常只有一个元素)与大集合合并。
    下图是按照大小求并的非显式表示:![image.png-16.4kB][7]

  • 按高度求并(union-by-height):它同样保证了所有树的深度最多为 O ( l o g N ) ,我们跟踪每一棵树的高度而不是大小并执行 Union 操作使得浅的树称为深的树的子树。这是一种平缓的算法,因为只有当两颗相等深度的树求并时树的高度才增加(此时树的高度增加 1).这样,按照高度求并是按大小求并的简单修改。
    下图是按照高度求并的非显式表示:![image.png-18.4kB][8]

void SetUnion1( DisjSet S, SetType Root1, SetType Root2){
    if( S[Root2] < S[Root1])
        S[Root1] = Root2;
    else{
        if( S[Root1] == S[Root2])
            S[Root1]--;
        S[Root2] = Root1;
    }
}

8.5 路径压缩

迄今为止所描述的 Union/Find 操作对于大多数情形是完全可以接受的,它非常简单,而且对于连续个 M 指令(在所有的模型下)平均是下线性的。不过, O ( M l o g N ) 的最快情况还是十分有可能发生。例如,如果我们把所有的集合放到一个队列中并重复地让前两个集合出队而让它们的并入队,那么最坏情况便可能发生。如果运算 Find 操作比 Union 操作多很多,那么其运行时间比快速查找算法的运行时间要糟糕。而且应该清楚,对于 Union 算法,我们没有更多的改进的可能。这是因为:执行 Union 操作的任何算法都将长身相同额最坏情形的树,因为它必然会随意打破树之间的均衡。因此,无须对整个数据结构重新架构而使算法加速的唯一方法是改进 Find 操作。

  • 路径压缩(path compression):路径压缩在一次 Find 操作起见执行而与执行 Union 操作的方法无关。设操作为 Find(X),此时路径压缩的效果是,从 X 到根的路径上的每一个节点都使得它的父节点变成根节点。如下图,为对最坏的树执行 Find(15) 后压缩路径的效果。
    压缩之前:
    ![image.png-51kB][9]
    压缩之后:
    ![image.png-54.4kB][10]

  • 路径压缩的实施:使用额外的两次指针移动,在上述例子中,节点 13 和节点 14 现在离根进了一个位置,而节点 15 和 16 离根进了两个位置。因此,对于这些节点在未来的快速访问将由于花费了额外的工作爱进行路径压缩而我们希望得到补偿.

SetType Find1( ElementType X, DisjSet S){
    i f( S[X] <= 0)
        return X;
    else
        return S[X] = Find( S[X], S);
}

正如同上述程序中指出的,路径压缩对于基本的 Find 操作改变不大。对于 Find 操作而言,唯一的变化使得 S [ X ] 等于由 Find 返回的值,这样,在集合的根被递归地找到以后, X 就指向它。对于通向根的路径上的每一个节点将递归地出现,从而实现了路径压缩。

  • 当任意执行一些 Union 操作的时候,路径压缩事业个好方法,因为存在很多的深层节点并通过路径压缩将我们它们移向根节点,已经证明,当在这种情况下进行路径压缩的时候,连续 M 次操作最多需要 O ( M l o g N ) 时间,不过,这种情形下的平均时间尚未确定。
  • 路径压缩与按大小求并完全兼容。
  • 路径压缩不完全与岸导度求并兼容,因为按照高度求并会改变树的高度。我们根本不清楚如何有效地去重新计算它们,因此最好的解决方法是对于每一棵树的高度是估计的高度——秩(rank),按秩求并(理论上和按照大小求并的效率是相同的).

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

  • 当使用两种探测法时,算法在最坏的情形下几乎是线性的,特别地,在最坏情形下需要的时间是 Θ ( M α ( M , N ) ) (假设 M N ) ,其中 α ( M , N ) 是 Ackermann 函数的逆,Ackermann 函数定义如下:

    A ( 1 , j ) = 2 j           j 1
    A ( i , 1 ) = A ( i 1 , 2 )             i 2
    A ( i , j ) = A ( i 1 ,   A ( i , j 1 ) )             i , j 2

    由此,我们定义
    α ( M , N ) = m i n { i 1   |   A ( i , M N ) > l o g N }

    我们当然想要计算这个函数的值,不过在实际用途中 α ( M , N ) 4 ,这才是最重要的。

  • 单变量反 Ackermann 函数有时候写成 l o g N ,它是 N 的直到 N 1 时取对数的次数。
    例如, l o g 65536 = 4 ,这是因为 l o g   l o g   l o g   l o g   l o g ( 65536 ) = 1 l o g ( 2 6 5536 ) = 5 ,要知道 2 6 5536 可是一个长达 20000 位的数字,在生活中我们几乎用不到这样的数字。 α ( M , N ) 实际上甚至比 l o g N 增长还慢,然而需要注意的是, α ( M , N ) 不是常数,因此它的运行时间不是线性的。

8.6.1 Union/Find 算法分析

  • M 次 Union 和 Find 的运行时间为 O ( N l o g N )
    证明略。

我的微信公众号

猜你喜欢

转载自blog.csdn.net/sd4567855/article/details/80957804