以前经常遇到并查集,但一直没有总结。今天把并查集的相关知识总结下。
在计算机科学中,并查集是一种树型的数据结构,其保持着用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。有一个联合-查找算法(union-find algorithm)定义了两个操作用于此数据结构:
- Find:确定元素属于哪一个子集。它可以被用来确定两个元素是否属于同一子集。
- Union:将两个子集合并成同一个集合。
因为它支持这两种操作,一个不相交集也常被称为联合-查找数据结构(union-find data structure)或合并-查找集合(merge-find set)。其他的重要方法,MakeSet,用于建立单元素集合。有了这些方法,许多经典的划分问题可以被解决。
为了更加精确的定义这些方法,需要定义如何表示集合。一种常用的策略是为每个集合选定一个固定的元素,称为代表(或者代表元),以表示整个集合。接着。Find(x)返回x所属集合的代表,而Union使用两个集合的代表作为参数。
需要注意的是,一开始我们假设元素都是分别属于一个独立的集合里的。
1.合并两个不相交集合(Union(x,y))
合并操作很简单:先设置一个数组Father[x],表示x的“父亲”的编号。那么,合并两个不相交集合的方法就是,找到其中一个集合最父亲的父亲(也就是最久远的祖先),将另外一个集合的最久远的祖先的父亲指向它。
也就是将一个集合的根节点的父指针指向另一个集合的根节点即可。
但是上面的union操作有一个明显的缺点,就是树的高度可能变得很大,以致find可能需要O(n)时间,(极端情况是树退化成一个线性表)。
(2) 判断两个元素是否属于同一集合(Find_Set(x))
本操作可转换为寻找两个元素的最久远祖先是否相同。可以采用递归实现。
优化
(1) Find_Set(x)时,路径压缩
寻找祖先时,我们一般采用递归查找,但是当元素很多亦或是整棵树变为一条链时,每次Find_Set(x)都是O(n)的复杂度。为了避免这种情况,我们需对路径进行压缩,即当我们经过”递推”找到祖先节点后,”回溯”的时候顺便将它的子孙节点都直接指向祖先,这样以后再次Find_Set(x)时复杂度就变成O(1)了,如下图所示。可见,路径压缩方便了以后的查找。
(2) Union(x,y)时,按秩合并
即合并的时候将元素少的集合合并到元素多的集合中,这样合并之后树的高度会相对较小。
(节点的秩:为了避免在union操作中,使树变成退化树,可以在树中的每一个节点存放一个非负的整数,称为节点的秩。节点的秩
等于以该节点作为子树的根时,该子树的高度,令x和y是当前森林中2颗不同树的根节点,rank(x)和rank(y)分别为这两个节点的秩。
在执行union(x,y)时,比较rank(x)和rank(y)
1.rank(x)<rank(y) 把y作为x的父亲,rank(x)和rank(y)不变。
2.rank(x)==rank(y) 把y作为x的父亲,rank(y)+1 (或者x也可以)
3.rank(x)>rank(y) 把x作为y的父亲,rank(x),rank(y)不变。
#include<iostream> using namespace std; #define MAX 80 int father[MAX];//fater[x]表示x的父节点 int rankArr[MAX];//rank[x]表示x的秩 void makeSet(int x) { father[x]=x;////根据实际情况指定的父节点可变化 rankArr[x]=0; // //根据实际情况初始化秩也有所变化 } ///* 查找x元素所在的集合,回溯时压缩路径*/ int findSet(int x) { if(x!=father[x]) { father[x]=findSet(father[x]); ////这个回溯时的压缩路径是精华 } return father[x]; } /* 按秩合并x,y所在的集合 下面的那个if else结构不是绝对的,具体根据情况变化 但是,宗旨是不变的即,按秩合并,实时更新秩。 */ void unionSet(int x,int y) { x=findSet(x); y=findSet(y); if(x==y) return ; if(rankArr[x]>rankArr[y]) { father[y]=x; } else if(rankArr[x]==rankArr[y]) { father[x]=y; rankArr[y]++; } else //rankArr[x]<rankArr[y] { father[x]=y; } } int main() { makeSet(0); father[4]=3; father[3]=2; father[2]=1; father[1]=1; findSet(4); father[7]=8; father[8]=9; father[9]=9; unionSet(1,7); for(int i=4;i>=1;i--) { cout<<i<<"--->"<<father[i]<<endl; } for(int i=7;i<=9;i++) { cout<<i<<"--->"<<father[i]<<endl; } }
注意我们用的是rankArr,而不是rank,因为是Stl里面有一个rank结构体。
main函数中,我们建立了2个集合,如图所示:
int findSet(int x) { if(x!=father[x]) { father[x]=findSet(father[x]); ////这个回溯时的压缩路径是精华 } return father[x]; }
为什么这点代码可以把所有子节点的父节点都指向根。
当我们调用
findSet(4); father[4]=findSet(3); ---father[3]=findSet(2) ---------father[2]=findSet(1) 由于1的父是自己,return 1. 于是回溯。 father[2]=1; 注意这时father【2】已经变成了1.这是关键。 father[3]=findSet(2),findSet返回值就是father[2]的值。为1.于是father[3]为1. 同理father[4]为1. 于是,2,,3,4的father都变成了1. 输出:
4--->1
3--->1
2--->1
1--->9
7--->9
8--->9
9--->9
请按任意键继续. . .
把findSet转成非递归:
int findSet(int x) { int p=x; while(p!=father[p]) p=father[p]; int q=x; int tmp; while(q!=p) { tmp=father[q]; father[q]=p; q=tmp; } return p; }
注意上面findSet有一个问题,由于路径压缩后有些节点秩改变了,可我们findSet根本没涉及到秩的操作。怎么回事?
我看了wikipedia findSet也是这么写的,也是没有涉及到秩rank的更改。一般来说,findSet后秩还是保持原来的秩,只是
合并时才更改秩。
复杂度分析
空间复杂度为O(N),建立一个集合的时间复杂度为O(1),N次合并M查找的时间复杂度为O(M Alpha(N)),这里Alpha是Ackerman函数的某个反函数,在很大的范围内(人类目前观测到的宇宙范围估算有10的80次方个原子,这小于前面所说的范围)这个函数的值可以看成是不大于4的,所以并查集的操作可以看作是线性的。具体复杂度分析过程见参考资料(3)。
应用
并查集常作为另一种复杂的数据结构或者算法的存储结构。常见的应用有:求无向图的连通分量个数,最近公共祖先(LCA),带限制的作业排序,实现Kruskar算法求最小生成树等。
参考:
并查集:http://www.nocow.cn/index.php/%E5%B9%B6%E6%9F%A5%E9%9B%86
并查集应用比较广泛。比如:M={1,4,6,8},N={2,4,5,7},我的需求就是判断{1,2}是否属于同一个集合。从并查集就很好实现了。
典型题目:
注意:两个城市之间可以有多条道路相通,也就是说
3 3
1 2
1 2
2 1
这种输入也是合法的
当N为0时,输入结束,该用例不被处理。
#include<iostream> using namespace std; const int N=1001; int father[N]; int total; int findSet(int x) { int r=x; while(r!=father[r]) r=father[r]; return r; } void join(int a,int b) { int fa=findSet(a); int fb=findSet(b); if(fa==fb) return; else { father[fa]=fb; total--;//统一了2个阵营的boss(根节点),所以需要数目-1 } } int main() { int n,m,x,y; while(scanf("%d",&n),n)//n代表城市数目 { scanf("%d",&m);//m代表街道数目 total=n-1;//初始化total,total为连接n个点最小需要n-1条边 for(int i=1;i<=n;i++)//注意这里是n father[i]=i;//初始化自己为自己的father for(int i=1;i<=m;i++) { scanf("%d%d",&x,&y); join(x,y); } printf("%d\n",total);//输出在已有基础上还需要的边的数目! } }
题目中要求比较特殊的是:N为0时,输入结束,我们上面的是用
while(scanf("%d",&n),n) 逗号表达式来做的,也可以这么做:
while(scanf("%d%d",&n,&m)!=EOF) { if(n==0) break; 用break来退出。
测试:
输入 4 2
1 3
3 4
输出1.
该问题另外实现代码:
问题:朋友圈(25分)
假如已知有n个人和m对好友关系(存于数字r)。如果两个人是直接或间接的好友(好友的好友的好友...),则认为他们属于同一个朋友圈,请写程序求出这n个人里一共有多少个朋友圈。
假如:n = 5 , m = 3 , r = {{1 , 2} , {2 , 3} , {4 , 5}},表示有5个人,1和2是好友,2和3是好友,4和5是好友,则1、2、3属于一个朋友圈,4、5属于另一个朋友圈,结果为2个朋友圈。
最后请分析所写代码的时间、空间复杂度。评分会参考代码的正确性和效率。
思路:简单的并查集的应用
#include <iostream> using namespace std; const int MAX_N=1000; int N,M; int par[MAX_N],rank[MAX_N]; void initset(int n){ for(int i=1;i<=n;i++){ par[i]=i; rank[i]=0; } } int findpar(int x){ if(x==par[x]) return x; return par[x]=findpar(par[x]); } void unionset(int x,int y){ x=findpar(x); y=findpar(y); if(x==y)return ; if(rank[x]<rank[y]){ par[x]=y; } else{ par[y]=x; if(rank[x]==rank[y]) rank[x]++; } } int main(){ while(cin>>N>>M){ // N persons, M pair of friends. initset(N); for(int i=0;i<M;i++){ int x,y; cin>>x>>y; unionset(x,y); } int res=0; for(int i=1;i<=N;i++){ if(par[i]==i) res++; } cout<<res<<endl; } return 0; }
上面的代码可以这么改进,在每次union操作减1.
POJ 1182 食物链
Description
动物王国中有三类动物A,B,C,这三类动物的食物链构成了有趣的环形。A吃B, B吃C,C吃A。
现有N个动物,以1-N编号。每个动物都是A,B,C中的一种,但是我们并不知道它到底是哪一种。
有人用两种说法对这N个动物所构成的食物链关系进行描述:
第一种说法是"1 X Y",表示X和Y是同类。
第二种说法是"2 X Y",表示X吃Y。
此人对N个动物,用上述两种说法,一句接一句地说出K句话,这K句话有的是真的,有的是假的。当一句话满足下列三条之一时,这句话就是假话,否则就是真话。
1) 当前的话与前面的某些真的话冲突,就是假话;
2) 当前的话中X或Y比N大,就是假话;
3) 当前的话表示X吃X,就是假话。
你的任务是根据给定的N(1 <= N <= 50,000)和K句话(0 <= K <= 100,000),输出假话的总数。
Input
第一行是两个整数N和K,以一个空格分隔。
以下K行每行是三个正整数 D,X,Y,两数之间用一个空格隔开,其中D表示说法的种类。
若D=1,则表示X和Y是同类。
若D=2,则表示X吃Y。
Output
只有一个整数,表示假话的数目。
Sample Input
100 7
1 101 1
2 1 2
2 2 3
2 3 3
1 1 3
2 3 1
1 5 5
Sample Output
3
题目告诉有3种动物,互相吃与被吃,现在告诉你m句话,其中有真有假,叫你判断假的个数(如果前面没有与当前话冲突的,即认为其为真话)
这题有几种做法,我以前的做法是每个集合(或者称为子树,说集合的编号相当于子树的根结点,一个概念)中的元素都各自分为A, B, C三类,在合并时更改根结点的种类,其他点相应更改偏移量。但这种方法公式很难推,特别是偏移量很容易计算错误。
下面来介绍一种通用且易于理解的方法:
首先,集合里的每个点我们都记录它与它这个集合(或者称为子树)的根结点的相对关系relation。0表示它与根结点为同类,1表示它吃根结点,2表示它被根结点吃。
那么判断两个点a, b的关系,我们令p = Find(a), q = Find(b),即p, q分别为a, b子树的根结点。
1. 如果p != q,说明a, b暂时没有关系,那么关于他们的判断都是正确的,然后合并这两个子树。这里是关键,如何合并两个子树使得合并后的新树能保证正确呢?这里我们规定只能p合并到q(刚才说过了,启发式合并的优化效果并不那么明显,如果我们用启发式合并,就要推出两个式子,而这个推式子是件比较累的活...所以一般我们都规定一个子树合到另一个子树)。那么合并后,p的relation肯定要改变,那么改成多少呢?这里的方法就是找规律,列出部分可能的情况,就差不多能推出式子了。这里式子为 : tree[p].relation = (tree[b].relation - tree[a].relation + 2 + d) % 3; 这里的d为判断语句中a, b的关系。还有个问题,我们是否需要遍历整个a子树并更新每个结点的状态呢?答案是不需要的,因为我们可以在Find()函数稍微修改,即结点x继承它的父亲(注意是前父亲,因为路径压缩后父亲就会改变),即它会继承到p结点的改变,所以我们不需要每个都遍历过去更新。
2. 如果p = q,说明a, b之前已经有关系了。那么我们就判断语句是否是对的,同样找规律推出式子。即if ( (tree[b].relation + d + 2) % 3 != tree[a].relation ), 那么这句话就是错误的。
3. 再对Find()函数进行些修改,即在路径压缩前纪录前父亲是谁,然后路径压缩后,更新该点的状态(通过继承前父亲的状态,这时候前父亲的状态是已经更新的)。
核心的两个函数为:
int Find(int x)
{
int temp_p;
if (tree[x].parent != x)
{
// 因为路径压缩,该结点的与根结点的关系要更新(因为前面合并时可能还没来得及更新).
temp_p = tree[x].parent;
tree[x].parent = Find(tree[x].parent);
// x与根结点的关系更新(因为根结点变了),此时的temp_p为它原来子树的根结点.
tree[x].relation = (tree[x].relation + tree[temp_p].relation) % 3;
}
return tree[x].parent;
}
void Merge(int a, int b, int p, int q, int d)
{
// 公式是找规律推出来的.
tree[p].parent = q; // 这里的下标相同,都是tree[p].
tree[p].relation = (tree[b].relation - tree[a].relation + 2 + d) % 3;
}
而这种纪录与根结点关系的方法,适用于几乎所有的并查集判断关系(至少我现在没遇到过不适用的情况…可能是自己做的还太少了…),所以向大家强烈推荐~~
搞定了食物链这题,基本POJ上大部分基础并查集题目就可以顺秒了,这里仅列个题目编号: POJ 1308 1611 1703 1988 2236 2492 2524。转自:http://www.cnblogs.com/ACShiryu/archive/2011/11/24/unionset.html
最近公共祖先LCA Tarjan
在一棵有根数T中,两个结点u和v的最近公共祖先(Least Common Ancestors)是指这样一个结点w, 它是u和v的祖先,并且在树T中具有最大深度。换种说法就是,对于有根树T的两个结点u、v,最近公共祖先 LCA(T, u, v):询问一个距离根最远的结点x,使得x同时为结点u、v的祖先。只有两种情况,上图:
“利用并查集优越的时空复杂度,我们可以实现LCA问题的O(n + Q)算法,这里Q表示询问的次数。Tarjan算法基于深度优先搜索的框架,对于新搜索到 的一个结点,首先创建由这个结点构成的集合,再对当前结点的每一个子树进行搜索,每搜索完一棵子树,则可确定子树内的LCA询问都已解决。其他的LCA询 问的结果必然在这个子树之外,这时把子树所形成的集合与当前结点的集合合并,并将当前结点设为这个集合的祖先。之后继续搜索下一棵子树,直到当前结点的所 有子树搜索完。这时把当前结点也设为已被检查过的,同时可以处理有关当前结点的LCA询问,如果有一个从当前结点到结点v的询问,且v已被检查过,则由于 进行的是深度优先搜索,当前结点与v的最近公共祖先一定还没有被检查,而这个最近公共祖先的包涵v的子树一定已经搜索过了,那么这个最近公共祖先一定是v 所在集合的祖先。”
为了解决最近公共祖先问题,通过对LCA(root[T])初始调用,来执行对T的树遍历。在遍历之前,假定每个结点都着色为WHITE。(同flag,标记false,true同理).下边是离线Tarjan算法的伪代码,说离线是因为,这个算法必须将所有的询问先记录下来,再一次性的求出每个点对的最近公共祖先。
LCA(u)
1 MAKE-SET(u)
2 ancestor[FIND-SET(u)] ← u
3 for each child v of u in T
4 do LCA(v)
5 UNION(u, v)
6 ancestor[FIND-SET(u)] ← u
7 color[u] ← BLACK
8 for each node v such that {u, v} ∈ P
9 do if color[v] = BLACK
10 then print "The least common ancestor of"
u "and" v "is" ancestor[FIND-SET(v)]
对于这个算法的应用,很好的一个例子是(HDU 2586 How far away ?)。题意是,求在一颗无向树中,任意两点间的距离。利用的简单的公式:distance(a,b) = dis[a] + dis[b] – 2 * dis[LCA(a,b)]即可求出。
#include<iostream> #include<cstdio> #include<cstring> using namespace std; const int N = 40001; struct Edge { int v, w, next; }edge[2 * N]; int n, m, size, head[N]; int x[N], y[N], z[N], root[N], dis[N]; bool mark[N]; //插入边 void Insert(int u, int v, int w) { edge[size].v = v; edge[size].w = w; edge[size].next = head[u]; head[u] = size++ ; edge[size].v = u; edge[size].w = w; edge[size].next = head[v]; head[v] = size++ ; } //查找操作 int Find(int x){ if(root[x] != x) { return root[x] = Find(root[x]); } return root[x]; } void LCA_Tarjan(int k) { mark[k] = true; root[k] = k; //m次询问, z[i]保存的是点 x[i] 和 y[i] 最近公共祖先 for(int i = 1; i <= m; i++ ) { if(x[i] == k && mark[y[i]]) z[i] = Find(y[i]); if(y[i] == k && mark[x[i]]) z[i] = Find(x[i]); } for(int i = head[k]; i != -1; i = edge[i].next) { if(!mark[edge[i].v]) { dis[edge[i].v] = dis[k] + edge[i].w; LCA_Tarjan(edge[i].v); root[edge[i].v] = k; } } } int main() { int cas, u, v, w, i; scanf("%d", &cas); while(cas--) { scanf("%d %d", &n, &m); size = 0; memset(head, -1, sizeof(head)); for(i = 1; i < n; i++ ) { scanf("%d %d %d", &u, &v, &w); Insert(u, v, w); } for(i = 1; i <= n; i++ ) { x[i] = y[i] = z[i] = 0; } for(i = 1; i <= m; i++ ) { scanf("%d %d", &u, &v); x[i] = u; y[i] = v; } memset(mark, false, sizeof(mark)); dis[1] = 0; LCA_Tarjan(1); for(i = 1; i <= m; i++ ) { printf("%d\n", dis[x[i]] + dis[y[i]] - 2 * dis[z[i]]); } } return 0; }
参考:
http://www.slyar.com/blog/disjoint-set.html
http://www.cnblogs.com/cherish_yimi/archive/2009/10/11/1580839.html
更多:
http://www.cnblogs.com/DreamUp/archive/2010/07/19/1780916.html
http://www.cnblogs.com/ACShiryu/archive/2011/11/24/unionset.html