MAKE-SET(x): p[x]=x;
FIND-SET(x): 要从x开始,不断向上寻找它的父亲,直到找到根为止。
UNION(x, y):只要使一棵树的根指向另一棵树的根即可。
(1)查找一个元素所属的集合
在分离集合森林中,每一棵分离集合树对应一个集合。要查找某一元素所属的集合,就是要找这个元素对应的结点所在的分离集合树。
不妨以分离集合树的根结点的编号来表示这个分离集合树。这样,查找一个结点所在的分离集合树,也就是查找该结点所在分离集合树的根结点了。
查找树的根结点的方法很简单,只需任取树中一结点(不妨就取我们要查找的那个结点),沿父结点方向一直往树根走:初始时,取一个结点,走到它的父结点(How?),然后以父结点为基点,走到父结点的父结点……直至走到一个父结点是它本身的结点为止,这个结点就是树的根结点。
(1)查找一个元素所属的集合
下图描述了查找一个结点的过程(黑色结点为当前查找结点)。
在分离集合森林中,分离集合是用分离集合树来表示的。要合并两个元素各自所属的集合,也就是合并两元素所对应的两个结点各自所在的分离集合树。
现在的问题就是如何合并两棵分离集合树。考虑到在分离集合森林中,只要结点属于同一棵树,即被视为在同一个集合中,而不管具体是如何相连的。那么,我们只需简单地将一棵分离集合树作为另一棵的子树,即可使两棵树合并为一棵。
前面提到,分离集合森林的查找与合并的时间复杂度都是O(h)。也就是说,查找与合并的时间复杂度主要取决于树的深度。就平均情况而言,树的深度应该在log2n的数量级,n为结点个数,所以分离集合森林查找与合并的平均时间复杂度为O(log2n)。但是,在最坏情况下,一棵树的深度可能达到n,如右图。这时的查找与合并的时间复杂度都达到了O(n)。这是我们不愿意看到的,因此必须想方设法避免出现这种情况。
为了提高效率,可以考虑在UNION(x,y)和FIND-SET(x)上做一些文章。
一棵较平衡的树拥有比较低的深度,查找和合并的复杂度也就相应较低。因此,如果两棵分离集合树A和B,深度分别为hA和hB,则若hA>hB,应将B树作为A树的子树;否则,将A树作为B树的子树。总之,总是深度较小的分离集合树作为子树。得到的新的分离集合树C的深度hC,以B树作A树的子树为例,hC=max{hA,hB+1}。
这样合并得到的分离集合树,其深度不会超过log2n,是一个比较平衡的树。因此,查找与合并的时间复杂度也就稳定在O(log2n)了。
在分离集合森林中,分离集合是用分离集合树来表示的。分离集合树是用来联系集合中的元素的,只要同一集合中的元素在同一棵树上,不管它们在树中是如何被联系的,都满足分离集合树的要求。如下图,这两棵分离集合树是等价的,因为它们所包含的元素相同。显然,右边那棵树比较“优秀”,因为它具有比较低的深度。相应地,查找与合并的时间复杂度也较低。那么,我们就应该使分离集合树尽量向右树的形式靠拢。
在查找一个结点所在树的根结点的过程中,要经过一条从待查结点到根结点的路径。我们不妨就让这些路径上的结点直接指向根结点,作为根结点的子结点。这样,这些路径上的结点仍在分离集合中,整棵树仍然满足分离集合树的要求,而路径上的结点的深度无疑降低了,这些点及其子树上的结点的查找复杂度大大降低。如下图,描述了在一棵分离集合树查找结点7的前后所呈现出的结构。
这种改变结点所指方向以降低结点深度,从而缩短查找路径长度的方法,叫做路径压缩。
实现路径压缩的最简单的方法是在查找从待查结点到根结点的路径时走两遍,第一遍找到树的根结点,第二遍改变路径上的结点,使之指向根结点(使它们的父结点为根结点)。
使用路径压缩技术后,大大提高了查找算法的效率。如果将带路径压缩的查找算法与优化过的合并算法联合使用,则可以证明,n次查找最多需要用O(n·α(n))时间。α(n)是单变量阿克曼函数的逆函数,它是一个增长速度比log2n慢得多、但又不是常数的函数。在一般情况下,α(n)≤4,可以当作常数看。
使用这两种方法优化后的代码:
MAKE-SET(x)
{ p[x]:=x;rank[x]:=0;}
UNION(x,y)
{ x:= FIND-SET(x);y:=FIND-SET(Y);
if rank[x] > rank[y] then p[y]:= x
else { p[x]:= y
if rank[x] = rank[y]
then rank[y] := rank[y]+1;
}
}
FIND-SET(x)
{ if x<>p[x] then p[x]:=FIND-SET(p[x]);
return p[x];
}