并查集
并查集的定义
并查集是一种维护集合的数据结构。“并”->Union(合并),“查”->Fing(查找)、“集”->Set(集合)。并查集主要用于下面两个操作:
- 合并:合并两个集合
- 查找:判断两个元素是否在同一个集合中
并查集是用一个数组int p[N]实现的。 p [ i ] p[i] p[i]表示元素i的父亲节点,而父亲节点本身也是这个集合中的元素( 1 ≤ i ≤ N 1\leq i\leq N 1≤i≤N)。例如 p [ 1 ] = 2 p[1]=2 p[1]=2表示元素1的父亲节点是元素2,以这种元素关系来表示元素所属的集合。 p [ i ] = i p[i]=i p[i]=i,则说明元素i是该集合的根节点,但是对于同一个集合来说,只有一个真正的根节点,且将其作为所属集合的标识。
如图所示。p[1]=1说明元素1的父节点是自己,即元素1是集合的根节点。p[2]=1说明元素2的父节点是元素1。p[3]=2和p[4]=2说明元素3和元素4的父节点是元素2。p[5]=5和p[6]=5说明5和6是以元素5为根节点的集合。于是就得到了两个不同的集合,第一个集合的根节点是元素1,第二个集合的根节点是元素5。
并查集的基本操作
初始化
并查集的使用需要先初始化p数组,然后再根据需要进行查找和合并的操作。
对于初始化来说,刚开始每个元素都是独立的一个集合,因此需要令所有 p [ i ] = i p[i]=i p[i]=i。
for(int i=1;i<=N;i++)
p[i]=i;
查找
由于规定了同一个集合中只能存在一个真正的根节点,因此对于查找操作来说,就是对给定的节点寻找其根节点的过程。具体思路就是:反复寻找父节点,直到找到根节点(即 p [ i ] = i p[i]=i p[i]=i的节点)。
可以用递推和递归实现
递推版本代码
//该函数返回元素x所在集合的根节点
int find(int x)
{
while(x!=p[x]) //如果不是根节点,循环继续
x=p[x]; //获得自己父节点
return x;
}
以图 9-37为例:假设要查找元素4的根节点,递推流程如下:
- x=4,p[4]=2,因此4!=p[4],于是继续查
- x=2,p[2]=1,因此2!=p[2],于是继续查
- x=1,p[1]=1,由于1==p[1],找到根节点,返回1。
递归版本代码
//非路径压缩版本
//该函数返回元素x所在集合的根节点
int find(int x)
{
//如果找到了根节点,则返回根节点的编号x
if(x==p[x])
return x;
//否则,递归判断x的父节点是否为根节点
else
return find(p[x]);
}
//路径压缩版本
//该函数返回元素x所在集合的根节点
int find(int x)
{
if(x!=p[x])
p[x]=find(p[x]);
return p[x];
}
合并
合并是指把两个集合合并成为一个集合。题目中一般是给出两个元素,要求把这两个元素所在的集合合并。具体方法:先判断这两个元素是否属于同一个集合,只有当这两个元素属于不同的集合时才能合并(因为如果你们都在同一个集合了,那还合并干嘛)。合并的过程一般是把其中一个元素的根节点的父亲指向另一个集合的根节点,也就是说,让集合A的根节点称为集合B的根节点的孩子。
- 对于给定的两个元素a、b,先判断它们是否属于同一个集合,而这可以调用find函数。找到元素a和元素b的根节点,然后再判断他俩的根节点是否相同。如果它俩的根节点相同,说明他俩属于同一个节点,那就不需要合并。否则说明他俩不再同一个集合中,那就可以合并。
- 合并两个集合:在第一步,我们已经得到了元素a的根节点fa,元素b的根节点fb,因此只需要把其中一个的父亲节点指向另一个节点即可。例如可以令 p [ f a ] = f b p[fa]=fb p[fa]=fb或者 p [ f b ] = f a p[fb]=fa p[fb]=fa。
以图9 -37为例,把元素4和元素6合并,合并过程如下:
- 判断元素4和元素6是否属于同一个集合:元素4所在集合的根节点是1,元素6所在集合的根节点是5,因此它们不属于同一个集合。
- 合并两个集合:令 p [ 5 ] = 1 p[5]=1 p[5]=1,即把元素5的父亲设置为元素。
void Union(int a,int b)
{
int fa=find(a); //找到元素a所在集合的根节点,记为fa
int fb=find(b); //找到元素b所在集合的根节点,记为fb
if(fa!=fb) //如果它们不属于同一个集合
p[fa]=fb; //合并两个集合
}
这里需要注意的是,我们不是随便直接把其中一个元素的父亲设为另一个元素,即 p [ a ] = b p[a]=b p[a]=b,这并不能实现将集合合并的效果。而是要找到根节点,让这个根节点的父亲成为另一个集合的根节点的孩子,即 p [ f a ] = f b p[fa]=fb p[fa]=fb。
例如将上面例子中的 p [ 4 ] = 6 p[4]=6 p[4]=6或者 p [ 6 ] = 4 p[6]=4 p[6]=4,就不能实现集合合并的效果。如下图所示:
最后说明一个并查集的性质:在合并的过程中,只对不同的集合进行合并,如果两个元素在相同的集合中,那么就不会对它们进行操作,这就保证了在同一个集合中一定不会产生环。即并查集产生的每一个集合都是一棵树。
路径压缩
考虑一种极端情况,即题目给出的元素数量很多,并且形成一条链,那么这个查找函数的效率就会特别低。如图所示,总共有 1 0 5 10^5 105个元素形成一条链,那么假设要进行 1 0 5 10^5 105次查询,且每次查询都查询最后的节点的根节点,那么每次都要花费 1 0 5 10^5 105的计算量去查找,这显然是无法承受的。
那么应该如何去优化查询算法呢?由于find函数的目的是寻找根节点,例如下面这个例子:
- p[1]=1
- p[2]=1
- p[3]=2
- p[4]=3
因此,如果只是为了查找根节点,那么完全可以想办法把操作等价变成:
- p[1]=1
- p[2]=1
- p[3]=1
- p[4]=1
对应图形的变化过程:
这样就相当于 把当前查询节点的路径上的所有节点的父亲都指向根节点,查找的时候就不需要一直回溯去找父亲了,查找的复杂度可以降为 O ( 1 ) O(1) O(1)。
int find(int x)
{
if(x!=p[x])
p[x]=find(p[x]);
return p[x];
}
路径上的所有节点的父亲都指向根节点**,查找的时候就不需要一直回溯去找父亲了,查找的复杂度可以降为 O ( 1 ) O(1) O(1)。
int find(int x)
{
if(x!=p[x])
p[x]=find(p[x]);
return p[x];
}