如果:给出各个元素之间的联系,要求将这些元素分成几个集合,每个集合中的元素直接或间接有联系。在这类问题中主要涉及的是对集合的合并和查找,因此将这种集合称为并查集。
链表被普通用来计算并查集.表中的每个元素设两个指针:一个指向同一集合中的下一个元素;另一个指向表首元素。
链结构的并查集
采用链式存储结构,在进行集合查找时的算法复杂度仅为O(1);但合并集合时的算法复杂度却达到了O(n)。如果我们希望两种基本操作的时间效率都比较高的话,链式存储方式就“力不从心”了。
树结构的并查集
采用树结构支持并查集的计算能够满足我们的要求。并查集与一般的树结构不同,每个顶点纪录的不是它的子结点,而是将它的父结点记录下来。下面是树结构的并查集的两种运算方式
⑴直接在树中查询
⑵边查询边“路径压缩”
对应与前面的链式存储结构,树状结构的优势非常明显:编程复杂度低;时间效率高。
直接在树中查询
集合的合并算法很简单,只要将两棵树的根结点相连即可,这步操作只要O(1)时间复杂度。算法的时间效率取决于集合查找的快慢。而集合的查找效率与树的深度呈线性关系。因此直接查询所需要的时间复杂度平均为O(logN)。但在最坏情况下,树退化成为一条链,使得每一次查询的算法复杂度为O(N)。
边查询边“路径压缩
其实,我们还能将集合查找的算法复杂度进一步降低:采用“路径压缩”算法。它的想法很简单:在集合的查找过程中顺便将树的深度降低。采用路径压缩后,每一次查询所用的时间复杂度为增长极为缓慢的ackerman函数的反函数——α(x)。对于可以想象到的n,α(n)都是在5之内的。
并查集:(union-find sets)是一种简单的用途广泛的集合. 并查集是若干个不相交集合,能够实现较快的合并和判断元素所在集合的操作,应用很多。一般采取树形结构来存储并查集,并利用一个rank数组来存储集合的深度下界,在查找操作时进行路径压缩使后续的查找操作加速。这样优化实现的并查集,空间复杂度为O(N),建立一个集合的时间复杂度为O(1),N次合并M查找的时间复杂度为O(M Alpha(N)),这里Alpha是Ackerman函数的某个反函数,在很大的范围内(人类目前观测到的宇宙范围估算有10的80次方个原子,这小于前面所说的范围)这个函数的值可以看成是不大于4的,所以并查集的操作可以看作是线性的。它支持以下三中种操作:
-Union (Root1, Root2) //并操作;把子集合Root2并入集合Root1中.要求:Root1和 Root2互不相交,否则不执行操作.
-Find (x) //搜索操作;搜索单元素x所在的集合,并返回该集合的名字.
-UFSets (s) //构造函数。将并查集中s个元素初始化为s个只有一个单元素的子集合.
-对于并查集来说,每个集合用一棵树表示。
-集合中每个元素的元素名分别存放在树的结点中,此外,树的每一个结点还有一个指向其双亲结点的指针。
-设 S1= {0, 6, 7, 8 },S2= { 1, 4, 9 },S3= { 2, 3, 5 }
-为简化讨论,忽略实际的集合名,仅用表示集合的树的根来标识集合。
-为此,采用树的双亲表示作为集合存储表示。集合元素的编号从0到 n-1。其中 n 是最大元素个数。在双亲表示中,第 i 个数组元素代表包含集合元素 i 的树结点。根结点的双亲为-1,表示集合中的元素个数。为了区别双亲指针信息( ≥ 0 ),集合元素个数信息用负数表示。
下标 parent |
|
集合S1, S2和S3的双亲表示:
S1 ∪ S2的可能的表示方法
const int DefaultSize = 10;
class UFSets { //并查集的类定义
private:
int *parent;
int size;
public:
UFSets ( int s = DefaultSize );
~UFSets ( ) { delete [ ] parent; }
UFSets & operator = ( UFSets const & Value );//集合赋值
void Union ( int Root1, int Root2 );
int Find ( int x );
void UnionByHeight ( int Root1, int Root2 ); };
UFSets::UFSets ( int s ) { //构造函数
size = s;
parent = new int [size+1];
for ( int i = 0; i <= size; i++ ) parent[i] = -1;
}
unsigned int UFSets::Find ( int x ) { //搜索操作
if ( parent[x] <= 0 ) return x;
else return Find ( parent[x] );
}
void UFSets::Union ( int Root1, int Root2 ) { //并
parent[Root2] = Root1; //Root2指向Root1
}
Find和Union操作性能不好。假设最初 n 个元素构成 n 棵树组成的森林,parent[i] = -1。做处理Union(0, 1), Union(1, 2), …, Union(n-2, n-1)后,将产生如图所示的退化的树。
执行一次Union操作所需时间是O(1),n-1次Union操作所需时间是O(n)。若再执行Find(0), Find(1), …, Find(n-1), 若被
搜索的元素为i,完成Find(i)操作需要时间为O(i),完成 n 次搜索需要的总时间将达到
Union操作的加权规则
为避免产生退化的树,改进方法是先判断两集合中元素的个数,如果以 i 为根的树中的结点个数少于以 j 为根的树中的结点个数,即parent[i] > parent[j],则让 j 成为 i 的双亲,否则,让i成为j的双亲。此即Union的加权规则。
parent[0](== -4) < parent[4] (== -3)
void UFSets::WeightedUnion(int Root1, int Root2) {
//按Union的加权规则改进的算法
int temp = parent[Root1] + parent[Root2];
if ( parent[Root2] < parent[Root1] ) {
parent[Root1] = Root2; //Root2中结点数多
parent[Root2] = temp; //Root1指向Root2
}
else {
parent[Root2] = Root1; //Root1中结点数多
parent[Root1] = temp; //Root2指向Root1
}
}
使用加权规则得到的树
引题——亲戚(relation)
【问题描述】若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
规定:x和y是亲戚,y和z是亲戚,那么x和z也是亲戚。如果x,y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。(人数≤5000,亲戚关系≤5000,询问亲戚关系次数≤5000)。
【算法分析】
1. 算法1,构造图论模型。
用一个n*n的二维数组描述上面的图形,记忆各个点之间的关系。然后,只要判断给定的两个点是否连通则可知两个元素是否有“亲戚”关系。
但要实现上述算法,我们遇到两个困难:
(1)空间问题:需要n2的空间,而n高达5000!
(2)时间问题:每次判断连通性需要O(n)的处理。
该算法显然不理想。
并查集多用于图论问题的处理优化,我们看看并查集在这里的表现如何。
2. 算法2,并查集的简单处理。
我们把一个连通块看作一个集合,问题就转化为判断两个元素是否属于同一个集合。
假设一开始每个元素各自属于自己的一个集合,每次往图中加一条边a-b,就相当于合并了两个元素所在集合A和B,因为集合A中的元素用过边a-b可以到达集合B中的任意元素,反之亦然。
当然如果a和b本来就已经属于同一个集合了,那么a-b这条边就可以不用加了。
(1)具体操作:
① 由此用某个元素所在树的根结点表示该元素所在的集合;
② 判断两个元素时候属于同一个集合的时候,只需要判断他们所在树的根结点是否一样即可;
③ 也就是说,当我们合并两个集合的时候,只需要在两个根结点之间连边即可。
(2)元素的合并图示:
(3)判断元素是否属于同一集合:
用father[i]表示元素i的父亲结点,如刚才那个图所示:
faher[1]:=1;faher[2]:=1;faher[3]:=1;faher[4]:=5;faher[5]:=3
至此,我们用上述的算法已经解决了空间的问题,我们不再需要一个n2的空间来记录整张图的构造,只需要用一个记录数组记录每个结点属于的集合就可以了。
但是仔细思考不难发现,每次询问两个元素是否属于同一个集合我们最多还是需要O(n)的判断!
3. 算法3,并查集的路径压缩。
算法2的做法是指就是将元素的父亲结点指来指去的在指,当这课树是链的时候,可见判断两个元素是否属于同一集合需要O(n)的时间,于是路径压缩产生了作用。
路径压缩实际上是在找完根结点之后,在递归回来的时候顺便把路径上元素的父亲指针都指向根结点。
这就是说,我们在“合并5和3”的时候,不是简单地将5的父亲指向3,而是直接指向根节点1,由此我们得到了一个复杂度只是O(1)的算法。
〖程序清单〗
(1)初始化:
for i:=1 to n do father[i]:=i;
因为每个元素属于单独的一个集合,所以每个元素以自己作为根结点。
(2)寻找根结点编号并压缩路径:
function getfather(v : integer) : integer;
begin
if father[v]=v then exit(v);
father[v]:=getfather(father[v]);
getfather:=father[v];
end;
(3)合并两个集合:
proceudre merge(x, y : integer);
begin
x:=getfather(x);
y:=getfather(y);
father[x]:=y;
end;
(4)判断元素是否属于同一结合:
function judge(x, y : integer) : boolean;
begin
x:=getfaher(x);
y:=gefather(y);
if x=y then exit(true)
else exit(false);
end;
这个的引题已经完全阐述了并查集的基本操作和作用。
三、并查算法
通过对上面引题的分析,我们已经十分清楚——所谓并查集算法就是对不相交集合(disjoint set)进行如下两种操作:
(1)检索某元素属于哪个集合;
(2)合并两个集合。
我们最常用的数据结构是并查集的森林实现。也就是说,在森林中,每棵树代表一个集合,用树根来标识一个集合。有关树的形态在并查集中并不重要,重要的是每棵树里有那些元素。
1. 合并操作
为了把两个集合S1和S2并起来,只需要把S1的根的父亲设置为S2的根(或把S2的根的父亲设置为S1的根)就可以了。
这里有一个优化:让深度较小的树成为深度较大的树的子树,这样查找的次数就会少些。这个优化称为启发式合并。可以证明:这样做以后树的深度为O(logn)。即:在一个有n个元素的集合,我们将保证移动不超过logn次就可以找到目标。
【证明】我们合并一个有i个结点的集合和一个有j个结点的集合,我们设i≤j,我们在一个小的集合中增加一个被跟随的指针,但是他们现在在一个数量为i+j的集合中。由于:
1+log i=log(i+i)<=log(i+j);
所以我们可以保证性质。
由于使用启发式合并算法以后树的深度为O(logn),因此我们可以得出如下性质:启发式合并最多移动2logn次指针就可以决定两个事物是否想联系。
同时我们还可以得出另一个性质:启发式快速合并所得到的集合树,其深度不超过 ,其中n是集合S中的所有子集所含的成员数的总和。
【证明】我们可以用归纳法证明:
当i=1时,树中只有一个根节点,即深度为1
又|log2 1|+1=1所以正确。
假设i≤n-1时成立,尝试证明i=n时成立。
不失一般性,可以假设此树是由含有m(1≤m≤n/2)个元素,根为j的树Sj,和含有n-m个元素、根为k的树Sk合并而得到,并且,树j合并到树k,根是k。
(1)若合并前:子树Sj的深度<子树Sk的深度
则合并后的树深度和Sk相同,深度不超过:
|log2(n-m)|+1
显然不超过|log2 n|+1;
(2)若合并前:子树Sj的深度≥子树Sk的深度
则合并后的树的深度为Sj的深度+1,即:
(|log2m|+1)+1=|log2(2m)|+1<=|log2n|+1
小结:实践告诉我们,上面所陈述的性质对于一个m条边n个事物的联系问题,最多执行mlogn次指令。我们只是增加了一点点额外的代码,我们就把程序的效率很大地提升了。大量的实验可以告诉我们,启发式合并可以在线形时间内解答问题。更确切地说,这个算法运行时间的花费,很难再有更加明显的优秀、高效的算法了。
2. 查找操作
查找一个元素u也很简单,只需要顺着叶子到根结点的路径找到u所在的根结点,也就是确定了u所在的集合。
这里又有一个优化:找到u所在树的根v以后,把从u到v的路径上所有点的父亲都设置为v,这样也会减少查找次数。这个优化称作路径压缩(compresses paths)。
压缩路径可以有很多种方法,这里介绍两种最常用的方法:
(1)满路径压缩(full compresses paths):这是一种极其简单但又很常用的方法。就是在添加另一个集合的时候,把所有遇到的结点都指向根节点。
(2)二分压缩路径(compresses paths by halving):具体思想就是把当前的结点,跳过一个指向父亲的父亲,从6而使整个路径减半深度减半。这种办法比满路径压缩要快那么一点点。数据越大,当然区别就会越明显。
压缩路径的本质使路径深度更加地减小,从而使访问的时候速度增快,是一种很不错的优化。在使用路径压缩以后,由于深度经常性发生变化,因此我们不再使用深度作为合并操作的启发式函数值,而是使用一个新的rank数。刚建立的新集合的rank为0,以后当两个rank相同的树合并时,随便选一棵树作为新根,并把它的rank加1;否则rank大的树作为新根,两棵树的rank均不变。
3. 时间复杂度
并查集进行n次查找的时间复杂度是O(n )(执行n-1次合并和m≥n次查找)。其中 是一个增长极其缓慢的函数,它是阿克曼函数(Ackermann Function)的某个反函数。它可以看作是小于5的。所以可以认为并查集的时间复杂度几乎是线性的。
通过上面的分析,我们可以得出:并查集适用于所有集合的合并与查找的操作,进一步还可以延伸到一些图论中判断两个元素是否属于同一个连通块时的操作。由于使用启发式合并和路径压缩技术,可以讲并查集的时间复杂度近似的看作O(1),空间复杂度是O(N),这样就将一个大规模的问题转变成空间极小、速度极快的简单操作。