【并查集】并查集详解及两种优化方法(路径压缩与按秩合并)
一.简介
并查集是一种树形的数据结构,用于解决不相交(disjoint)集合的合并及查询问题,包含两种操作:
①合并两个集合
②查询某个元素属于哪个集合
二.代表元法
用一个集合中的某个元素来代表这个集合
比如集合{2,3,5,7,9},不妨用7来代表这个集合,也可以理解为给这个集合编号/命名为7
并查集采用的正是这样的做法,因此它只解决不相交(不重叠)集合的问题,试想有两个集合都有元素7,并都用7来代表集合了岂不是出大问题
三.为什么需要并查集
现在有这样一个问题:有n个集合,m个元素,告诉你每个元素属于哪个集合,然后给出两个元素的编号,问你这两个元素是否属于同一个集合
问题很简单,你只要用一个数组f[x]来记录元素x属于哪个集合,然后对于题目给出的两个元素a、b,判断f[a]是否等于f[b]即可
将问题升级一下,不告诉你哪个元素属于哪个集合,而是给你这样的信息:
元素1和元素2是一个集合的
元素3和元素7是一个集合的
元素2和元素7是一个集合的
……
然后给出两个元素的编号,问你这两个元素是否属于同一集合。我们貌似仍然可以用上面的方法,但是存在了这样一个问题:
①一开始题目告诉我们元素1和元素2属于一个集合,那我们就令f[1]=f[2]=1;
②然后元素3和元素7属于一个集合,那我们就令f[3]=f[7]=3(代表元法)
③然后元素2和元素7属于一个集合,f[2]=1,f[7]=3,说明集合1和集合3其实是同一个集合,也就是说我们要合并这两个集合,那就要元素3和元素7的f[x]值都修改一遍
看起来没啥大不了的,但是可以细想一下上面这个操作的编程要如何实现:
发现集合3和集合1是同一个集合,就要将所有集合3的元素修改为集合1,那么首先得找到所有集合3的元素,怎么找?O(N)遍历f数组,如果f[x]=3就进行修改
假设有n个元素,题目给出m个元素a和元素b属于同一个集合这样的信息,那复杂度将高达O(m*n)
也就是说上面这种朴素的问题在合并集合时效率很低
四.并查集的实现
并查集是一种树形结构,它用一棵树来表示一个集合,不同的集合其实就构成了一个森林。
如何用树来表示集合可见下图:
①发现元素2和元素1属于一个集合,让元素2作为元素1的儿子
②发现元素3和元素2属于一个集合,让元素3作为元素2的儿子
③发现元素4和元素1属于一个集合,让元素4作为元素1的儿子
……
以此类推,就构建出了一棵树
并查集仍用一个数组来存放这个森林,f[x]记录的是元素x的父节点。这样做的好处是可以O(1)的复杂度合并集合。发现a和b属于同一个集合,令f[a]=b即可,而b也就是这个集合的代表元
当然查询速度会稍慢,要判断元素x是哪个集合的,需要从结点一直向上遍历到根节点
五.模板
①初始化
pre数组存放元素的父节点,初始化为1,可以理解为一开始每个元素都是一个集合
void init(){
for(int i=1;i<=50000;i++)
pre[i]=i;
}
②查询操作
递归实现:
因为pre[x]初始化为x,而集合在合并时pre数组会被赋值:pre[x]=y,也就是说如果pre[x]=x,x元素就是没被合并过的,是根节点,也是集合的代表元
int root_find(int x){
return pre[x]==x?x:root_find(pre[x]);
}
③集合合并
rx和ry分别是元素x和元素y的根节点,如果根节点相同说明本来就在同一树里,不同则进行合并
void join(int x,int y,int road_tag){
int rx=root_find(x);
int ry=root_find(y);
if(rx!=ry)
pre[rx]=ry;
}
六.两种优化及其利弊
路径压缩:
前文说过并查集查询速度略慢。而我们是需要进行大量的查询操作(每次合并集合时都需要),所以可以通过路径压缩来进行优化:
这两个树表示的是两个一模一样的集合,但是明显前者是我们更希望得到的,因为它的每个元素最多只需一步就能查询到根节点(代表元),而后者则差很多(比如元素5要三步才能到根节点)
也就是说让每个元素的pre[x]值存放根节点的值,而不是它的父节点。因为我们不关心树的结构,只关心哪些点在同一个树中。
很简单:
int root_find(int x){
return pre[x]==x?x:pre[x]=root_find(pre[x]);
}
对比没有路径压缩的查询代码:
int root_find(int x){
return pre[x]==x?x:root_find(pre[x]);
}
唯一的区别就是多了一个赋值:
理解:
①首先赋值表达式的值等于等号右边的值(不知道的可以看这篇:本人早年文献),所以加一个pre[x]=对查询函数并没有什么影响
②以上面的右图为例,查询元素5时,发现5的父亲为4,而4的上级不是根节点,故将其父亲赋值为根节点(4的父亲本来是2,给它换了个爹) 这样就在查询的过程中,让树的结构向上面的左图靠拢了
按秩合并:
秩可以理解为树高,按秩合并即在进行合并操作时让秩小的树依附到秩大的树上
可以这么理解:
将一棵树a接到另一棵树b后,树a的所有结点高度都会加1,而树b不会变化,所以让秩小的树接到秩大的根节点付出的代价相对更少
具体做法就是让一个数组记录每一个树的树高,在合并时取树高小的作为子树
这个优化在合并函数中进行(rk即rank缩写):
void join(int x,int y){
int rx=root_find(x);
int ry=root_find(y);
if(rx!=ry){
if(rk[rx]<rk[ry]) pre[rx]=ry;
else if(rk[ry]<rk[rx]) pre[ry]=rx;
else pre[rx]=ry,rk[ry]++;
}
else return 0;
return 1;
}
两种优化的比较:
都是以减少查询的代价(缩短查询路径)为目的,因为并查集合并集合的复杂度是O(1),只有查询稍显麻烦
两种优化可以同时进行,查询操作几乎可以降到O(1)
路径压缩的优化程度更高,所以一般用路径压缩就足够了
但是路径压缩会破坏树的结构,在一些情况下是不能使用的,按秩合并则不会
七.并查集的应用
并查集解决的是不相交区间的查询及合并问题,在图论中有个很重要的应用就是最小生成树的构造
Kruskal算法:
初始时,图中每个点都为一个连通分量。
①将所有边按权值从小到大排序
②按序(权值从小到大)考虑所有边,如果连接这条边能使图的连通分支减少,就连接,否则不连接
最后得到的就是最小生成树,复杂度与边有关,适合边稀疏图
并查集的应用: 判断两个点是否属于同一个连通分量。其实连通分量就是几个不相交的集合,因此可以用并查集来解决
原理:
无向连通网的最小生成树首先是一棵生成树,即它应该是一个使网中所有顶点相连通而所需边的条数为最小的子网络,且其代价和在所有生成树中为最小。因此,可以从网的连通性角度来观察Kruskal算法 。
初始时,最小生成树就是网中所有顶点的集合,它们之间没有任何一条边连接,即它们自成一个连通分量。而Kruskal算法的执行过程其实就是一个选取网中权值为最小的边的过程,即将两个小的连通分量连接为较大的连通分量,直至所有顶点都在一个连通分量中为止。每当选取网中的一条边时总会出现以下两种情况之一:
①该边所依附的两个顶点分属于不同的连通分量。这时,该边可以作为最小生成树的一条边,因为两个不同的连通分量通过这条边的连接而相连通,成为了一个连通分量 。
②该边所依附的两个顶点属于同一个连通分量。如果选取这条边作为最小生成树的一条边,则必将构成环。因为连通分量中任意两个顶点间都有路径相通,一旦再加入一条边,该边所依附的两个顶点之间就有了两条路径,即构成了环。故属于这种情况的边即使其权值小也应该舍弃 。
更多推荐
所有评论(0)