两个等价类集合如果满足:,则称
和
构成一个不相交集。对于不相交集中的任意两个元素(x,y)我们有两种操作:Find(x(or y))和Union(root(x),root(y)),其中root表示元素所在的等价类集合。
Find操作
该操作返回包含给定元素的集合(即等价类)的名字。譬如存在这样的集合构成的不相交集:DisjSet1={a, b, c}, DisjSet2 = {d, e, f}, DisjSet3 = {g},Find(a)将返回DisjSet1。有了这个操作,我们将非常容易的判别任意两个元素是否属于同一个等价类:Find(x) == Find(y) ? "属于同一等价类" : "属于不同的等价类"。
显然等价类的具体名字对于Find操作而言是无关紧要的,对于Find操作而言重要的是标记,理所当然我们会想到推举等价类中的一个元素作为代表来标记这个等价类,当Find某个元素的时候返回的都是这一个代表,而判断两个等价类是否是同一个等价类也就转换成了比较两个代表是否相同。编程实现也相当的简单:
C/C++ typedef int ElementType; ElementType Find(ElementType x, ElementType p[]) { if(p[x] <= 0) return x; else return Find(p[x], p); } //给个非递归的版本: ElementType Find(ElementType x, ElementType p[]) { ElementType ret = x; while(p[ret] > 0)ret = p[ret]; return ret; }
其中p数组表示元素x的父亲是谁。通过后面的Union介绍可知p数组能将这个集合逻辑的形成一棵树,所以每次的Find操作的复杂度为O(logN),其中N为集合的大小。但是有个问题,如果树退化成一条链后,每次Find操作的复杂度将变为O(N)。于是我们提出一种改进,在每次查找结束后,我们顺带的将沿路上的每个元素的父亲修改为直接代表(即树的根元素)。代码修改部分很小:
C/C++ typedef int ElementType; ElementType Find(ElementType x, ElementType p[]) { if(p[x] <= 0) return x; else return p[x] = Find(p[x], p);//仅在此修改即可 }
上面的方法叫做压缩路径法。
Union操作
Union操作是将两个不相交的等价类合并为一个。显然只需要将某一个等价类集合代表的父指针修改为另一个集合的代表(根节点)即可。代码实现很简单:
C/C++ typedef int ElementType; void Union(ElementType root1, ElementType root2, ElementType p[]) { p[root2] = root1; }
在前面已经提到过通过Union操作后,p数组将逻辑上形成一棵树,显然为了防止树退化(即小的集合和大的集合执行Union操作,却以小的集合的代表作为合并后的代表,极端情况每次Union操作小的集合大小都为1),我们能采取的一种手段是给每个集合多记录一个数据:集合的大小。由p数组我们知晓,只有代表所对应的p数组中的元素是没有太多意义的,仅作为是否是代表的标志,所以我们利用这个位置来记录集合的大小,为了和p中其余的元素区别开来,存放集合大小的相反数。这样我们可以通过判断p[x] < 0来获知x即为集合的代表。
有了上面的策略后,我们重新实现Union操作:
C/C++ typedef int ElementType; void Union(ElementType root1, ElementType root2, ElementType p[]) { if(p[root2] < p[root1])//表示第一个集合小 { p[root2] += p[root1]; p[root1] = root2; } else { p[root1] += p[root2]; p[root2] = root1; } }
除了上面的以集合的大小作为合并时的判据外还可以以集合形成的树的高度作为判据。我们知道当且仅当两个将要合并的集合的高度一致的时候合并后的高度才会+1。代码实现也很简单:
C/C++ typedef int ElementType; void Union(ElementType root1, ElementType root2, ElementType p[]) { if(p[root2] < p[root1])//表示第一个集合树的高度小 { p[root1] = root2; } else { if(p[root1] == p[root2]) p[root1]--; p[root2] = root1; }
}
时间复杂度
连续的M次操作最都需要O(MlogN)的时间。而且路径压缩与灵巧求法结合在所有情况下都将产生非常有效的算法。
一个典型应用
我们知道Kruskal算法是一种基于贪心的求解最小生成树的算法,Kruskal的基本过程是每次挑选G\T中权重最小的与生成树T中的边不成环的边插入到生成树T中,直到|T| == N - 1(其中G为带权连通图,T为构成生成树的边集合,N表示G的顶点数)。显然挑选一条边使得插入T后不出现环的充要条件是:边e(i,j)的两个顶点i,j属于两个不同的集合。到这里你发现这不就是我们上面介绍的不相交集吗?是的,初始每个顶点属于一个集合:
if (Find(i) != Find(j)) { insert e(i,j) into T; Union(Find(i), Find(j), p); }