并查集详谈

华为的一道经典的并查集试题:(看完解析回来看)

要求满足条件下的最多分组数,就先用并查集分组,分好之后查看并查集的根的个数就是做多分组数。

/*
    题目描述:
    幼儿园老师安排小朋友做游戏,现在需要给N个小朋友进行分组,老师让每个同学写一个名字,代表这位小朋友想和谁分到一组,请问老师在满足所有小朋友意思的情况下,最多可以将班级分为几组?

    输入描述:
    第一行输入N,0<N<=100000
    接下来是N行代表每个小朋友希望和谁分到一组,如:”John Jack”,代表John希望和Jack分到一组,两个名字之间以空格分割,名字本身不存在空格。

    输出描述:
    分组的最多数量

    输入:
    6
    Jack Tom
    Alice John
    Jessica Leonie
    Tom Alice
    John Jack
    Leonie Jessica

    输出:
    2
*/
#include<iostream>
#include<map>
#include<set>
#include<string>
#include<vector>
using namespace std;

int findRoot(vector<int>& parent, int x){
    // return x == parent[x] ? x : findRoot(parent, parent[x]);  // 路径折叠后如下
    if(x != parent[x]){
        parent[x] = findRoot(parent, parent[x]);
    }
    return parent[x];
}

void unionElement(vector<int>& parent, vector<int>& rank, int a, int b){
    int aRoot = findRoot(parent, a);
    int bRoot = findRoot(parent, b);

    if(rank[aRoot] < rank[bRoot]){
        parent[aRoot] = bRoot;
    }else{
        parent[bRoot] = aRoot;
        if(rank[aRoot] == rank[bRoot]){
            rank[aRoot]++;
        }
    }

}

bool isSame(vector<int>& parent, int a, int b){
    return findRoot(parent, a) == findRoot(parent, b);
}

int main(){
    int n;
    cin >> n;
    string name1, name2;
    map<string, int> nameIdMap;
    vector<pair<string, string> > vec;
    vector<int> parent(n);
    vector<int> rank(n,0);
    set<int> retSets;
    int i = 0;
    while(n--){
        cin >> name1 >> name2;
        nameIdMap[name1] = i;
        parent[i] = i;
        i++;
        vec.emplace_back(pair<string, string>(name1, name2));
    }

    for(pair<string, string> p : vec){
        int a = nameIdMap[p.first];
        int b = nameIdMap[p.second];
        if(isSame(parent, a, b)){
            continue;
        }
        unionElement(parent, rank, a, b);
    }

    for(int index = 0; index < vec.size(); index++){
        retSets.insert(findRoot(parent, index));
    }

    cout <<  retSets.size() << endl;
    return 0;
}

1.为什么会有并查集?

在这里插入图片描述
如上图,任意两个节点是否连接?这如同所有的网络节点中,任意的两个节点的用户是否通过多层关系相互认识?
为了探讨这个问题,发明了并查集这种数据结构。
并查集只是探讨了众多节点中的两个节点是否相连,但是没有探讨这两个节点具体的连接路径,这一点交给后面的图进行处理解决!

2.什么是并查集?

百度解释:并查集是一种树型的数据结构,用于处理一些不相交集合(disjoint sets)的合并及查询问题。

3.怎样用代码表示并查集?

1.最原始的并查集
在这里插入图片描述

比如上图,总共有两个派别,元素是1的是一组,元素是0的是一组。那怎么判断某两个数组下标(0,1,2,3…)是否是一个派别,原始方法如下:
先在这个unionfind1.h文件中写出最简单并查集的类:
先在这个unionfind1.h问文件中写出最简单并查集的类:

#ifndef INC_02_QUICK_FIND_UNIONFIND1_H
#define INC_02_QUICK_FIND_UNIONFIND1_H

#include <iostream>//输入输出
#include <cassert>//查错用

using namespace std;

// 我们的第一版Union-Find
namespace UF1 {

    class UnionFind {

    private:
        int *id;    // 我们的第一版Union-Find本质就是一个数组
        int count;  // 数据个数

    public:
        // 构造函数
        UnionFind(int n) {
            count = n;
            id = new int[n];
            // 初始化, 每一个id[i]指向自己, 没有合并的元素
            for (int i = 0; i < n; i++)
                id[i] = i;
        }

        // 析构函数
        ~UnionFind() {
            delete[] id;
        }

        // 查找过程, 查找元素p所对应的集合编号
        int find(int p) {
            assert(p >= 0 && p < count);
            return id[p];
        }

        // 查看元素p和元素q是否所属一个集合
        // O(1)复杂度
        bool isConnected(int p, int q) {
            return find(p) == find(q);
        }

        // 合并元素p和元素q所属的集合
        // O(n) 复杂度
        void unionElements(int p, int q) {

            int pID = find(p);
            int qID = find(q);

            if (pID == qID)
                return;

            // 合并过程需要遍历一遍所有元素, 将两个元素的所属集合编号合并
            for (int i = 0; i < count; i++)
                if (id[i] == pID)
                    id[i] = qID;
        }
    };
}
#endif //INC_02_QUICK_FIND_UNIONFIND1_H

上述代码重新添加了新的命名空间,是为了避免和系统的同名函数重叠,前面用using namespace 该指令告诉编译器后续代码正在使用指定命名空间中的名称。具体可看下述链接:
命名空间的使用

然后使用一个测试文件测试一下这个并查集对于“查看是否属于同一个元素集合”和“将两个不同元素集合合并为同一个集合”的效率,测试代码如下:

#ifndef INC_02_QUICK_FIND_UNIONFINDTESTHELPER_H
#define INC_02_QUICK_FIND_UNIONFINDTESTHELPER_H

#include <iostream>
#include <ctime>
#include "UnionFind1.h"

using namespace std;

// 测试并查集的辅助函数
namespace UnionFindTestHelper{

    // 测试第一版本的并查集, 测试元素个数为n
    void testUF1( int n ){

        srand( time(NULL) );
        //实例化了一个对象,它在构造函数中就创建了一个数组
        UF1::UnionFind uf = UF1::UnionFind(n);

        time_t startTime = clock();

        // 进行n次操作, 每次随机选择两个元素进行**合并**操作
        for( int i = 0 ; i < n ; i ++ ){
            int a = rand()%n;
            int b = rand()%n;
            uf.unionElements(a,b);
        }
        // 再进行n次操作, 每次随机选择两个元素, 查询他们是否同属一个集合
        for(int i = 0 ; i < n ; i ++ ){
            int a = rand()%n;
            int b = rand()%n;
            uf.isConnected(a,b);
        }
        time_t endTime = clock();

        // 打印输出对这2n个操作的耗时
        cout<<"UF1, "<<2*n<<" ops, "<<double(endTime-startTime)/CLOCKS_PER_SEC<<" s"<<endl;
    }
}
#endif //INC_02_QUICK_FIND_UNIONFINDTESTHELPER_H

主函数中输入数组大小进行测试:

#include <iostream>
#include "UnionFindTestHelper.h"

using namespace std;

// 测试UF1
int main() {

    // 使用10000的数据规模
    int n = 10000;

    // 虽然isConnected只需要O(1)的时间, 但由于union操作需要O(n)的时间
    // 总体测试过程的算法复杂度是O(n^2)的
    UnionFindTestHelper::testUF1(n);

    return 0;
}

结果显示,如果执行100 000次操作,合并和查询两个数是否同一集合就需要40s的时间,因为在合并的测试那里是O(n*n)的时间复杂度。

2.对原始并查集进行改进

在这里插入图片描述
先把每一个元素都看作是一个节点,然后想办法构成一个子节点指向父节点的树!

用到树就离不开指针,假设最初每一个节点的指针都指向自己,现在要合并某两个节点,那就让其中一个节点指向另一个节点,比如下图中的5和6,再比如438和9合并,就让9指向8,也就是那一串的根节点,当然指向4也是可以,但是会增加树的深度,不方便后续查找,所以直接指向根(具体谁指向谁会在后期优化)。
在这里插入图片描述
升级后的代码如下:
1.下面该文件用了新的命名空间,这样就避免和第一个同类型的文件同名函数冲突。

2.改进后的代码添加了指针(突然发现第一版也用了指针,因为在开辟堆空间的时候要用),这个指针的目的是为了指向父节点,从find函数就能看到,上一版的find函数直接利用指针数组形式返回要查找的内容,升级后返回的直接是该集合的根节点!判断是否属于同一集合就是判断他们的根节点是否相等,而不是上面那种判断两个数是否相等!最后合并的时候就是把其中一个根节点指向另一个根节点!

3.这里的指针并不是真的指针,初始化的时候parent[i]=i就表示第i个元素指向自己的父节点!尽管形式和第一版相同但意义不同了!

#ifndef INC_03_QUICK_UNION_UNIONFIND2_H
#define INC_03_QUICK_UNION_UNIONFIND2_H

#include <cassert>

using namespace std;

// 我们的第二版Union-Find
namespace UF2{

    class UnionFind{

    private:
        // 我们的第二版Union-Find, 使用一个数组构建一棵指向父节点的树
        // parent[i]表示第i个元素所指向的父节点
        int* parent;
        int count;  // 数据个数

    public:
        // 构造函数
        UnionFind(int count){
            parent = new int[count];
            this->count = count;
            // 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
            for( int i = 0 ; i < count ; i ++ )
                parent[i] = i;
        }

        // 析构函数
        ~UnionFind(){
            delete[] parent;
        }

        // 查找过程, 查找元素p所对应的集合编号
        // O(h)复杂度, h为树的高度
        int find(int p){
            assert( p >= 0 && p < count );
            // 不断去查询自己的父亲节点, 直到到达根节点
            // 根节点的特点: parent[p] == p
            while( p != parent[p] )
                p = parent[p];
            return p;
        }

        // 查看元素p和元素q是否所属一个集合
        // O(h)复杂度, h为树的高度
        bool isConnected( int p , int q ){
            return find(p) == find(q);
        }

        // 合并元素p和元素q所属的集合
        // O(h)复杂度, h为树的高度
        void unionElements(int p, int q){

            int pRoot = find(p);
            int qRoot = find(q);

            if( pRoot == qRoot )
                return;

            parent[pRoot] = qRoot;
        }
    };
}

#endif //INC_03_QUICK_UNION_UNIONFIND2_H

最后结果显示,同样20000个操作,上一个需要40多秒,而改进后的反向指针二叉树只需要20多秒,节省了一半的时间,效率提高了一倍!

3.再升级后的并查集

在这里插入图片描述
同样这个图里,834和9合并的时候,如果系统把8指向9了怎么办?这样的话,想要找到新集合的根节点,就需要更多次数的遍历循环,所以要对哪个根节点指向哪个根节点进行条件限制,**我们限制必须让集合元素少的指向集合元素多的!具体实现就需要重新定义一个元素个数指针,同样构造函数开辟空间并赋初值,然后在最后的合并函数中进行判断优化。**具体代码如下:

#ifndef INC_04_OPTIMIZE_BY_SIZE_UNIONFIND3_H
#define INC_04_OPTIMIZE_BY_SIZE_UNIONFIND3_H

#include <cassert>

using namespace std;

// 我们的第三版Union-Find
namespace UF3{

    class UnionFind{

    private:
        int* parent; // parent[i]表示第i个元素所指向的父节点
        int* sz;     // sz[i]表示以i为根的集合中元素个数
        int count;   // 数据个数

    public:
        // 构造函数
        UnionFind(int count){
            parent = new int[count];
            sz = new int[count];
            this->count = count;
            for( int i = 0 ; i < count ; i ++ ){
                parent[i] = i;
                sz[i] = 1;//每一个元素初值都是1
            }
        }

        // 析构函数
        ~UnionFind(){
            delete[] parent;
            delete[] sz;
        }

        // 查找过程, 查找元素p所对应的集合编号
        // O(h)复杂度, h为树的高度
        int find(int p){
            assert( p >= 0 && p < count );
            // 不断去查询自己的父亲节点, 直到到达根节点
            // 根节点的特点: parent[p] == p
            while( p != parent[p] )
                p = parent[p];
            return p;
        }

        // 查看元素p和元素q是否所属一个集合
        // O(h)复杂度, h为树的高度
        bool isConnected( int p , int q ){
            return find(p) == find(q);
        }

        // 合并元素p和元素q所属的集合
        // O(h)复杂度, h为树的高度
        void unionElements(int p, int q){

            int pRoot = find(p);
            int qRoot = find(q);

            if( pRoot == qRoot )
                return;

            // 根据两个元素所在树的元素个数不同判断合并方向
            // 将元素个数少的集合合并到元素个数多的集合上
            if( sz[pRoot] < sz[qRoot] ){
                parent[pRoot] = qRoot;
                sz[qRoot] += sz[pRoot];
            }
            else{
                parent[qRoot] = pRoot;
                sz[pRoot] += sz[qRoot];
            }
        }
    };
}

#endif //INC_04_OPTIMIZE_BY_SIZE_UNIONFIND3_H

最后结果显示,同样2万次操作,再升级后的版本只需要0.03s就可执行完毕,200万只需要0.7s!
在这里插入图片描述

4.第3次升级的并查集查询(最优秀的!)
当出现下图这种情况,如果依然按照元素少指向元素多,就会使本来深度为3变成了深度为4,所以新的办法就是让层数少的集合指向层数多的集合!
在这里插入图片描述
修改办法很简单,把上一个代码里的sz指针换成rank指针,rank就表示层数!
然后在最后的合并阶段,如果一个层数小,就把层数小的集合指向层数大的,而且还不需要修改合并后的层数,因为本身就没有变!只有当两个集合层数相同时才需要进行加一操作!
具体代码如下:

#ifndef INC_05_OPTIMIZE_BY_RANK_UNIONFIND3_H
#define INC_05_OPTIMIZE_BY_RANK_UNIONFIND3_H

#include <cassert>

using namespace std;

// 我们的第四版Union-Find
namespace UF4{

    class UnionFind{

    private:
        int* rank;   // rank[i]表示以i为根的集合所表示的树的层数
        int* parent; // parent[i]表示第i个元素所指向的父节点
        int count;   // 数据个数

    public:
        // 构造函数
        UnionFind(int count){
            parent = new int[count];
            rank = new int[count];
            this->count = count;
            for( int i = 0 ; i < count ; i ++ ){
                parent[i] = i;
                rank[i] = 1;
            }
        }

        // 析构函数
        ~UnionFind(){
            delete[] parent;
            delete[] rank;
        }

        // 查找过程, 查找元素p所对应的集合编号
        // O(h)复杂度, h为树的高度
        int find(int p){
            assert( p >= 0 && p < count );
            // 不断去查询自己的父亲节点, 直到到达根节点
            // 根节点的特点: parent[p] == p
            while( p != parent[p] )
                p = parent[p];
            return p;
        }

        // 查看元素p和元素q是否所属一个集合
        // O(h)复杂度, h为树的高度
        bool isConnected( int p , int q ){
            return find(p) == find(q);
        }

        // 合并元素p和元素q所属的集合
        // O(h)复杂度, h为树的高度
        void unionElements(int p, int q){

            int pRoot = find(p);
            int qRoot = find(q);

            if( pRoot == qRoot )
                return;

            // 根据两个元素所在树的元素个数不同判断合并方向
            // 将元素个数少的集合合并到元素个数多的集合上
            if( rank[pRoot] < rank[qRoot] ){
                parent[pRoot] = qRoot;
            }
            else if( rank[qRoot] < rank[pRoot]){
                parent[qRoot] = pRoot;
            }
            else{ // rank[pRoot] == rank[qRoot]
                parent[pRoot] = qRoot;
                rank[qRoot] += 1;   // 此时, 我维护rank的值
            }
        }
    };
}

#endif //INC_05_OPTIMIZE_BY_RANK_UNIONFIND3_H

5.并查集的最大优化–路径压缩(时间复杂度近乎O(1))

这一次优化不再是合并时优化,而是在查找的时候进行优化,查找的时候不再依次向上查找,而是从叶子节点开始,比如寻找4的根节点,先让它指向父亲的父亲,这样4就指向2,再让2指向父亲的父亲,这样2就指向了0。这样寻找4的根节点两步就完成了!
在这里插入图片描述
修改方式就是把find函数添加一行代码即可:

        int find(int p){
            assert( p >= 0 && p < count );

            // path compression 1
            while( p != parent[p] ){
                parent[p] = parent[parent[p]];//让p直接指向父亲的父亲
                p = parent[p];
            }
            return p;

            // path compression 2, 递归算法
//            if( p != parent[p] )
//                parent[p] = find( parent[p] );//直接得到根节点
//            return parent[p];
        }
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值