写在之前:本文用来记录自己对并查集的初步理解。
实质
并查集是一种对元素进行分组管理的树型数据结构,高效地进行合并及查找。
下面我将用图来表达。
首先是图一,我们最初得到一些离散的点,而我们的目的就是将它们建成几颗不同的树。
一、将它们看做是独立的,并且每个点的根结点是自己。
二、然后按照所需的规则,合并它们,变成图二。
可以从图二看出,最后合并的大概模样是几颗相互独立的树,特点就是拥有相同特点的元素汇聚在一起(这里以根结点作为相同特点)
下面引用例题来更进一步理解。
例题
出自HDU1232(https://cn.vjudge.net/problem/HDU-1232)
畅通工程:某省调查城镇交通状况,得到现有城镇道路统计表,表中列出了每条道路直接连通的城镇。省政府“畅通工程”的目标是使全省任何两个城镇间都可以实现交通(但不一定有直接的道路相连,只要互相间接通过道路可达即可)。问最少还需要建设多少条道路?
测试输入包含若干测试用例。每个测试用例的第1行给出两个正整数,分别是城镇数目N ( < 1000 )和道路数目M;随后的M行对应M条道路,每行给出一对正整数,分别是该条道路直接连通的两个城镇的编号。为简单起见,城镇从1到N编号。 注意:两个城市之间可以有多条道路相通。当N为0时,输入结束,该用例不被处理。
对每个测试用例,在1行里输出最少还需要建设的道路数目。
样例输入
4 2
1 3
4 3
3 3
1 2
1 3
2 3
5 2
1 2
3 5
999 0
0
输出
1
0
2
998
一、题目理解
问题是需要再建多少条道路,才能打通所有城镇。给出城镇的数量,连通的道路,即现在已连通的城镇。
那么我们可以将能够相互走通的城镇划进一个集合,然后看能够得到多少个独立的集合,然后在每个集合之间连一条路,那么就可以实现所有道路的连通了(参照图一和图二进行理解),所以需要再建的道路数量就是集合数量减1(两个集合连1条、三个集合连2条、四个集合连3条......)。
如果用并查集的思想来看就是,一开始将城镇看做是独立的元素,然后每个城镇连通自己,下面通过输入哪两个城市相连,就将它们合并,拥有同一根结点。完成所有合并过程后,根据根结点的数目就可以看做是有多少个集合,那么根结点数-1就是最后结果了。
值得注意的是,这里的合并规则就是城镇的连通,如果两个城镇连通,那就可以合并成一个集合,代码在最后给出。
并查集基础代码分析
#include <iostream>
#include <string.h>
#include <algorithm>
using namespace std;
const int MAX_N (1010);
int par[MAX_N]; //这是存储每个元素所指向自己根结点的数组
void init(int n){
for(int i = 1; i <= n; i++){
par[i] = i; //一开始将所有元素的根结点指向自己
}
}
int find(int x){ //寻找根结点
if(par[x] == x){ //如果找到的根结点是自己,就代表找到了最终的根结点。
return x;
}
else
return par[x]; //如果不是自己,就顺着自己所指向的结点往上找。
}
void unite(int x, int y){ //合并元素
xx = find(x); //找X的根结点
yy = find(y); //找Y的根结点
if(xx == yy) //如果相同,则代表是属于同一集合,就退出。
return;
par[yy] = xx; //如果不是,就将其中一者指向另一个,形成一条链。
return;
}
bool same(int x, int y){ //判断是否属于同一集合
return find(x) == find(y);
}
通过代码,我们可以直观看出并查集基础的实现原理,就是通过数组存储每个元素的根结点,然后将属于同一集合的元素相连,使其最终指向同一个根结点,主要函数包括init()初始化、find()寻找根结点、unite()合并元素,自己可以用实际例子运作一遍。待我们熟悉了基础代码后,我们再看进行优化后的并查集代码。
并查集优化代码分析
优化主要针对寻找根结点find()和合并元素unite()两部分。
如果按照基础代码进行运作,很有可能出现图3这种现象。
如果发生这样的现象,就会导致寻找根结点的时间很长(如果从3找,就要往上找两次)。所以我们想,要是只查一次就好了。那我们需要树设计成图4的形状。
所以这里将运用路径压缩的思想,就是将归属于同一根结点的元素,直接连接最终的根结点。下面是用递归优化find()函数的代码
int find(int x){ //寻找根结点
if(par[x] == x) //如果找到的根结点是自己的话,那么就表示找到了最终的根结点。
return x;
else
return par[x] = find(par[x]); //如果不是就进行递归,往上找;同时将par[X]即当前点的根结点上移,直到上移成最终的根结点,就完成了所谓的路径压缩。
}
下面是合并方面,直接从图5观察,如果出现不同高度的树,又要对其进行合并,那我们应该怎么操作呢?
这时,我们会将矮树,移到高树下面,这种思路被称作启发式策略——按秩合并,合并效果如图6所示。
这种优化的好处是,使得寻找最终根结点迭代次数减少,优化代码如下。
void unite(int x, int y){ //合并元素
xx = find(x); //查找点X根结点
yy = find(y); //查找点Y根结点
if(xx == yy) //如果两者寻得结点相同,即已归于同一集合当中
return;
if(ran[xx] < ran[yy]) //矮树归并到高树之下
par[xx] = yy; //归并
else{
par[yy] = xx; //同理,矮树归并到高树之下
if(ran[xx] == ran[yy]) //若两树高度相同,将Y合并进XX后,XX的树高加1
ran[xx]++;
}
return;
}
这里新增了一个数组用来存储每个结点所在的树高,对应的在初始化时,要将数组ran全部赋值为0,默认大小与根数组par一致。
好了,这就是全部的理解了,最后我将贴出例题HDU1232通畅工程的AC代码。
AC代码
#include <iostream>
#include <string.h>
#include <algorithm>
using namespace std;
const int MAX_N (1010);
int par[MAX_N];
int ran[MAX_N];
void init(int n){
for(int i = 1; i <= n; i++){
par[i] = i; //初始化par将所有元素的根结点设置为自己
ran[i] = 0; //初始化ran赋值为零,用于秩优化
}
}
int find(int x){
if(par[x] == x){
return x;
}
else
return par[x] = find(par[x]);
// return par[x] == x ? x : par[x] = find(par[x]); //简化写法,随意。
}
//int find (int x) //用迭代的方式代替递归
//{
// int root = x;
// while (root != par[root])
// {
// root = par[root];
// } //找到根结点
// int y;
// while (x != root) { //压缩路径,从点X开始往上找值,并将其赋值为根结点的值。
// y = par[x];
// par[x] = root;
// x = y;
// }
// return root;
//}
void unite(int x, int y){
x = find(x);
y = find(y);
if(x == y)
return;
if(ran[x] < ran[y])
par[x] = y;
else{
par[y] = x;
if(ran[x] == ran[y])
ran[x]++;
}
return;
}
bool same(int x, int y){
return find(x) == find(y);
}
int main(int argc, const char * argv[]) {
int n,m;
while(1){
cin >> n; //读入城镇数量
if(n == 0) //为0则退出
break;
cin >> m; //读入道路数量
init(n); //初始化数组
for(int i = 0; i < m; i++){ //读入,合并连通的城镇
int c1,c2;
cin >> c1 >> c2; //读入
unite(c1, c2); //合并
}
int t[MAX_N]; //用于存放每个集合的最终根结点
memset(t, 0, sizeof(t));
for(int i = 1; i <= n; i++){
t[find(i)] = 1; //寻找最终根结点
}
int ans = 0;
for(int i = 1; i <= n; i++){ //统计个数
if(t[i] == 1)
ans++;
}
cout << ans-1 << endl; //注意最后要减1
}
return 0;
}