七 不相交集类
本章中我们描述解决等价问题的一种有效数据结构。这种数据结构实现起来简单,每个方法只需要几行代码,而且可以使用一个简单的数组。它的实现也非常快,每种操作只需要常数平均时间。它的分析极其困难:最坏情形的函数形式不停于我们已经见到的任何形式。对于这种不相交集数据结构,我们将:
-
讨论如何能够以最少的编程代价实现。
-
通过两个简单的观察结果极大地提高它的速度。
-
分析一种快速的实现方法的运行时间。
-
介绍一个简单的应用。
7.1 等价关系
若对于每一对元素(a,b),a,b∈S,a R b或者为true或者为false,则称在集合S上定义关系(relation)R。如果a R b为true,那么我们就说a与b有关系。
等价关系(equivalence relation)是满足下列三个性质的关系R:
-
自反性:对于所有的a∈S, a R a。
-
对称性:a R b当且仅当b R a。
-
传递性:若a R b且b R c则a R c。
电器连通性(electrical connectivity)是一个等价关系,其中所有的连接都是通过金属导线完成的。
7.2 动态等价性问题
给定一个等价关系“~”,一个自然的问题是对任意的a和b,确定是否a~b。问题是关系的定义通常不明显而且相当隐匿。
元素a∈S的等价类(equivalence class)是S的子集,它包含所有与a有(等价)关系的元素。等价类形成对S的一个划分:S的每一个成员恰好出现在一个等价类中。为确定是否a~b,我们只需验证a和b是否都在同一个等价类中。
输入数据最初是N个集合的类(collection),每个集合含有一个元素。初始的描述是所有的关系均为false(自反的关系除外)。每个集合都有一个不同的元素,从而Si∩Sj=Ø:这使得这些集合不相交。
此时,有两种操作允许进行。第一种操作是find,它返回包含给定元素的集合(即等价类)的名字。第二种操作是添加关系。从集合的观点上看,添加a~b,“∪”的结果是建立一个新集合Sk=Si∪Sj,去掉原来两个集合而保持所有的集合的不相交性。由于这个原因,常常把做这项工作的算法叫做不相交集合的求并/查找(union/find)算法。
该算法是动态的,因为在算法执行过程中,集合可以通过union操作而发生变化。这个算法还必然是联机(on-line)操作:当find执行时,他必须给出结果算法才能继续进行。另一种可能是脱机(off-line)算法,该算法需要观察全部的union和find序列。该算法在看到所有这些问题以后再给出它的所有解。这种差别类似于参加一次笔试(它一般是脱机的——只能在规定的时间用完之前给出解)和一次口试(它是联机的,因为你必须处理当前的问题,然后才能继续下一个问题)。
开始时我们有Si={i},i=0到N-1。真正的关键点在于:find(a)==find(b)为true,当且仅当a和b在同一个集合中。
解决动态等价问题的方案有两种。一种方案保证指令find能够以常数最坏情形运行时间执行,而另一种方案则保证指令union能够以常数最坏情形运行时间执行。已证明两者不能同时以常数最坏情形运行时间执行。
7.3 基本数据结构
回想以下问题:不要求find操作返回任何特定的名字,而只要求当且仅当两个元素属于相同的集合时,作用在这两个元素上的find返回相同的名字。一种想法是可以使用树来表示每一个集合,因为树上的每个元素都有相同的根。这样,该根可以用来命名所在的集合。图7-1表达了这种显示的表达方式,为了方便起见,把根的父链垂直画出。
图7-1 8个元素,初始时在不同的集合上
为了执行两个集合的union操作,我们通过使一棵树的根的父链接到另一棵树的根节点合并两棵树。显然这种操作花费常数时间。图7-2、7-3和7-4分别表示在union(4,5)、union(6,7)和union(4,6)操作之后的森林。其中,我们采纳了在union(x,y)后新的根是x的约定。最后的森林的非显示表示如图7-5。
图7-2 在union(4,5)之后
图7-3 在union(6,7)之后
图7-4 在union(4,6)之后
图7-5 上面的树的非显示表示
对元素x的一次find(x)操作通过返回包含x的树的根而完成。执行这次操作花费的时间与表示x的节点的深度成正比。上面建立一个深度为N-1的树,find的最坏情形运行时间是Ο(N)。
下面是基本算法的程序实现。不相交集的类架构代码。
class DisjSets
{
public:
explicit DisjSets( int numElements );
int find( int x ) const;
int find( int x );
void unionSets( int root1, int root2 );
private:
vector<int> s;
};
不相交集的初始化方法:
/**
* Construct the disjiont sets object.
* numElements is the initial number of disjoint sets.
*/
DisjSets::DisjSets( int numElements ) : s( numElements )
{
for( int i = 0; i < s.size(); i++ )
s[ i ] = -1;
}
下面是union的实现(不是最好的方法)。
/**
* Union two disjiont sets.
* For simplicity, we assume root1 and root2 are distinct.
* root1 is the root of set 1.
* root2 is the root of set 2.
*/
void DisjSets::unionSets( int root1, int root2 )
{
s[ root2 ] = root1;
}
下面是一个简单的不相交集find算法实现。
/**
* Perform a find.
* Error checks omitted again for simplicity.
* Return the set containing x.
*/
int DisjSets::find( int x ) const
{
if( s[ x ] < 0 )
return x;
else
return find( s[ x ] );
}
7.4 灵巧求并算法
上面的union的执行是相当随意的。可以进行简单改进来打破现有的随意性,使得总是较小的树成为较大的树的子树,我们称这种方法叫作按大小求并(union by size)。如下图:
图7-6 按大小求并的结果
图7-7 进行一次任意的union的结果
可以证明,如果这些union都是按照大小进行的,那么任何节点的深度不会超过logN。find操作运行时间是Ο(logN),而连续M次操作则花费Ο(MlogN)。下面是一棵最坏情形的树。
图7-8 N=16时最坏情形的树
另一种方法是按高度求并(union-by-height),它同样保证所有树的深度最多是Ο(logN)。下面是一棵树以及其对于按大小和按高度并的非显式表示。
/**
* Union two disjoint sets.
* For simplicity, we assume root1 and root2 are distinct
* and represent set names.
* root1 is the root of set 1.
* root2 is the root of set 2.
*/
void DisjSets::unionSets( int root1, int root2 )
{
if( s[ root2 ] < s[ root1] ) // root2 is deeper
s[ root1 ] = root2; // Make root2 new root
else
{
if( s[ root1 ] == s[ root2 ] )
s[ root1 ]--; // Update height if same
s[ root2 ] = root1; // Make root1 new root
}
}
7.5 路径压缩
执行union操作的任何算法都将产生相同的最坏情形的树,因为它必然会任意打破树间的均衡。无需对整个数据结构重新加工的方法是对find操作进行巧妙的改进。这种改进叫做路径压缩(path compression)。路径压缩在一次find操作期间执行而与用来执行union的方法无关。设操作为find(x),此时路径压缩的效果是,从x到根的路径上的每一个节点都使它的父节点变成根。图7-9给出对图7-8的最坏的树执行find(14)后路径压缩的效果。
图7-9 路径压缩的一个例子
路径压缩的效果在于通过额外的两个链的变化。因此,以后这些节点的访问将由于花费额外的工作来进行路径压缩而得到补偿。下面是递归地代码实现。
/**
* Perform a find with path compression
* Error checks omitted again for simplicity.
* Return the set containing x.
*/
int DisjSets::find( int x )
{
if( s[ x ] < 0 )
return x;
else
return s[ x ] = find( s[ x ] );
}
路径压缩与按大小求并完全兼容,而不完全与按高度求并兼容。
7.6 按秩求并和路径压缩的最坏情形
任意顺序的M=Ω(N)次union/find操作花费的总运行时间为Ο(Mlog*N)。
求并/查找算法的分析
引理7.1 当执行一系列union指令时,一个秩为r的节点必然至少有2^r个后裔节点(包括它自己)。
引理7.2 秩为r的节点的个数最多是N/2^r。
引理7.3 在求并/查找算法的任一时刻,从树叶到根的路径上的节点的秩单调增加。
对从表示i的顶点到根的路径上的每一个顶点v,我们在两个账户之一存入一个分币:
(1)如果v是根,或者v的父亲是根,或者v的父亲与v咋不同的秩组中,那么在该法则下收取一个单位的费用,这就需要将一个美分存入公共储金中。
(2)否则,将一个加拿大分币存入该顶点。
引理7.4 对于任意的find(v),不论存入公共储金还是存入节点,所存分币的总数恰好等于从v到根的路径上的节点的个数。
引理7.5 经过整个算法,在法则1下美分币总的存入量最多为M(G(N)+2)。
引理7.6 秩组g>0中顶点的个数V(g)至多为N/2^(F(g-1))。
引理7.7 存入秩组g的所有顶点的加拿大分币的最大个数至多是NF(g)/2(F(g-1))。
引理7.8 在法则2下总的存入分币数量最多为 个加拿大分币。
定理8.1 M次union和find的运行时间为Ο(Mlog*N)。
7.7 一个应用
应用求并/查找数据结构的一个例子是迷宫的生成,如图7-10所示就是这样一个迷宫。
图7-10 一个50x80迷宫
生成迷宫的一个简单算法是从各处的墙壁开始(除入口和出口外)。因此,不断地随机选择一面墙,如果被该墙分割的单元彼此不相连通,那么就把这面墙拆掉。重复这个过程直到开始单元和终止单元连通,那么就得到了一个迷宫。实际上不断地拆掉墙壁直到每一个单元都可以从其他单元达到更好(这会使迷宫产生更多误导的路径)。
我们用5x5迷宫叙述该算法。图7-11显示了初始的状态。开始的时候,各处都有墙,而每个单元都在它自己的等价类中。
图7-11 初始状态:所有的墙都存在,所有的单元都在它自己的集合中
图7-12显示了算法随后的一个状态,这是一些墙被拆掉之后的状态。
图7-12 在算法的某个时刻:几面墙被拆掉,集合合并。如果这时单元8和13之间的墙被随机地选定,那么这面墙将不拆掉,因为单元8和13已经是连通的
图7-13 在图7-12中单元18和13之间的墙被随机地选定。这面墙被拆掉,因为单元18和13还没有连通。他们所在的集合被合并
图7-14 最后,24面墙被拆掉,所有的元素都在一个集合中