Loading...

并查集(Union Find)

技术分享4年前 (2020)更新 技术分享
1,826 0 0

目录

并查集介绍

  我们之前讲的树结构,都是由父亲节点指向孩子节点,而并查集却是由孩子指向父亲的这样一种数据结构。
并查集(Union Find)
  给出图中任意的两点,问这两点之间是否可以通过一个路径连接起来?并查集就是处理这类连接问题的很好的数据结构。即用来处理网络中节点的连接状态,这里的网络是个抽象慨念,可以是用户之间形成 的网络。其实这类连接问题我们也可以使用集合类来进行实现,即求两个集合的并集。

本文设计的并查集主要支持两个操作:

  1. union(p,q) 并,对传入的两个数据p和q,在并查集内部将这两个数据,以及这两个数据所在的集合合并起来。
  2. isConnected(p,q)查询对于给定的两个数据,他们是否属于同一个集合。

由于并查集可以有不同的实现,我们可以设计一个并查集的接口:

public interface UF {

    int getSize();
    boolean isConnected(int p ,int q);
    void unionElements(int p,int q);

}

  对于具体元素是谁,我们并不关心,我们关心的是 id=p 和 id=q 这样的两个元素是否相连,而对于p和q对应具体的值,并不关心。
并查集(Union Find)

第一种实现

  表示每一个元素都属于不同的集合,没有任意两个元素是相连的

public class UnionFind01 implements UF {

    //我们第一种实现,Union-Find本质就是一个数组
    private int[] id;

    //有参构造:指定并查集中元素的个数
    public UnionFind01(int size){
        id = new int[size];
        //初始化, 每一个id[i]指向自己, 没有合并的元素
        for (int i=0; i<id.length; 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是否所属一个集合
    @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;
        }
    }
}

第二种实现

  我们的第二版Union-Find, 使用一个数组构建一棵指向父节点的树,如图:
并查集(Union Find)
  我们在初始化时,有10个元素,编号分别为0至9,即可以看成一棵只含有一个根节点的树结构,这个根节点的值即为索引值0至9,连接操作图如下:
并查集(Union Find)
代码实现如下:

public class UnionFind02 implements UF {

    //parent[i]表示第i个元素所指向的父节点
    private int[] parent;

    //构造函数
    public UnionFind02(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为树的高度
    public int find(int p){
        if (p < 0 || p >= parent.length)
            throw new IllegalArgumentException("p id 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;
    }
}

  现在让我们来简单测试下并查集的这两种实现方式的效率:

public class Test {

    //简单测试两种并查集实现方式的效率
    private static double testUF(UF uf, int m){
        int size = uf.getSize();
        Random random = new Random();

        long startTime = System.nanoTime();
        //测试连接操作
        for (int i = 0; i < m ; i ++){
            int a = random.nextInt(size);
            int b = random.nextInt(size);
            uf.unionElements(a,b);
        }

        //测试这两个元素是否连接
        for (int i = 0; i < m ; i ++){
            int a = random.nextInt(size);
            int b = random.nextInt(size);
            uf.isConnected(a,b);
        }

        long endTime = System.nanoTime();

        return (endTime - startTime) / 1000000000.0;
    }

    public static void main(String[] args) {
        int size = 100000;
        int m = 10000;
        //第一种实现
        UnionFind01 uf01 = new UnionFind01(size);
        System.out.println("UnionFind01 -> "+ testUF(uf01,m) +"s");
        //第二种实现
        UnionFind02 uf02 = new UnionFind02(size);
        System.out.println("UnionFind02 -> "+ testUF(uf02,m) +"s");

    }

}

测试代码运行结果:
并查集(Union Find)
通过测试代码的运行结果可以看出,我们的第二种实现方式是要比第一种实现方式 效率高一些的,现在我们看一个关于并查集的应用,就是力扣网上的547号练习题,题目描述如下:
并查集(Union Find)
现在就让我们基于并查集的第二种实现方式,来解决这个问题:

    public int findCircleNum(int[][] M) {

        int n = M.length;
        UnionFind2 uf = new UnionFind2(n);
        for(int i = 0 ; i < n ; i ++)
            for(int j = 0 ; j < i ; j ++)
                //如果M[i][j] = 1,则表示第 i 个和 j 个学生互为朋友,即属于同一个集合
                if(M[i][j] == 1)
                    uf.unionElements(i, j);

        TreeSet<Integer> set = new TreeSet<>();
        for(int i = 0 ; i < n ; i ++)
            set.add(uf.find(i));
        //获取不同集合的个数
        return set.size();
    }

并查集的优化方式

考虑size

  由于我们之前的实现方式,在进行连接操作时,总是将左边的元素指向右边的元素,这样很容易就会形成一个链表,这样的查询时间复杂度就会变成O(n),为了避免这样极端的情况,我们可以在每个节点中添加一个元素个数的变量,即该节点下连接的节点个数,这样在进行连接操作时,就将节点加到节点个数少的集合中去,如图:
并查集(Union Find)
并查集(Union Find)
代码实现如下:

public class UnionFind03 implements UF {

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

    public UnionFind03(int size){
        parent = new int[size];
        sz = new int[size];

        // 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
        for(int i = 0 ; i < size ; i ++){
            parent[i] = i;
            sz[i] = 1;
        }
    }

    @Override
    public int getSize() {
        return parent.length;
    }

    // 查找过程, 查找元素p所对应的集合编号
    // O(h)复杂度, h为树的高度
    public 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;

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

我们可以通过测试方法,对这三种实现方式进行简单的比较,我们会发现,优化size之后的并查集,效率比前两种是要好很多的。
并查集(Union Find)

基于rank的优化

  rank[i]表示根节点为i的树的高度,即根据两个元素所在的输的rank不同判断合并方向,将rank低的合并到rank高的集合上
并查集(Union Find)
代码实现如下:

public class UnionFind04 implements UF {
    private int[] rank;   // rank[i]表示以i为根的集合所表示的树的层数
    private int[] parent; // parent[i]表示第i个元素所指向的父节点

    // 构造函数
    public UnionFind04(int size){

        rank = new int[size];
        parent = new int[size];

        // 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
        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.");

        // 不断去查询自己的父亲节点, 直到到达根节点
        // 根节点的特点: 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;

        // 根据两个元素所在树的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的值
        }
    }
}

现在让我们对基于size的优化,和基于rank的优化进行简单的测试对比:
并查集(Union Find)

路径压缩优化

  为什么需要路径压缩优化呢?基于前面的四种实现方式,我们会发现下图中的三颗并查集数,无论是find()还是isConnected()都是等效的
并查集(Union Find)
  由于并查集的查找方法是和树得高度相关的,所以我们只要让树得高度降低,就都是对并查集的优化,理想情况下,我们树得高度为2,即只有根节点和叶子节点。但实际情况并不都是如此,所以我们只能尽可能降低树得高度,这就是我们的路径压缩优化。那我们的路径压缩是如何实现的呢?我们在查找某个节点的时候,让这个节点指向它父亲节点的父亲节点,然后判断这个节点是否是待查找节点的根节点,如不是,再使用待查找节点指向它父亲节点的父亲节点,如下图:
并查集(Union Find)
并查集(Union Find)
  根据图中分析,可以看出我们的路径压缩是在查找过程中实现的,所以我们修改查找方法即可:代码 实现如下:

    // 查找过程, 查找元素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]];
            p = parent[p];
        }
        return p;
    }
© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...