并查集的原理及其优化

并查集

顾名思义,并查集是支持“合并”和“查询”操作的集合。

合并操作针对的是两个区域,是将在图论中称为非连通块的区域合并为一个连通块。

查询操作则是查找两个节点是否连通。

而对于无序性的集合来说,需要特别说明,并查集能够操作的是无向图,并不适用于有向图强连通性。

原理和结构

为了方便表示,我们用树这种数据结构来实现并查集,每个节点都有一个父节点,我们定义一个pre[i]数组用于存放第i个节点的父节点。初始化如下:

for(int i = 1;i<=n;i++) pre[i]=i;

这样我们就得到了n个指向自己的节点。

合并

并查集是怎么实现合并操作的呢?最简单地,我们构造一条新的边,将被操作的两个节点连在一起,就能够实现合并操作。但是并不能直接合并,因为每个节点只有一个父节点。有这样一种情况:

节点1——节点2,此时我们认为节点1的父节点为节点2,也就是pre[1]=2,如果需要再将节点1和节点3合并。按照错误的思路,我们会写到pre[1]=3这样一个代码。注意到,此时节点1与节点2便不在连通,因为节点1指向了节点3。那么有人说,将节点2的父节点指向节点3不就好了。是的,这样问题就解决了,但是问题解决的原因是什么。节点1和节点2有什么不同?他们的关系是怎样的?

节点2是节点1的根。没错,之所以用节点2操作可以实现,就是因为节点2是根的这一特性。同样,我们也可以认为节点3是根,在这里将节点3指向节点2也是正确的。所以合并的关键就是对根进行操作。实现代码如下:

void merge(int x,int y){
	x=root(x),y=root(y);//先找到根节点
    if(x==y) return;//如果根节点相同,则已连通,直接return
    pre[x]=y;//pre[y]=x也可以
}

int root(int x){//方法一:递归
    return pre[x]==x?x:root(pre[x]);
}
int root(int x){//方法二:循环
	while(pre[x]!=x) x=pre[x];
}

查询

我们已经知道了并查集的原理和结构,也实现了合并操作,那么查询也就不难了。如何查询两个节点是否连通?考虑到我们树的数据结构,越往根节点走,节点之间的关系应该是越密切,因此我们判断是否的连通的条件就是根节点是否相同,同根即连通。实现代码如下:

if(root(x)==root(y)) return true;
return false;

算法优化

上面是基础的模板,这里进一步思考有没有更加高效的算法。我们注意到,无论是合并还是查询的操作,关键都是在找根节点来进行操作,操作的过程并不复杂,往往就是一行代码,而主要的时间都花在了找根节点上,那么如何减少浪费在找根节点的时间呢?一方面,我们可以优化我们的具体找根的过程,比如通过记忆化,如果节点1的根节点是10,那么在1-10之间的所有节0点,其根节点也都应该是10,那么我们就可以采用路径压缩。另一方面,我们可以考虑合并时花点心思,将树树形结构合并的又矮又胖,也能减少查询的次数。

路径压缩

时间复杂度:O(1)

原理刚刚已经讲过了,就是那么回事,直接上代码:

int root(int x){//方法一:递归
    return pre[x]=(pre[x]==x?x:root(pre[x]));//找到根后返回的过程中,给路径上的节点的pre都赋上根节点的编号
}
int root(int x){//方法二:循环
    int res = x;
    while(pre[res]!=res) res=pre[res];//res为根 
    while(pre[x]!=x){
	int y = x;
    x=pre[x];
    pre[y]=res;
    }//类似于链表的插入操作,只不过这里是将链表中要指向的新的节点改成根节点
}

路径压缩有一个弊端:那就是改变了原来的树形结构

按秩合并

时间复杂度:O(logn)

秩可以理解为树高,我们尽量保证得到一个每个树支上的秩都比较平均的树,而非一个很深很深,每次找根都要从头找到尾的树,因此合并时,可以按秩进行合并。将秩小的节点合并到秩大的节点上,此时秩不会改变,而当两部分秩相同,则根节点的秩+1

int rnk[N];
void merge(int x,int y){
    x=root(X),y=root(y);
    if(x==y)return;
    if(rnk[x]>rnk[y]) swap(x,y)//保证rnk[x]<=rnk[y]
    pre[x]=y;//秩小的指向秩大的
    if(rnk[x]==rnk[y]) rnk[y]++;//相等时需要更新根节点的秩
}

启发式合并(按大小合并)

时间复杂度:O(logn)

大小和秩的本质类似,都是为了避免又长又深的树,代码也类似

int sz[N];
void merge(int x,int y){
    x=root(X),y=root(y);
    if(x==y)return;
    if(sz[x]>sz[y]) swap(x,y)//保证sz[x]<=sz[y]
    pre[x]=y;//小的指向大的
    sz[y]+=sz[x]//根节点更新大小
}

int main(){
for(int i = 1;i<=n;i++)sz[i]=1;//不要忘记初始化,否则一直为0
}

总结大概就到此为止啦~

posted @ 2025-03-24 20:28  Fllipped  阅读(42)  评论(0)    收藏  举报