在一些有N个元素的集合应用问题中,咱们一般是在开始时让每一个元素构成一个单元素的集合,而后按必定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪一个集合中,这就是并查集思想。c++
这一类问题近几年来反复出如今信息学的国际国内赛题中,其特色是看似并不复杂,但数据量极大,若用图的数据结构来表示关系的话过于“奢侈”了,每每在空间上过大,计算机没法承受;即便在空间上勉强经过,运行的时间复杂度也极高,根本就不可能在比赛规定的运行时间(1~3秒)内计算出试题须要的结果,这就是为何常考的缘由。其实这只是一个对分离集合(并查集)操做的问题。而并查集经过树形结构,将没必要要的计算去除,减小了连通图的空间,优化了时间,达到了更高的效率。算法
【关键词】树型结构、并集、查集、优化数组
【并查集定义】并查集是一种树型的数据结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。经常在使用中以森林来表示。数据结构
【关键操做】函数
初始化:把每一个点所在集合初始化为其自身。优化
(这一步必不可少,是最关键的操做之一,这会直接影响到整串代码的正确)spa
查找:查找元素所在的集合,即根节点。blog
(用递归或循环不停查找当前节点的父节点,直到该节点的父节点等于本身为止)递归
合并:将两个元素所在的集合合并为一个集合。ci
(一般来讲,合并以前,应先判断两个元素是否属于同一集合,这可用上面的查找操做实现。)
【关键代码】(c++语言)
初始化:
for(i=1;i<=n;i++)fath[i]=i;//将本身的父节点赋值为自己
查找/判断集合:
递归:
int find(int x){ if(fath[x]!=x){ return find(fath[x]); } return x; }
非递归:
int find(int x){ while(fath[x]!=x){ x=fath[x]; } return x; }
【例题】亲戚
问题描述:
或许你并不知道,你的某个朋友是你的亲戚。他多是你的曾祖父的外公的女婿的外甥女的表姐的孙子。若是能获得完整的家谱,判断两我的是否亲戚应该是可行的,但若是两我的的最近公共祖先与他们相隔好几代,使得家谱十分庞大,那么检验亲戚关系实非人力所能及。在这种状况下,最好的帮手就是计算机。为了将问题简化,你将获得一些亲戚关系的信息,如Marry和Tom是亲戚,Tom和Ben是亲戚,等等。从这些信息中,你能够推出Marry和Ben是亲戚。请写一个程序,对于咱们的关于亲戚关系的提问,以最快的速度给出答案。
输入格式:
输入由两部分组成。
第一部分以N,M,Q开始。N为问题涉及的人的个数(1≤N≤5000)。这些人的编号为1,2,3,…, N。下面有M行(1≤M≤5000),每行有两个数ai, bi,表示已知ai和bi是亲戚。
第二部分如下Q行有Q个询问(1≤Q≤5000),每行为ci, di,表示询问ci和di是否为亲戚。
输出格式:
对于每一个询问ci, di,输出一行:若ci和di为亲戚,则输出“Yes”,不然输出“No”。
输入样例:
10 7 3
2 4
5 7
1 3
8 9
1 2
5 6
2 3
3 4
7 10
8 9
输出样例:
Yes
No
Yes
【题目解析】
若是使用常规思路就是连通图的算法,用Floyed算法经过第三路径连通连两个节点(亲戚)再查找的确可行,但本题因时间空间要求对于它来讲太严格,用连通图的思想必定会超过期空限制,须要寻找新的思路。咱们能够给每一个人创建一个集合,集合的元素值有他本身,表示最开始时他不知道任何人是它的亲戚。之后每次给出一个亲戚关系a, b,则a和他的亲戚与b和他的亲戚就互为亲戚了,将a所在集合与b所在集合合并。对于样例数据的操做全过程以下:
输入关系 分离集合
初始状态
(2,4) {1} {2,4} {3} {5} {6} {7} {8} {9}
(5,7) {1} {2,4} {3} {5,7} {6} {8} {9}
(1,3) {1,3} {2,4} {5,7} {6} {8} {9}
(8,9) {1,3} {2,4} {5,7} {6} {8,9}
(1,2) {1,2,3,4} {5,7} {6} {8,9}
(5,6) {1,2,3,4} {5,6,7} {8,9}
(2,3) {1,2,3,4} {5,6,7} {8,9}
最后咱们获得3个集合{1,2,3,4}, {5,6,7}, {8,9},因而判断两我的是否亲戚的问题就变成判断两个数是否在同一个集合中的问题。如此一来,须要的数据结构就没有图结构那样庞大了。
【并查集实现】
#include<cstdio> #include<cstring> using namespace std; int n,m,p,i,t1,t2,fath[5001]; bool ans[5001]; //用于储存答案 int find(int x){ if(fath[x]!=x)return find(fath[x]){ //若是x的父节点不是它自己就继续找根节点 fath[x]=find(fath[x]); } return fath[x]; //若是是就返回根节点的值 } int main(){ scanf("%d%d%d",&n,&m,&p); memset(ans,0,p); //初始化答案 for(i=1;i<=n;i++){ //初始化,使一个集合根节点为自己 fath[i]=i; } for(i=1;i<=m;i++){ scanf("%d%d",&t1,&t2); t1=find(t1); t2=find(t2); if(t1!=t2){ //若是不在同一集合中就合并集合 fath[t2]=t1; } } for(i=1;i<=p;i++){ scanf("%d%d",&t1,&t2); if(find(t1)==find(t2)){ //查找根节点 ans[i]=1; //记录答案 } } for(i=1;i<=p;i++){ //输出答案 if(ans[i])printf("Yes\n"); else printf("No\n"); } return 0; }
思路解析:
并查集的思想,就是亲戚就合并集合(树),对于两个亲戚关系的人的集合进行合并的操做,将一我的的所属树挂在另外一我的所属的树下面,而后对于两个判断的人就找他们的根节点的人是不是同一个就ok了。虽然得出的是正确答案,但这依然超过了时空限制。所以算法须要再优化,以下程序。
【并查集优化】
#include<cstdio> #include<cstring> using namespace std; int n,m,p,i,t1,t2,fath[5001]; bool ans[5001]; //用于储存答案 int find(int x){ //路径优压缩优化 if(fath[x]!=x){ //若是x的父节点不是它自己就继续找根节点 fath[x]=find(fath[x]); } return fath[x]; //若是是就返回根节点的值 } int main(){ scanf("%d%d%d",&n,&m,&p); memset(ans,0,p); //初始化答案 for(i=1;i<=n;i++){ //初始化,使一个集合根节点为自己 fath[i]=i; } for(i=1;i<=m;i++){ scanf("%d%d",&t1,&t2); t1=find(t1); t2=find(t2); if(t1!=t2){ //若是不在同一集合中就合并集合 fath[t2]=t1; } } for(i=1;i<=p;i++){ scanf("%d%d",&t1,&t2); if(find(t1)==find(t2)){ //查找根节点 ans[i]=1; //记录答案 } } for(i=1;i<=p;i++){ //输出答案 if(ans[i]){ printf("Yes\n"); } else printf("No\n"); } return 0; }
思路解析:
对于以前的程序,此处最大的优化就是在寻找根节点的函数上,函数直接在递归过程当中顺便将其合并的集合的子结点直接指向了根节点,这样的路径压缩很是简单而有效,能够减小查找时的递归层数,大大减小了计算的时间。
【优化关键代码】
递归:
int find(int x){ if(fath[x]!=x){//若是不是根节点就继续寻找 fath[x]=find(fath[x]);//将子节点直接指向根节点 } return x;//若是已寻找到根节点就返回根节点 }
非递归:
int find(int x){ int father,now=x,t=x; while(fath[now]!=now){//寻找到根节点 now=fath[now]; } father=fath[now];//储存根节点 while(fath[x]!=x){//将遍历到的元素直接指向它的根节点 x=fath[x]; fath[t]=father; t=x; } return father;//返回根节点的值 }
对于这种优化思路,另外一种方式也能实现,以下。
【以另外一种方式优化】
#include<stdio.h> using namespace std; int n,m,q,i,t1,t2,fath[5001]; bool ans[5001]; //用于储存答案 void change(int a){ //合并两棵树 int i; for(i=1;i<=n;i++){ //查找每个节点 if(fath[i]==a&&fath[i]!=fath[t1]){ fath[i]=fath[t1]; //合并节点 } } return ; } int main(){ scanf("%d%d%d",&n,&m,&q); for(i=1;i<=n;i++){ //初始化集合 fath[i]=i; } for(i=1;i<=m;i++){ scanf("%d%d",&t1,&t2); if(fath[t1]!=fath[t2]){ change(fath[t2]); //并集 fath[fath[t2]]=fath[t1]; } } for(i=1;i<=q;i++){ scanf("%d%d",&t1,&t2); if(fath[t1]==fath[t2]){ //只需看它们的父节点是否相同就好了 ans[i]=1; //储存答案 } else ans[i]=0; } for(i=1;i<=q;i++){ //输出答案 if(ans[i])printf("Yes\n"); else printf("No\n"); } return 0; }
思路解析:
本思路就是将集合当作只有一层的树,每次输入两个亲戚的关系后就将一棵树合并到另外一棵,此处就是直接将一棵树直接指向另外一棵的根节点,这样每个叶节点的父节点就是它所属树的根节点,查找起来十分方便,虽然没有【并查集优化快】,但也减小了查找的时间。这种思路能够再优化,就是将每一棵树的子节点数量储存下来,这样在并集的时候就能减小时间,以下。
【以另外一种方式优化的优化】
#include<stdio.h> using namespace std; int n,m,q,i,t1,t2,fath[5001],num[5001]; //num数组用于记录每一集合的成员个数 bool ans[5001]; //用于储存答案 void change(int a){ int i; for(i=1;i<=n;i++){ if(fath[i]==a&&fath[i]!=fath[t1]){ //合并集合 fath[i]=fath[t1]; } } return ; } int main(){ scanf("%d%d%d",&n,&m,&q); for(i=1;i<=n;i++){ //初始化元素的根节点 fath[i]=i; num[i]=1; } for(i=1;i<=m;i++){ scanf("%d%d",&t1,&t2); if(fath[t1]!=fath[t2]){ if(num[fath[t1]]<num[fath[t2]]){ //判断集合成员个数多少 t1=t1^t2;t2=t1^t2;t1=t1^t2; //位运算交换两个数的值 } //等同于swap(t1,t2) num[fath[t1]]+=num[fath[t2]]; //合并后的成员个数 change(fath[t2]); fath[fath[t2]]=fath[t1]; } } for(i=1;i<=q;i++){ scanf("%d%d",&t1,&t2); if(fath[t1]==fath[t2]){ //收集答案 ans[i]=1; } else ans[i]=0; } for(i=1;i<=q;i++){ //输出答案 if(ans[i]){ printf("Yes\n"); } else printf("No\n"); } return 0; }
思路解析:
本思路就是将集合中成员个数记录下来,在输入两个成员关系时,将成员所属集合成员少的合并到集合成员较多的集合中去,这样在并集时能够优化计算次数,但因为增长了交换的步骤使其抵消了原来的时间,反而时间复杂度增多了,下面提供一种本优化思路时间复杂度最低的也是最快的一个程序。
#include<stdio.h> using namespace std; int n,m,q,i,t1,t2,fath[5001]; bool ans[5001]; //用于储存答案 void change(int a){ //合并两棵树 int i; for(i=1;i<=n;i++){ //查找每个节点 if(fath[i]==a&&fath[i]!=fath[t1]){ fath[i]=fath[t1]; //合并节点 } } return ; } int main(){ scanf("%d%d%d",&n,&m,&q); for(i=1;i<=n;i++){ //初始化元素的根节点 fath[i]=i; } for(i=1;i<=m;i++){ scanf("%d%d",&t1,&t2); if(fath[t1]!=fath[t2]){ if(fath[t1]==t1&&fath[t2]!=t2){//仅判断是否有本身的集合 t1=t1^t2;t2=t1^t2;t1=t1^t2; } change(fath[t2]); //并集 fath[fath[t2]]=fath[t1]; } } for(i=1;i<=q;i++){ scanf("%d%d",&t1,&t2); if(fath[t1]==fath[t2])ans[i]=1 //只需它们的父节点是否相同再储存答案 else ans[i]=0; } for(i=1;i<=q;i++){ //输出答案 if(ans[i]) printf("Yes\n"); else printf("No\n"); } return 0; }
【总结】
并查集就是在一些有N个元素的集合应用问题中,输入某两个元素在一个集合中,合并集合,并对于输入的有两个元素的询问判断它们是否在一个集合中的问题。
若是这种关系用图来表示空间和时间就都必定会超过限制,可是用树形结构表示时空复杂度就要简化不少,一开始将每个元素断定在本身的集合中,而后对于输入数据合并集合(如【并查集实现】)。信息学奥赛中给的时空限制都很苛刻,通常都须要路径压缩优化来减化时空才能经过(如【并查集优化】),这就须要在合并时直接将一个集合的全部结点直接指向另外一个集合的根节点,这样查找时就能够减小递归层数,从而作到优化时间和空间。
若是用【以另外一种方式优化】作这道题,虽然经过了提交,但这样在合并集合时仍然有点费时间,应此改进后的思路【并查集优化】,就是在在寻找根节点时顺便就把子节点指向了根节点,虽然不是全部的子节点都指向了,但的确是最优解。