并查集详解

 以前经常遇到并查集,但一直没有总结。今天把并查集的相关知识总结下。

在计算机科学中,并查集是一种树型的数据结构,其保持着用于处理一些不相交集合(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}是否属于同一个集合。从并查集就很好实现了。

典型题目:

HDU 1232 畅通工程

某省调查城镇交通状况,得到现有城镇道路统计表,表中列出了每条道路直接连通的城镇。省政府“畅通工程”的目标是使全省任何两个城镇间都可以实现交通(但不一定有直接的道路相连,只要互相间接通过道路可达即可)。问最少还需要建设多少条道路? 
 
Input
测试输入包含若干测试用例。每个测试用例的第1行给出两个正整数,分别是城镇数目N ( < 1000 )和道路数目M;随后的M行对应M条道路,每行给出一对正整数,分别是该条道路直接连通的两个城镇的编号。为简单起见,城镇从1到N编号。 
注意:两个城市之间可以有多条道路相通,也就是说
3 3
1 2
1 2
2 1
这种输入也是合法的
当N为0时,输入结束,该用例不被处理。 
 
Output
对每个测试用例,在1行里输出最少还需要建设的道路数目。
 
初看起来比较麻烦,可以简化为有n个点,给出单独点的连接关系,求不相交的集合数目
复制代码
#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.

 

该问题另外实现代码:

View Code

 

问题:朋友圈(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)]即可求出。

View Code
复制代码
#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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值