并查集——解决连接问题的神器

简介: 并查集是一种很不一样的树形结构,由子节点指向父节点。常被用来回答连接问题(Connectivity Problem),比如下图中求左上角的点和右下角的点是否相连,我们难以用肉眼观察出结果,需要借助这样的数据结构来帮助我们解决。

1 定义


并查集是一种很不一样的树形结构,由子节点指向父节点。常被用来回答连接问题(Connectivity Problem),比如下图中求左上角的点和右下角的点是否相连,我们难以用肉眼观察出结果,需要借助这样的数据结构来帮助我们解决。


20200918163656572.png



网络是个抽象的概念:


社交软件的用户,购物网站的商品信息,交通系统等等都可以抽象成网络中的节点,所以实际中的很多问题都可以借助这个强大的数据结构来解决。


除了解决连接问题,并查集还是数学中集合这一概念的具体实现,并查集的“并”字其实也是数学集合求并集的概念。


主要支持两个动作:


// 将两个数据合并起来

union(p,q);


// 判断p,q是否相连

isConnected(p,q);


程序框架(接口)如下:


public interface UF {
    int getSize();
    boolean isConnected(int p, int q);
    void unionElements(int p, int q);
}


其中p和q表示id,即数组的索引




2 Quick Find实现


在并查集内部,可以为每一个数据索引 p 或 q 分配一个编号id


20200918164228722.png



判断p,q是否属于同一个集合只需查看对应的id是否相同即可。


我们可以定义一个函数 find(p) 来寻找p所对应的分组,相应的isConnected方法会调用find


image.png


这样设计的并查集查询操作的时间复杂度为O(1),所以被称为quick find


public class UnionFind1 implements UF {
    private int[] id;    // 本质就是一个数组
    public UnionFind1(int size) {
        id = new int[size];
        // 初始化, 每一个id[i]指向自己, 没有合并的元素
        for (int i = 0; i < size; i++)
            id[i] = i;
    }
    @Override
    public int getSize(){
        return id.length;
    }
    // 查找元素p所对应的集合编号
    // O(1)复杂度
    private int find(int p) {
        if(p < 0 || p >= id.length)
            throw new IllegalArgumentException("p is out of bound.");
        return id[p];
    }
    // 查看元素p和元素q是否所属一个集合
    // O(1)复杂度
    @Override
    public boolean isConnected(int p, int q) {
  return find(p) == find(q);
    }
    // 合并元素p和元素q所属的集合
    // O(n) 复杂度
    @Override
    public void unionElements(int p, int q) {
        int pID = find(p);
        int qID = find(q);
        if (pID == qID)
            return;
        // 合并过程需要遍历一遍所有元素, 将两个元素的所属集合编号合并
        for (int i = 0; i < id.length; i++)
            if (id[i] == pID)
                id[i] = qID;
    }
}

其中合并方法 unionElements(int p, int q) 需要单独解释一下,还是上面的例子,如果此时要合并1和4,那么意味着1所在的组和4所在的组合并


20200918164357273.png

20200918164410954.png


3 Quick Union


不过我们真实实现并查集时,是将每一个元素看作是一个节点,节点之间形成树,只是这棵树是子节点指向父节点的。

例如一个以2为根节点的树:

20200918164514186.png


如果此时另外的节点1要和3进行合并,需要将节点1指向3的根节点


20200918164553700.png


如果有另外一棵以5为根的树,其中节点7要和节点3合并,那么结果是将节点5指向节点2


image.png


在这种情况下我们依然可以用数组来进行存储,因为每一个节点只有一个指针指向别人。定义一个parent数组,parent[i]的值表示i指向的节点。初始形状如下:


20200918164712490.png


此时如果要进行union(4,3)操作


20200918164712490.png


对应数组里的


20200918164758501.png

对应数组里的


20200918164825127.png


对应数组中的


20200918164848653.png


再进行union(9, 4)时,需要进行查找,4的父节点是3,3的父节点是8,8是根节点所以9指向8

时间复杂度:


20200918164908125.png


union过程:O(h),其中h为树的高度,h << n。相应的查询过程的时间复杂度也是O(h)。


public class UnionFind2 implements UF {
    // 使用一个数组构建一棵指向父节点的树
    // parent[i]表示第一个元素所指向的父节点
    private int[] parent;
    // 构造函数
    public UnionFind2(int size){
        parent = new int[size];
        // 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
        for( int i = 0 ; i < size ; i ++ )
            parent[i] = i;
    }
    @Override
    public int getSize(){
        return parent.length;
    }
    // 查找过程, 查找元素p所对应的集合编号
    // O(h)复杂度, h为树的高度
    private int find(int p){
        if(p < 0 || p >= parent.length)
            throw new IllegalArgumentException("p is out of bound.");
        // 不断去查询自己的父亲节点, 直到到达根节点
        // 根节点的特点: parent[p] == p
        while(p != parent[p])
            p = parent[p];
return p;
    }
    // 查看元素p和元素q是否所属一个集合
    // O(h)复杂度, h为树的高度
    @Override
    public boolean isConnected( int p , int q ){
        return find(p) == find(q);
    }
    // 合并元素p和元素q所属的集合
    // O(h)复杂度, h为树的高度
    @Override
    public void unionElements(int p, int q){
        int pRoot = find(p);
        int qRoot = find(q);
        if( pRoot == qRoot )
            return;
        parent[pRoot] = qRoot;
    }
}

之后的代码都基于此来进行优化。



4 基于size的优化


上面的例子中,如果依次执行union(0, 1),union(0, 2),union(0, 3),union(0, 4)...则会退化成一个链表,一个简单的解决方案是考虑当前的树有多少个节点。


假设并查集如下:


20200918165049548.png


如果让8指向9的话,最终会得到一颗深度为4的树,但如果让9指向8只会得到一颗高度为3的树。

定义一个sz数组用于记录以i为根的集合中元素个数,union是将sz小的集合的根节点指向sz大的集合的根节点。


核心代码:


public 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{ // sz[qRoot] <= sz[pRoot]
            parent[qRoot] = pRoot;
            sz[pRoot] += sz[qRoot];
        }
}


5 基于rank的优化


rank指树的高度。

对于下面这个例子:


20200918165150770.png


基于上面基于size优化的代码,节点数小的树指向节点数大的树,union(4, 2)将使得8指向7,将形成一颗高度为4的树,更加合理的合并方案是将7指向8,深度为3。


所以我们在真正合并时应该用深度代替size来决定谁指向谁。

核心代码:


public void unionElements(int p, int q){
        int pRoot = find(p);
        int qRoot = find(q);
        if( pRoot == qRoot )
            return;
        // 根据两个元素所在树的rank不同判断合并方向
        // 将rank低的集合合并到rank高的集合上
        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的值
        }
}


6 路径压缩


上面的代码还是有可能出现退化成链表的情况


20200918165328411.png


由于树的高度决定并查集的性能,所以可以考虑使用路径压缩来尽力降低树的高度。



路径压缩:


讲一课比较高的树变为比较矮的树。



可以将路径压缩过程设计成执行find操作的时候,在寻找的过程中顺便执行路径压缩。路径压缩可以采用如下的方式:



parent[p] = parent[parent[p]];

即将p的父节点设置为其爷爷节点。


在find(4)时,将4的父节点设为2:


20200918165417739.png


继续向上查找,2的父节点变为0


20200918165445960.png


执行完毕之后树的深度从原先的5降低至现在的3。


核心代码:


private int find(int p){
        if (p < 0 || p >= parent.length)
            throw new IllegalArgumentException("p is out of bound");
        while (p != parent[p]){
            parent[p] = parent[parent[p]];  // 路径压缩,添加后rank不再表示树的高度的物理意义,但是依然有效
            p = parent[p];
        }
        return p;
    }


至此我们对于并查集的优化完全结束。


完整代码:


/*
quick union
基于rank(树的层数)的优化
路径压缩
 */
public class UnionFind implements UF{
    private int[] parent;
    private int[] rank;   // sz[i]表示以i为根的集合中元素个数
    public UnionFind(int size){
        parent = new int[size];
        rank = new int[size];
        for (int i = 0; i < size; i ++){
            parent[i] = i;
            rank[i] = 1;
        }
    }
    @Override
    public int getSize(){
        return parent.length;
    }
    // 查找过程,查找p对应的集合编号
    // O(h),h为树高度
    private int find(int p){
        if (p < 0 || p >= parent.length)
            throw new IllegalArgumentException("p is out of bound");
  while (p != parent[p]){
            parent[p] = parent[parent[p]];  // 路径压缩,添加后rank不再表示树的高度的物理意义,但是依然有效
            p = parent[p];
        }
        return p;
    }
    @Override
    public boolean isConnected(int p, int q){
        return find(p) == find(q);
    }
    // 合并过程,合并pq所属的集合
    // O(h),h为树高度
    @Override
    public void unionElements(int p, int q){
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot)
            return;
        // 让rank低的树的根节点指向rank高的树的根节点
        if (rank[pRoot] < rank[qRoot]){
            parent[pRoot] = qRoot;
            // 高度低的树的根指向高度高的树的根,总的高度不变不用维护rank
        }
        else if (rank[qRoot] < rank[pRoot]){
 parent[qRoot] = pRoot;
        }
        else {  // rank[qRoot] == rank[pRoot]
            parent[qRoot] = pRoot;
            rank[pRoot] += 1;
        }
    }
}
相关文章
|
7月前
|
存储 缓存 算法
【数据结构与算法】【小白也能学的数据结构与算法】递归 分治 迭代 动态规划 无从下手?一文通!!!
【数据结构与算法】【小白也能学的数据结构与算法】递归 分治 迭代 动态规划 无从下手?一文通!!!
|
4月前
|
算法 Python
【Leetcode刷题Python】455.分发饼干
文章提供了解决LeetCode "分发饼干" 问题的Python实现代码,使用了排序和贪心算法。首先将孩子的胃口值和饼干的尺寸分别进行升序排序,然后通过双指针的方式遍历,尝试为每个孩子分配尺寸足够大的饼干,直到所有的孩子都得到满足或饼干分配完毕,返回满足的孩子数量的最大值。
22 2
|
5月前
|
算法 Python
深度剖析!Python中图的DFS与BFS遍历,让你的数据搜索快到飞起
【7月更文挑战第10天】在数据结构和算法中,图遍历是核心概念,Python支持DFS和BFS来探索图。DFS递归深入节点,利用栈,先访问深处;BFS使用队列,层次遍历,先访问最近节点。
152 1
|
6月前
|
C++
【洛谷 P1618】三连击(升级版)题解(深度优先搜索+位集合)
`三连击(升级版)` 是一道编程题,要求将数字 $1$ 到 $9$ 分成三组,构成三个三位数,其比例为 $A:B:C$。给定 $A$, $B$, $C$,程序应找到所有可能的组合并按首位升序输出。输入为 $A$, $B$, $C$,输出是满足比例的三位数或&quot;No!!!&quot;(当无解时)。解决方案涉及全排列搜索和比例验证。提供的AC代码使用C++,通过位集记录数字使用情况,递归实现全排列。
75 0
|
存储 算法 Java
Java数据结构与算法分析(十)B树图文详解(含完整代码)
迄今为止,已经介绍了《 二叉查找树 》和《 AVL树 》,我们始终假设可以把整个数据结构存储在内存中。可是,如果数据多到内存装不下,这就意味着必须把数据放在磁盘上,显然这些数据结构不再适用。 问题在于磁盘的I/O速度是远远不如内存访问速度的,然而从一棵树中查找到某个元素,必须从根节点一层层往下找,这每一次查找便是一次I/O操作。为了提高性能,就必须要减少查找的次数。 如能减少树的高度、增加每个节点中的元素数,便是种有效的解决方案。实现这种想法的一种方法是使用B树。
174 1
|
存储
二叉树的基本操作(C 语言版)
二叉树的基本操作(C 语言版)
53 1
|
存储 算法
学会二叉树不知道干啥?二叉树的深度优先搜索和广度优先搜索,我要打十个乃至二十个(打开你的LeetCode撸起来)学练并举
学会二叉树不知道干啥?二叉树的深度优先搜索和广度优先搜索,我要打十个乃至二十个(打开你的LeetCode撸起来)学练并举
学会二叉树不知道干啥?二叉树的深度优先搜索和广度优先搜索,我要打十个乃至二十个(打开你的LeetCode撸起来)学练并举
|
算法 索引
【切图仔的算法修炼之旅】LeetCode141:判断链表是否有环
【切图仔的算法修炼之旅】LeetCode141:判断链表是否有环
109 0
【切图仔的算法修炼之旅】LeetCode141:判断链表是否有环
|
存储 算法
【算法刷题】—7.10单调栈神器
✨今日算法三题 1.下一个更大元素I 2.每日温度 3.下一个更大元素II
【算法刷题】—7.10单调栈神器
|
人工智能 BI
几分钟搞懂并查集
本文主要讲解并查集
133 0
几分钟搞懂并查集