算法导论-上课笔记8:并查集


0 前言

在某些实际应用中,会涉及将n个不同的元素划分成一个大集合,其中包括多个不相交的小集合,而这些应用经常需要进行两种特别的操作:

1、寻找包含给定元素的唯一集合;

2、合并两个集合。

开始时,每个元素自己构成一个单元素的集合,然后按照一定的顺序,或问题给定的条件和要求将属于同一组的有特定关系的元素所在的集合进行合并,最后统计集合的个数往往就是问题的解。在这个过程中会反复地用到上述的两个操作:寻找给定元素所属于的唯一集合与合并两个不同的集合。适合描述这类问题的抽象数据结构类型称为并查集,其中“并查”分别对应合并与查找操作。


1 并查集的操作

一个并查集数据结构(disjoint-set data structure)维护了一个不相交动态集的集合S={S1,S2,…,Sk}。使用一个代表(representative)来标识每个集合,这个所谓的代表是这个集合的某个成员。在一些应用中,通常不关心哪个成员被用来作为代表,仅仅关心的是在多次查询动态集合的代表中,如果这些查询没有修改动态集合,则每次查询应该得到相同的答案。其他一些应用可能会需要一个预先说明的规则来选择代表,比如选择这个集合中最小的成员,这时需要假设集合中的元素能被比较次序。

设x表示一个对象,代表一个集合中的任意一个元素,有以下操作:

1、MAKE-SET(x):建立一个新的集合,它的唯一成员是x。因为各个集合是不相交的,故x不会出现在别的某个集合中。

2、UNION(x,y):将包含x和y的两个动态集合Sx和Sy合并成一个新的集合,即这两个集合的并集。假定在操作之前这两个集合是不相交的。虽然UNION的很多实现中特别地选择Sx或Sy的代表作为新的代表,然而结果集的代表可以是Sx∪Sy的任何成员。由于要求各个集合不相交,故要“消除”原有的集合Sx和Sy,即把它们从S中删除。实际中经常把其中一个集合的元素并入另一个集合中,来代替删除操作。

3、FIND-SET(x):返回一个指针,这个指针指向包含x的集合的代表。

若使用两个参数来分析并查集数据结构的运行时间,其中:

1、一个参数是n,表示MAKE-SET操作的次数;

2、另一个是m,表示MAKE-SET、UNION和FIND-SET操作的总次数。

因为各个集合是不相交的,所以每个UNION操作会减少一个集合。因此,n-1次UNION操作后,只有一个集合留下来,即UNION操作的次数至多是n-1。由于MAKE-SET操作被包含在总操作次数m中,因此有m≥n。假设在所有执行的操作中,最先执行的总是n个MAKE-SET操作。

并查集数据结构的应用之一是确定无向图的连通分量,下面先来介绍这个概念。

在无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的。若图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图。无向图G中的极大连通子图称为G的连通分量。若一个图有n个顶点,并且边数小于n-1,则此图必是非连通图。如下:
在这里插入图片描述
有3个连通分量,对应的顶点集分别为:{1,2,5},{3,6}和{4}。若一个无向图只有一个连通分量,则该无向图连通。一个连通分量的边是只与该连通分量中顶点关联的边,即:边(u,v)是连通分量的一条边⇔u和v均为该连通分量中的顶点

下图显示了一个包含4个连通分量的图(记为图M):
在这里插入图片描述
下面的CONNECTED-COMPONENTS伪代码使用并查集操作来计算一个图的连通分量:

CONNECTED-COPONENTS(G)
    for each vertex v∈G.V //图G的顶点集用G.V表示
        MAKE-SET(v)
    for each edge(u,v)∈G.E //边集用G.E表示
        if FIND-SET(u)≠FIND-SET(v)
            UNION(u,v)

若CONNECTED-COMPONENTS预处理了图M,则SAME-COMPONENT就可以判断任意两个顶点是否在同一个连通分量中:

SAME-COMPONENT(u,v)
    if FIND-SET(u)==FIND-SET(v)
        return TRUE
    else return FALSE

CONNECTED-COMPONENTS开始时将每个顶点v放在单独一个集合中,然后对于每条边(u,v),它将包含u和v的集合进行合并。处理完所有的边之后:两个顶点在相同的连通分量⇔与之对应的对象在相同的集合中。因此,CONNECTED-COMPONENTS以这种方式计算出的集合,使得过程SAME-COMPONENT能确定两个顶点是否在相同的连通分量中。下图展示了CONNECTED-COMPONENTS如何计算并查集:
在这里插入图片描述


2 并查集的链表表示

在这里插入图片描述
上图是两个集合的链表表示,其中:

1、集合S1包含成员d、f和g,代表为f;

2、集合S2包含成员b、c、e和h,代表为c。

上面那张图给出了一个实现并查集数据结构的简单方法:每个集合用一个链表来表示。每个集合的对象包含head属性和tail属性,head属性指向表的第一个对象,tail属性指向表的最后一个对象。链表中的每个对象都包含一个集合成员、一个指向链表中下一个对象的指针和一个指回到集合对象的指针。在每个链表中,对象可以以任意的次序出现。所谓的代表是链表中第一个对象的集合成员

用这种链表表示,MAKE-SET操作和FIND-SET操作是非常方便的,只需O(1)的时间:

1、要执行MAKE-SET(x)操作,需要创建一个只有x对象的新的链表。

2、对于FIND-SET(x),仅沿着x对象的返回指针返回到集合对象,然后返回head指向对象的成员。在上面那张图中,FIND-SET(g)的调用将返回f。

2.1 合并的一个简单实现

在使用链表集合表示的实现中,UNION操作明显比MAKE-SET或FIND-SET花费的时间多。如下图所示:
在这里插入图片描述
上图是UNION(g,e)的结果,操作使得包含e的链表S2加到包含g的链表S1中,其中S1与S2是上上一张图中的链表。UNION的结果集合的代表为f,而包含e的链表S2的集合对象被删除。

通过把y所在的链表拼接到x所在的链表实现了UNION(x,y)。x所在的链表的代表成为结果集的代表。利用x所在链表的tail指针,可以迅速地找到拼接y所在的链表的位置。因为y所在的链表的所有成员加入了x所在的链表中,此时可以删除y所在的链表的集合对象。遗憾的是,对于y所在链表的每个对象,必须更新指向集合对象的指针,将花费的时间与y所在链表长度呈线性关系。例如在上面一张图中,UNION(g,e)促使b、c、e和h对象的指针被更新。

可轻松构建一个在n个对象上需要Θ(n2)时间的由m个操作组成的序列。假设有对象x1,x2,…,xn。如下图所示:
在这里插入图片描述
执行n个MAKE-SET操作,后面跟着执行n-1个UNION操作,因而有m=2n-1。执行n个MAKE-SET操作需要Θ(n)的时间。由于第i个UNION操作更新i个对象的指针,因此所有的n-1个UNION操作更新的对象的总数为:
在这里插入图片描述
总的操作数为m=2n-1,这样每个操作的平摊时间为Θ(n)。

2.2 一种加权合并的启发式策略

在最坏情况下,上面给出的UNION过程的每次调用平均需要Θ(n)的时间,这是因为需要把一个较长的表拼接到一个较短的表上,此时必须对较长表的每个成员更新其指向集合对象的指针。

假设每个表中还包含了表的长度,并且拼接次序可以任意的话,就可以总是把较短的表拼接到较长的表中。当使用这种简单的加权合并启发式策略(weighted-union heuristic)时,如果两个集合都有Ω(n)个成员,则单个的UNION操作仍然需要Ω(n)的时间。

定理1:使用并查集的链表表示和加权合并启发式策略,一个具有m个MAKE-SET、UNION和FIND-SET操作的序列(其中有n个是MAKE-SET操作)需要的时间为O(m+n·lgn)。

证明:由于每个UNION操作会合并两个不相交集,因此总共至多执行n-1个UNION操作。现在来确定由这些UNION操作所花费时间的上界。先确定每个对象指向它的集合对象的指针被更新次数的上界。考虑某个对象x,若知道每次x的指针被更新,则x早先一定在一个规模较小的集合当中。因此第一次x的指针被更新时,结果集一定至少有2个成员。类似地,下次x的指针被更新时结果集一定至少有4个成员。一直继续下去,注意到对于任意的k≤n,在x的指针被更新⌈lgk⌉次后,结果集一定至少有k个成员。因为最大集合至多包含n个成员,故每个对象的指针在所有的UNION操作中最多被更新⌈lgn⌉次。因此在所有的UNION操作中被更新的对象的指针总数为O(n·lgn)。当然,也必须考虑tail指针和表长度的更新,而它们在每个UNION操作中只花费Θ(1)时间。所以总共花在UNION操作的时间为O(n·lgn)。而每个MAKE-SET和FIND-SET操作需要O(1)时间,它们的总数为O(m)。所以m个操作的序列的总运行时间是O(m+n·lgn)。得证。


3 并查集森林

在一个并查集更快的实现中,使用树来表示集合,树中每个结点包含一个成员,每棵树代表一个集合。在一个并查集森林(disjoint-set forest)中,如下图所示:
在这里插入图片描述
上图的两棵树表示下图:
在这里插入图片描述
中的两个集合。其中上上一张图中左边的树表示集合{b,c,e,h},其中c作为集合的代表;右边的树表示集合{d,f,g},f作为集合的代表。注意,每个成员仅指向它的父结点。每棵树的根包含集合的代表,并且根结点本身就是自己的父结点(树中表现为有一条指向自己的有向边)。

虽然使用这种表示的直接算法并不比使用链表表示的算法快,但通过引入两种启发式策略(“按秩合并”和“路径压缩”,后面会详细讨论),能得到一个渐近最优的并查集数据结构。

执行以下三种并查集操作:

1、MAKE-SET操作简单地创建一棵只有一个结点的树;

2、FIND-SET操作通过沿着指向父结点的指针找到树的根。这一通向根结点的简单路径上所访问的结点构成了查找路径(find path)。

FIND-SET(x) //x是某个结点,函数功能是输出包含结点x的树的根结点
    y=x
    while p.y!=null
        y=p.y
    end while
    return y

3、对于UNION操作:

UNION(x,y)
    u=FIND-SET(x)
    v=FIND-SET(y)
    p(u)=v

如下如图所示:
在这里插入图片描述
使得一棵树的根指向另外一棵树的根。上图是UNION(e,g)的结果。

3.1 改进运行时间的启发式策略

一个包含n-1个UNION操作的序列可以构造出一棵恰好含有n个结点的线性链的树,通过使用下面两种启发式策略,能获得一个几乎与总的操作数m呈线性关系的运行时间:

1、按秩合并(union by rank),使具有较少结点的树的根指向具有较多结点的树的根。这里并不显式地记录以每个结点为根的子树的大小,而是采用一种易于分析的方法。对于每个结点,维护一个秩,它表示该结点高度的一个上界。在使用按秩合并策略的UNION操作中,可以让具有较小秩的根指向具有较大秩的根。

2、路径压缩(path compression),如下图所示,在FIND-SET操作中,使用这种策略可以使查找路径中的每个结点直接指向根。路径压缩并不改变任何结点的秩。
在这里插入图片描述
上图是操作FIND-SET过程中的路径压缩。箭头和根结点的环被略去了,其中:

1、a是在执行FIND-SET(a)之前代表一个集合的树。三角形代表一棵子树,其根为图中示出的结点。每个结点有一个指向父结点的指针。

2、b是在执行FIND-SET(a)之后的同一个集合。现在在查找路径上每个结点都直接指向了根。

假设x是任意一个结点,x.p是结点x的父结点,则有:

1、rank(x.p)≥rank(x),若x≠x.p时,该式是严格大于的;

2、初始时,rank(x)=0,由于随后的多个UNION操作会使rank(x)不断增加,直到x≠x.p。一旦结点x成为其他结点的孩子结点,则rank(x)将不再改变。

3.2 实现并查集森林的伪代码

为了使用按秩合并的启发式策略实现一个并查集森林,必须记录下秩的变化情况。对于每个结点x,维护一个整数值x.rank,它代表x的高度(从x到某一后代叶结点的最长简单路径上边的数目)的一个上界。其中:

1、当MAKE-SET创建一个单元素集合时,这个树上的单结点有一个为0的初始秩。

2、每一个FIND-SET操作不改变任何秩。

3、UNION操作分为两种情况,取决于两棵树的根是否有相同的秩:

  • 如果根的秩互不相同,就让较大秩的根成为较小秩的根的父结点,但两个秩本身保持不变。
  • 两个根有相同的秩时,任意选择两个根中的一个作为父结点,并使它的秩加1。

下面是几个伪代码,用x.p代表结点x的父结点。LINK过程是由UNION调用的一个子过程,以指向两个根的指针作为输入。

MAKE-SET(x)
    x.p=x
    x.rank=0
UNION(x,y)
    LINK(FIND-SET(x),FIND-SET(y))
LINK(x,y)
    if x.rank>y.rank
        y.p=x
    else x.p=y
        if x.rank==y.rank
            y.rank+=1

下面是带有路径压缩的FIND-SET:

FIND-SET(x) //功能是输出包含结点x的树的根结点
    y=x
    while p.y!=null
        y=p.y
    end while
    root=y
    y=x
    while p.y!=null //路径压缩
        w=p.y
        p.y=root
        y=w
    end while
    return root

引理1:一棵通过按秩合并操作得到的树(设根结点为x)的结点数量至少为2rank(x)

证明

1、初始时,x本身是一棵树,这棵树的根结点就是x,因此rank(x)=0,结点数正好就是1=2rank(x)=20。引理1成立。

2、假设x与y分别为两棵树的根结点,暂设执行UNION(x,y)操作之前引理1成立。有:

  • 若rank(x)<rank(y),则执行UNION(x,y)操作之后得到的树的根结点为y,新的树比原先以根结点为y的树的结点更多,但是两个时期的rank(y)未发生变化;同理,若rank(x)>rank(y),则执行UNION(x,y)操作之后得到的树的根结点为x,新的树比原先以根结点为x的树的结点更多,但是两个时期的rank(x)未发生变化。也就是说:若rank(x)≠rank(y),则引理1成立。

  • 若rank(x)=rank(y),归纳可得:生成的以y为根的树至少有2rank(x)+2rank(y)=2rank(y)+1个结点。因为执行UNION(x,y)操作之后rank(y)=rank(y)+1,因此引理1成立。

综上所述,引理1成立。

由引理1可得:

1、若代表并查集的森林中的某棵树的结点数为n,则某个FIND-SET操作的运行时间为O(logn)。设以x为根结点的树的结点数为k,则树的高度最大是⌊logk⌋。

2、当x与y分别为两棵树的根结点时,执行UNION(x,y)操作的运行时间是O(1);否则UNION(x,y)的运行时间退化为FIND-SET操作的运行时间O(logn)。

结论:在只使用按秩合并启发式策略的UNION操作时,一个由m个UNION和FIND-SET操作构成的操作序列的时间复杂度为O(m·logn),其中n为树的结点数。

引理2:在一棵有n个结点的树中,秩为r的结点的数量最多是n/2r,其中r是非负整数。

证明:假定r是一个常数。当一个结点x被赋予一个秩r时,用x标记以x为根的树中包含的所有结点。根据引理1,标记结点的数量至少为2r。若树的根结点发生更改,则新树的秩至少是r+1,这代表标签是x的那些结点不会被再次标记。而被标记的结点数量最多是n,又因为以秩为r的根结点的树的结点数量最少是2r,因此秩为r的结点的数量最多是n/2r。得证。

推论1:在一棵有n个结点的树中,任意一个结点x的秩最多是⌊logn⌋。

证明:若对结点x有:rank(x)=r≥⌊logn⌋+1,根据引理2得秩为r的结点的数量最多有n/2⌊logn⌋+1<1个,矛盾。得证。

如果单独使用按秩合并或路径压缩,它们每一个都能改善并查集森林上操作的运行时间,而一起使用这两种启发式策略时,这种改善更大。


END

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值