http://www.cnblogs.com/cyjb/p/UnionFindSets.htmlphp
http://blog.csdn.net/dm_vincent/article/details/7655764html
http://blog.csdn.net/dm_vincent/article/details/7769159java
并查集(Union-find Sets)是一种很是精巧而实用的数据结构,它主要用于处理一些不相交集合的合并问题。一些常见的用途有求连通子图、求最小生成树的 Kruskal 算法和求最近公共祖先(Least Common Ancestors, LCA)等。node
使用并查集时,首先会存在一组不相交的动态集合 S={S1,S2,⋯,Sk}S={S1,S2,⋯,Sk},通常都会使用一个整数表示集合中的一个元素。算法
每一个集合可能包含一个或多个元素,并选出集合中的某个元素做为表明。每一个集合中具体包含了哪些元素是不关心的,具体选择哪一个元素做为表明通常也是不关心的。咱们关心的是,对于给定的元素,能够很快的找到这个元素所在的集合(的表明),以及合并两个元素所在的集合,并且这些操做的时间复杂度都是常数级的。编程
并查集的基本操做有三个:数组
并查集的实现原理也比较简单,就是使用树来表示集合,树的每一个节点就表示集合中的一个元素,树根对应的元素就是该集合的表明,如图 1 所示。网络
图 1 并查集的树表示数据结构
图中有两棵树,分别对应两个集合,其中第一个集合为 {a,b,c,d}{a,b,c,d},表明元素是 aa;第二个集合为 {e,f,g}{e,f,g},表明元素是 ee。数据结构和算法
树的节点表示集合中的元素,指针表示指向父节点的指针,根节点的指针指向本身,表示其没有父节点。沿着每一个节点的父节点不断向上查找,最终就能够找到该树的根节点,即该集合的表明元素。
如今,应该能够很容易的写出 makeSet 和 find 的代码了,假设使用一个足够长的数组来存储树节点(很相似以前讲到的静态链表),那么 makeSet 要作的就是构造出如图 2 的森林,其中每一个元素都是一个单元素集合,即父节点是其自身:
图 2 构造并查集初始化
相应的代码以下所示,时间复杂度是 O(n)O(n):
1
2
3
4
5
6
|
const
int
MAXSIZE = 500;
int
uset[MAXSIZE];
void
makeSet(
int
size) {
for
(
int
i = 0;i < size;i++) uset[i] = i;
}
|
接下来,就是 find 操做了,若是每次都沿着父节点向上查找,那时间复杂度就是树的高度,彻底不可能达到常数级。这里须要应用一种很是简单而有效的策略——路径压缩。
路径压缩,就是在每次查找时,令查找路径上的每一个节点都直接指向根节点,如图 3 所示。
图 3 路径压缩
我准备了两个版本的 find 操做实现,分别是递归版和非递归版,不过两个版本目前并无发现有什么明显的效率差距,因此具体使用哪一个彻底凭我的喜爱了。
1
2
3
4
5
6
7
8
9
10
|
int
find(
int
x) {
if
(x != uset[x]) uset[x] = find(uset[x]);
return
uset[x];
}
int
find(
int
x) {
int
p = x, t;
while
(uset[p] != p) p = uset[p];
while
(x != p) { t = uset[x]; uset[x] = p; x = t; }
return
x;
}
|
最后是合并操做 unionSet,并查集的合并也很是简单,就是将一个集合的树根指向另外一个集合的树根,如图 4 所示。
图 4 并查集的合并
这里也能够应用一个简单的启发式策略——按秩合并。该方法使用秩来表示树高度的上界,在合并时,老是将具备较小秩的树根指向具备较大秩的树根。简单的说,就是老是将比较矮的树做为子树,添加到较高的树中。为了保存秩,须要额外使用一个与 uset 同长度的数组,并将全部元素都初始化为 0。
1
2
3
4
5
6
7
8
|
void
unionSet(
int
x,
int
y) {
if
((x = find(x)) == (y = find(y)))
return
;
if
(rank[x] > rank[y]) uset[y] = x;
else
{
uset[x] = y;
if
(rank[x] == rank[y]) rank[y]++;
}
}
|
下面是按秩合并的并查集的完整代码,这里只包含了递归的 find 操做。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
const
int
MAXSIZE = 500;
int
uset[MAXSIZE];
int
rank[MAXSIZE];
void
makeSet(
int
size) {
for
(
int
i = 0;i < size;i++) uset[i] = i;
for
(
int
i = 0;i < size;i++) rank[i] = 0;
}
int
find(
int
x) {
if
(x != uset[x]) uset[x] = find(uset[x]);
return
uset[x];
}
void
unionSet(
int
x,
int
y) {
if
((x = find(x)) == (y = find(y)))
return
;
if
(rank[x] > rank[y]) uset[y] = x;
else
{
uset[x] = y;
if
(rank[x] == rank[y]) rank[y]++;
}
}
|
除了按秩合并,并查集还有一种常见的策略,就是按集合中包含的元素个数(或者说树中的节点数)合并,将包含节点较少的树根,指向包含节点较多的树根。这个策略与按秩合并的策略相似,一样能够提高并查集的运行速度,并且省去了额外的 rank 数组。
这样的并查集具备一个略微不一样的定义,即若 uset 的值是正数,则表示该元素的父节点(的索引);如果负数,则表示该元素是所在集合的表明(即树根),并且值的相反数即为集合中的元素个数。相应的代码以下所示,一样包含递归和非递归的 find 操做:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
const
int
MAXSIZE = 500;
int
uset[MAXSIZE];
void
makeSet(
int
size) {
for
(
int
i = 0;i < size;i++) uset[i] = -1;
}
int
find(
int
x) {
if
(uset[x] < 0)
return
x;
uset[x] = find(uset[x]);
return
uset[x];
}
int
find(
int
x) {
int
p = x, t;
while
(uset[p] >= 0) p = uset[p];
while
(x != p) {
t = uset[x];
uset[x] = p;
x = t;
}
return
x;
}
void
unionSet(
int
x,
int
y) {
if
((x = find(x)) == (y = find(y)))
return
;
if
(uset[x] < uset[y]) {
uset[x] += uset[y];
uset[y] = x;
}
else
{
uset[y] += uset[x];
uset[x] = y;
}
}
|
若是要获取某个元素 x 所在集合包含的元素个数,可使用 -uset[find(x)] 获得。
并查集的空间复杂度是 O(n)O(n) 的,这个很显然,若是是按秩合并的,占的空间要多一些。find 和 unionSet 操做均可以当作是常数级的,或者准确来讲,在一个包含 nn 个元素的并查集中,进行 mm 次查找或合并操做,最坏状况下所需的时间为 O(mα(n))O(mα(n)),这里的 αα 是 Ackerman 函数的某个反函数,在极大的范围内(比可观察到的宇宙中估计的原子数量 10801080 还大不少)均可以认为是不大于 4 的。具体的时间复杂度分析,请参见《算法导论》的 21.4 节 带路径压缩的按秩合并的分析。
本文主要介绍解决动态连通性一类问题的一种算法,使用到了一种叫作并查集的数据结构,称为Union-Find。
更多的信息能够参考Algorithms 一书的Section 1.5,实际上本文也就是基于它的一篇读后感吧。
原文中更多的是给出一些结论,我尝试给出一些思路上的过程,即为何要使用这个方法,而不是别的什么方法。我以为这个可能更加有意义一些,相比于记下一些结论。
关于动态连通性
咱们看一张图来了解一下什么是动态连通性:
假设咱们输入了一组整数对,即上图中的(4, 3) (3, 8)等等,每对整数表明这两个points/sites是连通的。那么随着数据的不断输入,整个图的连通性也会发生变化,从上图中能够很清晰的发现这一点。同时,对于已经处于连通状态的points/sites,直接忽略,好比上图中的(8, 9)。
动态连通性的应用场景:
若是每一个pair中的两个整数分别表明一个网络节点,那么该pair就是用来表示这两个节点是须要连通的。那么为全部的pairs创建了动态连通图后,就可以尽量少的减小布线的须要,由于已经连通的两个节点会被直接忽略掉。
在程序中,能够声明多个引用来指向同一对象,这个时候就能够经过为程序中声明的引用和实际对象创建动态连通图来判断哪些引用其实是指向同一对象。
对问题建模:
在对问题进行建模的时候,咱们应该尽可能想清楚须要解决的问题是什么。由于模型中选择的数据结构和算法显然会根据问题的不一样而不一样,就动态连通性这个场景而言,咱们须要解决的问题多是:
就上面两种问题而言,虽然只有是否可以给出具体路径的区别,可是这个区别致使了选择算法的不一样,本文主要介绍的是第一种状况,即不须要给出具体路径的Union-Find算法,而第二种状况可使用基于DFS的算法。
建模思路:
最简单而直观的假设是,对于连通的全部节点,咱们能够认为它们属于一个组,所以不连通的节点必然就属于不一样的组。随着Pair的输入,咱们须要首先判断输入的两个节点是否连通。如何判断呢?按照上面的假设,咱们能够经过判断它们属于的组,而后看看这两个组是否相同,若是相同,那么这两个节点连通,反之不连通。为简单起见,咱们将全部的节点以整数表示,即对N个节点使用0到N-1的整数表示。而在处理输入的Pair以前,每一个节点必然都是孤立的,即他们分属于不一样的组,可使用数组来表示这一层关系,数组的index是节点的整数表示,而相应的值就是该节点的组号了。该数组能够初始化为:
即对于节点i,它的组号也是i。
初始化完毕以后,对该动态连通图有几种可能的操做:
数组对应位置的值即为组号
分别获得两个节点的组号,而后判断组号是否相等
分别获得两个节点的组号,组号相同时操做结束,不一样时,将其中的一个节点的组号换成另外一个节点的组号
初始化为节点的数目,而后每次成功链接两个节点以后,递减1
API
咱们能够设计相应的API:
注意其中使用整数来表示节点,若是须要使用其余的数据类型表示节点,好比使用字符串,那么能够用哈希表来进行映射,即将String映射成这里须要的Integer类型。
分析以上的API,方法connected和union都依赖于find,connected对两个参数调用两次find方法,而union在真正执行union以前也须要判断是否连通,这又是两次调用find方法。所以咱们须要把find方法的实现设计的尽量的高效。因此就有了下面的Quick-Find实现。
Quick-Find 算法:
举个例子,好比输入的Pair是(5, 9),那么首先经过find方法发现它们的组号并不相同,而后在union的时候经过一次遍历,将组号1都改为8。固然,由8改为1也是能够的,保证操做时都使用一种规则就行。
上述代码的find方法十分高效,由于仅仅须要一次数组读取操做就可以找到该节点的组号,可是问题随之而来,对于须要添加新路径的状况,就涉及到对于组号的修改,由于并不能肯定哪些节点的组号须要被修改,所以就必须对整个数组进行遍历,找到须要修改的节点,逐一修改,这一下每次添加新路径带来的复杂度就是线性关系了,若是要添加的新路径的数量是M,节点数量是N,那么最后的时间复杂度就是MN,显然是一个平方阶的复杂度,对于大规模的数据而言,平方阶的算法是存在问题的,这种状况下,每次添加新路径就是“牵一发而动全身”,想要解决这个问题,关键就是要提升union方法的效率,让它再也不须要遍历整个数组。
Quick-Union 算法:
考虑一下,为何以上的解法会形成“牵一发而动全身”?由于每一个节点所属的组号都是单独记录,各自为政的,没有将它们以更好的方式组织起来,当涉及到修改的时候,除了逐一通知、修改,别无他法。因此如今的问题就变成了,如何将节点以更好的方式组织起来,组织的方式有不少种,可是最直观的仍是将组号相同的节点组织在一块儿,想一想所学的数据结构,什么样子的数据结构可以将一些节点给组织起来?常见的就是链表,图,树,什么的了。可是哪一种结构对于查找和修改的效率最高?毫无疑问是树,所以考虑如何将节点和组的关系以树的形式表现出来。
若是不改变底层数据结构,即不改变使用数组的表示方法的话。能够采用parent-link的方式将节点组织起来,举例而言,id[p]的值就是p节点的父节点的序号,若是p是树根的话,id[p]的值就是p,所以最后通过若干次查找,一个节点老是可以找到它的根节点,即知足id[root] = root的节点也就是组的根节点了,而后就可使用根节点的序号来表示组号。因此在处理一个pair的时候,将首先找到pair中每个节点的组号(即它们所在树的根节点的序号),若是属于不一样的组的话,就将其中一个根节点的父节点设置为另一个根节点,至关于将一颗独立的树编程另外一颗独立的树的子树。直观的过程以下图所示。可是这个时候又引入了问题。
在实现上,和以前的Quick-Find只有find和union两个方法有所不一样:
树这种数据结构容易出现极端状况,由于在建树的过程当中,树的最终形态严重依赖于输入数据自己的性质,好比数据是否排序,是否随机分布等等。好比在输入数据是有序的状况下,构造的BST会退化成一个链表。在咱们这个问题中,也是会出现的极端状况的,以下图所示。
为了克服这个问题,BST能够演变成为红黑树或者AVL树等等。
然而,在咱们考虑的这个应用场景中,每对节点之间是不具有可比性的。所以须要想其它的办法。在没有什么思路的时候,多看看相应的代码可能会有一些启发,考虑一下Quick-Union算法中的union方法实现:
上面 id[pRoot] = qRoot 这行代码看上去彷佛不太对劲。由于这也属于一种“硬编码”,这样实现是基于一个约定,即p所在的树老是会被做为q所在树的子树,从而实现两颗独立的树的融合。那么这样的约定是否是老是合理的呢?显然不是,好比p所在的树的规模比q所在的树的规模大的多时,p和q结合以后造成的树就是十分不和谐的一头轻一头重的”畸形树“了。
因此咱们应该考虑树的大小,而后再来决定究竟是调用:
id[pRoot] = qRoot 或者是 id[qRoot] = pRoot
即老是size小的树做为子树和size大的树进行合并。这样就可以尽可能的保持整棵树的平衡。
因此如今的问题就变成了:树的大小该如何肯定?
咱们回到最初的情形,即每一个节点最一开始都是属于一个独立的组,经过下面的代码进行初始化:
以此类推,在初始状况下,每一个组的大小都是1,由于只含有一个节点,因此咱们可使用额外的一个数组来维护每一个组的大小,对该数组的初始化也很直观:
而在进行合并的时候,会首先判断待合并的两棵树的大小,而后按照上面图中的思想进行合并,实现代码:
Quick-Union 和 Weighted Quick-Union 的比较:
能够发现,经过sz数组决定如何对两棵树进行合并以后,最后获得的树的高度大幅度减少了。这是十分有意义的,由于在Quick-Union算法中的任何操做,都不可避免的须要调用find方法,而该方法的执行效率依赖于树的高度。树的高度减少了,find方法的效率就增长了,从而也就增长了整个Quick-Union算法的效率。
上图其实还能够给咱们一些启示,即对于Quick-Union算法而言,节点组织的理想状况应该是一颗十分扁平的树,全部的孩子节点应该都在height为1的地方,即全部的孩子都直接链接到根节点。这样的组织结构可以保证find操做的最高效率。
那么如何构造这种理想结构呢?
在find方法的执行过程当中,不是须要进行一个while循环找到根节点嘛?若是保存全部路过的中间节点到一个数组中,而后在while循环结束以后,将这些中间节点的父节点指向根节点,不就好了么?可是这个方法也有问题,由于find操做的频繁性,会形成频繁生成中间节点数组,相应的分配销毁的时间天然就上升了。那么有没有更好的方法呢?仍是有的,即将节点的父节点指向该节点的爷爷节点,这一点很巧妙,十分方便且有效,至关于在寻找根节点的同时,对路径进行了压缩,使整个树结构扁平化。相应的实现以下,实际上只须要添加一行代码:
至此,动态连通性相关的Union-Find算法基本上就介绍完了,从容易想到的Quick-Find到相对复杂可是更加高效的Quick-Union,而后到对Quick-Union的几项改进,让咱们的算法的效率不断的提升。
这几种算法的时间复杂度以下所示:
Algorithm |
Constructor |
Union |
Find |
Quick-Find |
N |
N |
1 |
Quick-Union |
N |
Tree height |
Tree height |
Weighted Quick-Union |
N |
lgN |
lgN |
Weighted Quick-Union With Path Compression |
N |
Very near to 1 (amortized) |
Very near to 1 (amortized) |
对大规模数据进行处理,使用平方阶的算法是不合适的,好比简单直观的Quick-Find算法,经过发现问题的更多特色,找到合适的数据结构,而后有针对性的进行改进,获得了Quick-Union算法及其多种改进算法,最终使得算法的复杂度下降到了近乎线性复杂度。
若是须要的功能不只仅是检测两个节点是否连通,还须要在连通时获得具体的路径,那么就须要用到别的算法了,好比DFS或者BFS。
首先仍是回顾和总结一下关于并查集的几个关键点:
以上就是我认为并查集中存在的几个关键点。关于并查集更详尽的演化过程,能够参考上一篇关于并查集的文章:《并查集算法原理和改进》
言归正传,来看几个利用并查集来解决问题的例子:
(说明:除了第一个问题贴了完整的代码,后面的问题都只会贴出关键部分的代码)
问题的描述是这样的:
Today is Ignatius' birthday. He invites a lot of friends. Now it's dinner time. Ignatius wants to know how many tables he needs at least. You have to notice that not all the friends know each other, and all the friends do not want to stay with strangers.
One important rule for this problem is that if I tell you A knows B, and B knows C, that means A, B, C know each other, so they can stay in one table.
For example: If I tell you A knows B, B knows C, and D knows E, so A, B, C can stay in one table, and D, E have to stay in the other one. So Ignatius needs 2 tables at least.
对这个问题抽象以后,就是要求进行若干次union操做以后,还会剩下多少颗树(或者说还剩下多少Connected Components)。反映到这个例子中,就是要求有多少“圈子”。其实,这也是社交网络中的最基本的功能,每次系统向你推荐的那些好友通常而言,会跟你在一个“圈子”里面,换言之,也就是你可能认识的人,以并查集的视角来看这层关系,就是大家挂在同一颗树上。
给出实现代码以下:
最后,经过调用count方法获取的返回值就是树的数量,也就是“圈子”的数量。
根据问题的具体特性,上面同时采用了两种优化策略,即按秩合并以及路径压缩。由于问题自己对合并的前后关系以及子树的秩这类信息不敏感。然而,并非全部的问题都这样,好比下面这一道题目,他对合并的前后顺序就有要求:
http://acm.hdu.edu.cn/showproblem.PHP?pid=3635
题意:起初球i是被放在i号城市的,在年代更迭,世事变迁的状况下,球被转移了,并且转移的时候,连带该城市的全部球都被移动了:T A B(A球所在的城市的全部球都被移动到了B球所在的城市),Q A(问:A球在那城市?A球所在城市有多少个球呢?A球被转移了多少次呢?)
(上面题意的描述摘自:http://www.cnblogs.com/Shirlies/archive/2012/03/06/2382118.html)
在这道题中,对子树进行合并时,就不能按秩进行合并,由于合并是有前后关系的。
咱们重点关注一下要回答的问题是什么,好比Q A表明的问题就是:
A球在哪里? --- 这个问题好回答,A球所在的城市就是该子树的根节点,即find方法的返回值。
A球所在的城市有多少个球? --- 一样地,这个问题的答案就是size数组中对应位置的信息,虽然本题不能按秩进行合并优化,可是秩仍是须要被保存下来的。
A球被转移了多少次呢? --- 这个问题画张图,就比较好理解了:
首先将球1所在城市的全部球转移到球2所在的城市中,即城市2,而后将球1所在城市的全部球转移到球3所在的城市中,即城市3。显然,在第二步中,1球已经不在城市1中,由于其在第一步中已经转移到城市2了。而后第二步实际就是将城市2中的全部球(包括球1和球2)都转移到城市3中。
紧接着,将1球所在城市的球所有转移(包括球1,2,3)到球4所在的城市中,便是将3和4进行合并。这个时候若是直接进行合并的话,会获得一个链表状的结构,这种结构使咱们一直都力求避免的,因此能够采用前面使用的路径压缩进行优化。路径压缩的具体作法就不赘述了。如今须要考虑的是,通过这3轮合并,球1到底移动了多少次?若是从最后的结果图来看,球1最后到城市4,应该移动了2次,即1->3, 3->4。可是,仔细想一想就会发现,这是不正确的。由于在T1 2中球1首先移动到了城市2,而后T 1 3,表示1球所在的城市中的全部球被移动到了城市3中,即城市2中的球移动到城市3中,这会对1球进行一次移动。以此类推,最后在T 1 4中,1球从城市3中移动到了城市4中,又发生了一次移动,所以,1球一共移动了3次,1->2, 2->3, 3->4。那么这就存在问题了,至少在最后的图中,这一点很不直观,由于从1到4的路径上,已经没有2的踪影了。显然,这是路径压缩带来的反作用。由于采用了路径压缩,因此对树结构形成了一些破坏,具体而言,是可以推导出球的转移次数的信息被破坏了。试想一下,若是没有进行路径压缩,转移次数其实是很直观的,从待求节点到根节点走过的路径数,就是转移次数。
因此为了解决引入路径压缩带来的问题,须要引入第三个数组来保存每一个球的转移次数。结合题意,每次在进行转移的时候,是转移该球所在城市中全部的球到目标球所在的城市,把这句话抽象一下,就是只有根节点才可以进行合并。所以,现有的union方法仍是适用的,由于它在进行真正的合并以前,仍是须要首先找到两个待合并节点的根节点。而后合并的时候,将第一个球所在城市的的号码的转移次数加1。按照这种想法,实现代码为:
可是跟踪一下以上代码的调用过程不难发现,最后的球1,2,3,4的转移次数分别为1,1,1,0(惟一对trans数组进行影响的操做目前只存在于union方法中,见上)。显然,这是不正确的,正确的转移次数应该是3,2,1,0。那么是什么地方出了岔子呢,仍是看看路径压缩就明白了,在路径压缩的时候,只顾着压缩,而没有对转移次数进行更新。
那么如何进行更新呢?看看上图,1原本是2的孩子,如今却成了3的孩子,跳过了2,所以能够当作,1->2->3的路径被压缩成了1->3,即2->3的这条路径被压缩了。被压缩在了1->3中,所以更新的操做也就有了基本的想法,咱们能够讲被压缩的那条路径中的信息增长到压缩后的结果路径中,对应前面的例子,咱们须要把2->3的信息给添加到1->3,用代码来表示的话,就是:
trans[1] += trans[2];
通常化后,实现代码以下所示:
最后,若是须要得到球A的转移次数,直接获取trans[A]就OK了。
这道题目的目的是想知道通过一系列的合并操做以后,查询在全部的子树中,秩的最大值是多少,简而言之,就是最大的那颗子树包含了多少个节点。
很显然,这个问题也可以同时使用两种优化策略,只不过由于要求最大秩的值,须要有一个变量来记录。那么在哪一个地方来更新它是最好的呢?咱们知道,在按秩进行合并的时候,须要比较两颗待合并子树的秩,所以能够顺带的将对秩的最大值的更新也放在这里进行,实现代码以下:
这样,在完成了全部的合并操做以后,max中保存的即为所须要的信息。
http://acm.hdu.edu.cn/showproblem.php?pid=1272
http://acm.hdu.edu.cn/showproblem.php?pid=1325
这两个问题都是判断是否合并后的结构是一棵树,即结构中应该没有环路,除此以外,还有边数和顶点数量的之间的关系,应该知足edges + 1 = nodes。
对于并查集,后者能够经过检查最后的connected components的数量是否为1来肯定。
固然,二者在题目描述上仍是有必定的区别,前者是无向图,后者是有向图。可是对于使用并查集来实现时,这一点的区别仅仅体如今合并过程没法按秩优化了。其实,若是可以采用路径压缩,按秩优化的效果就不那么明显了,由于每次进行查询操做的时候,会对被查询的节点进行路径压缩(参见find方法),能够说这是一种“懒优化”,或者叫作“按需优化”。而按秩合并则是一个主动优化的过程,每次进行合并的时候都会进行。而采用按秩合并优化,须要额外一个保存size信息的数组,在一些应用场景中,对size信息并不在乎,所以为了实现可选的优化方法而增长空间复杂度,就有一些得不偿失了。而且,对于按秩合并以及路径压缩到底可以提升多少效率,咱们目前也并不清楚,这里作个记号,之后有空了写一篇相关的文章。
扯远了,回到正题。前面提到了判断一张图是不是一颗树的两个关键点:
------------------------------------------总结的分割线---------------------------------------
就目前看来,通常问题都是围绕着并查集的两个主要操做,union和find作文章,根据具体应用,增长一些信息,增长一些逻辑,例如上题中的转移次数,或者是根据问题特征选择使用合适的优化策略,按秩合并以及路径压缩。