超简单去理解系列:并查集

474 阅读8分钟
  • 在考研的进程中,福大的863大纲独树一帜,划分并查集在里面,让我这个一只脚刚刚踏入数据结构门槛的小白日赶夜赶的学习这个新概念。

简介

  • 在一些应用问题中,需将n个不同的元素划分成一组不相交的集合。开始时,每个元素自成 一个单元素集合,然后按一定顺序将属于同一组元素的集合合并。其间要反复用到查询某个元 素属于哪个集合的运算,适合于描述这类问题的抽象数据类型称为并查集。它的数学模型是一 组不相交的动态集合的集合S={A,B,C,...},它支持以下的运算:

    1. UFunion(A,B,U):将并查集U中的集合A和B合并,其结果取名为A或B.
    2. UFind(e):找出包含元素e的集合,并返回该集合的名字.
  • 并查集的一个应用是确定集合上的等价关系。首先,将集合S中每个元素初始化为一个单元素集。然后逐个处理每个等价性条件。当处理等价性条件x≡y时,先用UFind将x和y所属的集合找出来,再用UFunion将找出的集合进行合并。

  • 学习一个新知识的必须要明白它存在的意义与价值和它怎么实现。刚开是理解并查集的时候可能会有点唐突,会觉得这个概念存在价值并不是那么高。其实,我们刚进入数据结构的领域时,首先,最重要的是学会思维搭建与概念的规范。在学习的过程中我们的想法可能会有跟某种知识点部分耦合,却缺少将想法进行科学的总结与衍生。而并查集就是我们在处理数量与数量关系的总结。

  • 为了更好的理解并查集,举一个直白的例子:

    话说江湖上散落着各式各样的大侠,有上千个之多。他们没有什么正当职业,整天背着剑在外面走来走去,碰到和自己不是一路人的,就免不了要打一架。但大侠们有一个优点就是讲义气,绝对不打自己的朋友。而且他们信奉“朋友的朋友就是我的朋友”,只要是能通过朋友关系串联起来的,不管拐了多少个弯,都认为是自己人。这样一来,江湖上就形成了一个一个的帮派,通过两两之间的朋友关系串联起来。而不在同一个帮派的人,无论如何都无法通过朋友关系连起来,于是就可以放心往死了打。但是两个原本互不相识的人,如何判断是否属于一个朋友圈呢?

    我们可以在每个朋友圈内推举出一个比较有名望的人,作为该圈子的代表人物。这样,每个圈子就可以这样命名“中国同胞队”美国同胞队”……两人只要互相对一下自己的队长是不是同一个人,就可以确定敌友关系了。

    但是还有问题啊,大侠们只知道自己直接的朋友是谁,很多人压根就不认识队长要判断自己的队长是谁,只能漫无目的的通过朋友的朋友关系问下去:“你是不是队长?你是不是队长?”这样,想打一架得先问个几十年,饿都饿死了,受不了。这样一来,队长面子上也挂不住了,不仅效率太低,还有可能陷入无限循环中。于是队长下令,重新组队。队内所有人实行分等级制度,形成树状结构,我队长就是根节点,下面分别是二级队员、三级队员。每个人只要记住自己的上级是谁就行了。遇到判断敌友的时候,只要一层层向上问,直到最高层,就可以在短时间内确定队长是谁了。由于我们关心的只是两个人之间是否是一个帮派的,至于他们是如何通过朋友关系相关联的,以及每个圈子内部的结构是怎样的,甚至队长是谁,都不重要了。所以我们可以放任队长随意重新组队,只要不搞错敌友关系就好了。于是,门派产生了。例子转载来自这里

  • 用数组实现的并查集可描述如下:

    typedef struct ufset *UFset;
    typedef struct ufset{
        int *components;  //集合的元素与其所属集合的子集的关系
        int n;      //元素数量
    };
    
  • 函数UFinit(size)将components初始化成大小为size的单元素集合。实现方法如下:

    UFset UFinit(int size){
        int e;
        UFset U = (UFset) malloc(sizeof(ufset));
        U->components = (int *) malloc(sizeof(int) * (size + 1));
        for (e = 1; e <= size; e++) {
            U->components[e] = e;
        }
        U->n = size;
        return U;
    }
    
  • 在并查集的这种表示法下,其基本运算很容易实现。UFfind(e)的值就是components[e]实 现方法如下:

    int UFfind(int e, UFset U){
        return U->components[e];
    }
    
  •  int UFunion(int i, int j, UFset U){
         int k;
         for (k = 1; k <= U->n; k++) {
             if (U->components[k] == j) {
                 U->components[k] = i;
             }
         }
         return i;
     }
    

用父亲数组实现并查集

  • 采用树结构实现并查集的基本思想是,每个集合用一棵树来表示。树的结点用于存储集合 中的元素名。每个树结点还存放一个指向其父结点的指针。树根结点处的元素代表该树所表示 的集合。利用映射可以找到集合中元素所对应的树结点。 父亲数组是实现上述树结构的有效方法。具体实现方法如下:
    //父节点实现并查集
    typedef struct ufset *UFset;
    typedef struct ufset{
        int *parent;
    };
    UFset UFinit(int size)
    {
        int e;
        UFset U = (UFset) malloc(sizeof(ufset));
        U->parent = (int *) malloc(sizeof(int) * (size + 1));
        for (e = 1; e <= size; e++) {
            U->parent[e] = 0;
        }
        return U;
    }
    
    int Ufind(int e, UFset u){
        while (u->parent[e]){
            e = u->parent[e];
        }
        return e;
    }
    
    int UFunion(int i,int j,UFset u){
        u->parent[j] = i;
        return i;    //容易退化成一条链
    }*/
    
  • 其中,parent是表示树结构的父亲数组。元素x的父结点为parent[x]。函数UFinit将每个元素初始化为一棵单结点树。在并查集的父亲数组表示下,UFfind(e)运算就是从元素e相应的结点走到树根处,找出 所在集合的名字。用UFunion(i,j,U)合并一个集合,只要将表示其中一个集合的树的树根改为表示另一个集合的树的树根的儿子.
  • 容易看出,在最坏情况下,合并可能使n个结点的树退化成一条链。在这种情况下,对所有 元素各执行一次UFfind将耗时O(n2)。所以,尽管UFunion只需要O(1)时间,但UFfind可能使 总的时间耗费很大。为了克服这个缺点,可以作下述改进,使得每次UFfind不超过O(logn)时 间。在树根中保存该树的结点数,每次合并时总是将小树合并到大树上去。当一个结点从一棵 树移到另一棵树上去时,这个结点到树根的距离就增加 1,而这个结点所在的树的大小至少增加 一倍。于是并查集中每个结点至多被移动O(logn)次,从而每个结点到树根的距离不会超过O(logn)。所以每次UFind运算只需要O(logn)时间。
  • 在下面所描述的改进的并查集结构中增加了一个根结点数组root,用来记录树的根结点。 当元素e所在结点不是根结点时,root[e]==0,+,parent[e]表示其父结点;当元素e所在结点是根结 点时,root[e]=1,parent[e]的值是树中结点个数。
    typedef struct ufset *UFset;
    typedef struct ufset{
        int *parent;   //root[i]如果为0,放父节点,。root[i]如果是1,放有多少个孩子
        int *root;
    };
    
    UFset UFinit(int size)
    {
        int e;
        UFset U = (UFset) malloc(sizeof(ufset));
        U->parent = (int *) malloc(sizeof(int) * (size + 1));
        U->root = (int *) malloc(sizeof(int) * (size + 1));
        for (e = 1; e <= size; e++) {
            U->parent[e] = 1;
            U->root[e] = 1;
        }
        return U;
    }
    
    int UFfind(int e, UFset u){
        while (!u->root[e]) {
            e = u->parent[e];
        }
        return e;
    }
    
    //小树合并到大树里
    int UFunion(int i,int j,UFset u){
        if (u->parent[i] > u->parent[j]) {
            u->parent[i] += u->parent[j];
            u->root[j] = 0;
            u->parent[j] = i;
            return i;
        } else{
            u->parent[j] += u->parent[i];
            u->root[i] = 0;
            u->parent[i] = j;
            return j;
        }
    }
    
  • 加速并查集运算的另一个办法是采用路径压缩技术。在执行UFfind时,实际上找到了从一 个结点到树根的一条路径。路径压缩就是把这条路上的所有结点都改为树根的儿子。实现路径 压缩的最简单的方法是在这条路上走2次,第1次找到树根,第2次将路上所有结点的父结点都 改为树根。
    //路径压缩 在find的时候进行压缩
    int UFfind2(int e, UFset u){
        int i, j = e;
        while (!u->root[j]) {
            j = u->parent[j];
        }//找树根
        while (j != e) {  //不相等就路径压缩
            i = u->parent[e];  //用i做中介,储存父亲
            u->parent[e] = j;
            e = i;
        }
        return j;
    }
    
  • 路径压缩并不影响UFfind运算的时间,它仍然只要O(1)时间。但是路径压缩大大地加速了UFfind 运算。如果在执行UFunion时总是将小树并到大树上,而且在执行UFfind时,实行路 径压缩,则可以证明,n次UFfind至多需要O(na(n))时间。其中a(n)是单变量阿克曼函数的 逆,它是一个增长速度比logn慢得多但又不是常数的函数。对于通常见到的正整数n而言a(n)<=4。