算法:并查集

  • 在计算机科学中,并查集是一种树形的数据结构,用于处理不交集的合并(union)及查询(find)问题。
  • 并查集主要是用来解决动态连通性问题的,比如查询网状图中两个节点的状态,进行数学中集合相关的操作, 如求两个集合的并集等。

其主要操作:

  • findRoot:确定元素属于哪一个子集。它可以被用来确定两个元素是否属于同一个子集
  • unionElements:将两个子集合并成同一个集合

啥叫做动态连通性

简单来说,动态连通性可以抽象为一幅图连线。比如下面图中,一共有10个节点,它们互不相连,分别用 0   9 0~9 0 9标记
在这里插入图片描述
并查集算法需要实现如下操作:


class UF{
public:
	// 将p和q相连
    void unionElements(int p, int q);
    // 判断p和q是否连通
    bool isConnected(int p, int q);
    // 返回图中有多少个连通分量
    int count();
};

这里说的[连通]是一种等价关系,也就是说具有如下三个性质:

  • ⾃反性: 节点 p 和 p 是连通的。
  • 对称性: 如果节点 p 和 q 连通, 那么 q 和 p 也连通。
  • 传递性: 如果节点 p 和 q 连通, q 和 r 连通, 那么 p 和 r 也连通。

如上图:

  • 0〜9 任意两个不同的点都不连通, 调⽤ isConnected都会返回 false, 连通分量为 10 个。
  • 如果现在调⽤ unionElements(0, 1) , 那么 0 和 1 被连通, 连通分量降为 9 个。
  • 再调⽤ unionElements(1, 2) , 这时 0,1,2 都被连通, 调⽤ isConnected(0, 2) 也会返回true, 连通分量变为 8 个。

在这里插入图片描述
判断这种「等价关系」 ⾮常实⽤, ⽐如说编译器判断同⼀个变量的不同引⽤, ⽐如社交⽹络中的朋友圈计算等等。

而Union-Find 算法的关键就在于 unionElements和 isConnected函数的效率。 那么⽤什么模型来表⽰这幅图的连通状态呢? ⽤什么数据结构来实现代码呢?

  • 一般我们用森林(若⼲棵树) 来表⽰图的动态连通性
  • 可以用数组等多种数据结构来模拟实现这个森林

实现

数组实现

怎么实现

怎么用森林来表示连通性呢?我们设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己。刚开始时,图并没有相互连通,如下:
在这里插入图片描述

class UnionFind{
private:
    // 记录连通分量
    int cnt;
    // 节点 x 的节点是 parent[x]
    std::vector<int> parent;
public:
    /* 构造函数, n 为图的节点总数 */
    explicit UnionFind(int n){
        // ⼀开始互不连通, ⽗节点指针初始指向⾃⼰
        this->cnt = n;
        parent.resize(n);
        for (int i = 0; i < n; ++i) {
            parent[i] = i;
        }
    }

    //返回当前的连通分量个数
    int count() const{
        return cnt;
    }


private: 
    // 查找某个节点的父节点
    int findParent(int n){
        return parent[n];
    }

    // 返回某个节点 x 的根节点
    int findRoot(int x){
        // 根节点的 parent[x] == x
        while (parent[x] != x){
            x = parent[x];
        }
        return x;
    }
};

如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上
在这里插入图片描述

    // 将p和q相连
    void unionElements(int p, int q){
        int rootP = findRoot(p);
        int rootQ = findRoot(q);
        if(rootP == rootQ){
            return;
        }

        // 将两棵树合并为⼀棵
        parent[rootP] = rootQ;
        --cnt;
    }

这样, 如果节点 p 和 q 连通的话, 它们⼀定拥有相同的根节点:

在这里插入图片描述

    // 判断p和q是否连通
    bool isConnected(int p, int q){
        int rootP = findRoot(p);
        int rootQ = findRoot(q);
        return rootQ == rootP;
    }

⾄此, Union-Find 算法就基本完成了。我们来分析下时间复杂度。可以发现,主要API isConnected和 unionElements中的复杂度都是 findRoot函数造成的, 所以说它们的复杂度和 findRoot⼀样。

findRoot主要功能就是从某个节点向上遍历到树根, 其时间复杂度就是树的⾼度。 我们可能习惯性地认为树的⾼度就是 logN , 但这并不⼀定。 logN 的⾼度只存在于平衡⼆叉树, 对于⼀般的树可能出现极端不平衡的情况, 使得「树」 ⼏乎退化成「链表」 , 树的⾼度最坏情况下可能变成 N
在这里插入图片描述
所以说上⾯这种解法, findRoot, isConnected, unionElements的时间复杂度都是 O(N)。这个复杂度很不理想的, 你想图论解决的都是诸如社交⽹络这样数据规模巨⼤的问题, 对于 unionElements和 isConnected的调⽤⾮常频繁, 每次调⽤需要线性时间完全不可忍受。

问题的关键在于, 如何想办法避免树的不平衡呢

平衡性优化

我们要知道哪种情况下可能出现不平衡现象,关键在于union过程:

    // 将p和q相连
    void unionElements(int p, int q){
        int rootP = findRoot(p);
        int rootQ = findRoot(q);
        if(rootP == rootQ){
            return;
        }

        // 将两棵树合并为⼀棵
        parent[rootP] = rootQ;
        --cnt;
    }

我们⼀开始就是简单粗暴的把 p 所在的树接到 q 所在的树的根节点下⾯,那么这⾥就可能出现「头重脚轻」 的不平衡状况, ⽐如下⾯这种局⾯:

在这里插入图片描述
⻓此以往, 树可能⽣⻓得很不平衡。 我们其实是希望, ⼩⼀些的树接到⼤⼀些的树下⾯, 这样就能避免头重脚轻, 更平衡⼀些。解决⽅法是额外使⽤⼀个 size 数组, 记录每棵树包含的节点数, 我们不妨称为「重量」

private:
    // 记录连通分量
    int cnt;
    // 节点 x 的节点是 parent[x]
    std::vector<int> parent;
    // 新增⼀个数组记录树的“重量”
    std::vector<int> weight;
public:
    /* 构造函数, n 为图的节点总数 */
    explicit UnionFind(int n){
        // ⼀开始互不连通, ⽗节点指针初始指向⾃⼰
        this->cnt = n;
        parent.resize(n);
        weight.resize(n);
        for (int i = 0; i < n; ++i) {
            parent[i] = i;
            weight[i] = 1;
        }
    }

⽐如说 weight[3] = 5 表⽰, 以节点 3 为根的那棵树, 总共有 5 个节点。 这样我们可以修改⼀下 union ⽅法:

 
    // 将p和q相连
    void unionElements(int p, int q){
        int rootP = findRoot(p);
        int rootQ = findRoot(q);
        if(rootP == rootQ){
            return;
        }

        // ⼩树接到⼤树下⾯, 较平衡
        if(weight[rootP] > weight[rootQ]){
            parent[rootQ] = rootP;
            weight[rootP] += weight[rootQ];
        }else{
            parent[rootP] = rootQ;
            weight[rootQ] += weight[rootP];
        }
        --cnt;
    }

这样, 通过⽐较树的重量, 就可以保证树的⽣⻓相对平衡, 树的⾼度⼤致在 logN 这个数量级, 极⼤提升执⾏效率。

此时, findRoot, isConnected, unionElements 的时间复杂度都下降为 O(logN), 即便数据规模上亿, 所需时间也⾮常少

路径压缩

这步优化特别简单,所以非常巧妙。我们能不能进一步压缩每棵树的高度,使树高始终保持为常数?这样find就能以 O(1) 的时间找到某一节点的根节点,相应的,isConnected和unionElements 复杂度都下降为 O(1)。

要做到这一点,非常简单,只需要在要做到这一点,非常简单,只需要在find中加一行代码:中加一行代码:

    // 返回某个节点 x 的根节点
    int findRoot(int x){
        // 根节点的 parent[x] == x
        while (parent[x] != x){
            // 进行路径压缩
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }

请添加图片描述
可见,调用find函数每次向树根遍历的同时,顺手将树高缩短了,最终所有树高都不会超过 3(union的时候树高可能达到 3)。

问题:既然有了路径压缩, weight数组的重量平衡还需要吗? 这个问题很有意思, 因为路径压缩保证了树⾼为常数(不超过 3) , 那么树就算不平衡, ⾼度也是常数, 基本没什么影响。

论时间复杂度的话,确实,不需要重量也是O(1)。但是如果加上 weight数组辅助, 效率还是略微⾼⼀些, ⽐如下⾯这种情况:
在这里插入图片描述
如果带有重量平衡优化, ⼀定会得到情况⼀, ⽽不带重量优化, 可能出现情况⼆。 ⾼度为 3 时才会触发路径压缩那个 while 循环, 所以情况⼀根本不会触发路径压缩, ⽽情况⼆会多执⾏很多次路径压缩, 将第三层节点压缩到第⼆层。

也就是说, 去掉重量平衡, 虽然对于单个的 find 函数调⽤, 时间复杂度依然是 O(1), 但是对于 API 调⽤的整个过程, 效率会有⼀定的下降。 当然, 好处就是减少了⼀些空间, 不过对于 Big O 表⽰法来说, 时空复杂度都没变。

总结如下:

class UnionFind{
private:
    // 记录连通分量
    int cnt;
    // 节点 x 的节点是 parent[x]
    std::vector<int> parent;
    // 新增⼀个数组记录树的“重量”
    std::vector<int> weight;
public:
    /* 构造函数, n 为图的节点总数 */
    explicit UnionFind(int n){
        // ⼀开始互不连通, ⽗节点指针初始指向⾃⼰
        this->cnt = n;
        parent.resize(n);
        weight.resize(n);
        for (int i = 0; i < n; ++i) {
            parent[i] = i;
            weight[i] = 1;
        }
    }

    //返回当前的连通分量个数
    int count() const{
        return cnt;
    }


    // 将p和q相连
    void unionElements(int p, int q){
        int rootP = findRoot(p);
        int rootQ = findRoot(q);
        if(rootP == rootQ){
            return;
        }

        // ⼩树接到⼤树下⾯, 较平衡
        if(weight[rootP] > weight[rootQ]){
            parent[rootQ] = rootP;
            weight[rootP] += weight[rootQ];
        }else{
            parent[rootP] = rootQ;
            weight[rootQ] += weight[rootP];
        }
        --cnt;
    }

    // 判断p和q是否连通
    bool isConnected(int p, int q){
        int rootP = findRoot(p);
        int rootQ = findRoot(q);
        return rootQ == rootP;
    }

private:
    int findParent(int n){
        return parent[n];
    }

    // 返回某个节点 x 的根节点
    int findRoot(int x){
        // 根节点的 parent[x] == x
        while (parent[x] != x){
            // 进行路径压缩
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }
};

算法的关键点有 3 个:

  • ⽤ parent 数组记录每个节点的⽗节点, 相当于指向⽗节点的指针, 所以 parent 数组内实际存储着⼀个森林(若⼲棵多叉树) 。
  • ⽤ weight数组记录着每棵树的重量, ⽬的是让unionElements后树依然拥有平衡性, ⽽不会退化成链表, 影响操作效率。
  • 在 findRoot函数中进⾏路径压缩, 保证任意树的⾼度保持在常数, 使得unionElements和 isConnected API 时间复杂度为 O(1)。

数组第二种实现(待完成)

我们通过一个数组来实现一个并查集,数组索引作为数据编号:
在这里插入图片描述

从上面的图可以知道:

  • 0、1、2、3、4属于一个集合,5、6、7、8、9属于一个集合。
  • 45 两个元素就不属于同一个集合(或者不相连),因为他们对应的编号不一样。4 对应的编号是 25对应的编号是4
  • 如果要合并两个集合(union(1,5)),因为15是属于两个不同的集合
  • 合并后,以前分别和元素 1 连接的元素;和 5 连接的元素,也都连接起来了:
    在这里插入图片描述
    根据上面的描述得知,基于上面实现方案的并查集,查询操作的时间复杂度为 O(1),合并操作的时间复杂度为 O(n)

map实现

class Node{
    int value;
public:
    explicit Node(int v) : value(v){

    }
};

class UnionFind{
   std::map<int, std::shared_ptr<Node>> nodes;
   std::map<std::shared_ptr<Node>, std::shared_ptr<Node>> parents;
   std::map<std::shared_ptr<Node>, int> sizeMap;

public:
    ~UnionFind(){
        for(auto i : nodes){
            
        }
    }
   UnionFind(const std::list<int>& values){
       for(auto v : values){
           auto node = std::make_shared<Node>(v);
           nodes[v] = node;
           parents[node] = node;
           sizeMap[node] = 1;
       }
   }

    // 给你一个节点,请你往上到不能再往上,把代表返回
    std::shared_ptr<Node> findRoot(std::shared_ptr<Node> cur){
        std::stack<std::shared_ptr<Node>> path;
        while (cur != parents[cur]){
            path.emplace(cur);
            cur = parents[cur];
        }
        
        while (!path.empty()){
            parents[path.top()] = cur;
        }
        return cur;
    }
    
    bool  isSameSet(int a, int b){
        return findRoot(nodes[a]) == findRoot(nodes[b]);
    }
    
    void unionEle(int a, int b){
        auto aRoot = findRoot(nodes[a]);
        auto bRoot = findRoot(nodes[b]);
        if(aRoot != bRoot){
            int aSetSize = sizeMap[aRoot];
            int bSetSize = sizeMap[bRoot];
            auto big = aSetSize >= bSetSize ? aRoot : bRoot;
            auto small = big == aRoot ? bRoot : aRoot;
            parents[small] = big;
            sizeMap[big] = aSetSize + bSetSize;
            sizeMap.erase(small);
        }
    }
    
    int sets(){
        return sizeMap.size();
    }
};

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值