实现一个解决连接问题的并查集

304 阅读4分钟

1 定义

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

网络是个抽象的概念:

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

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

主要支持两个动作:

// 将两个数据合并起来
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

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

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

这样设计的并查集查询操作的时间复杂度为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所在的组合并

3 Quick Union

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

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

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

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

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

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

对应数组里的

对应数组里的

对应数组中的

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

时间复杂度:

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)...则会退化成一个链表,一个简单的解决方案是考虑当前的树有多少个节点。

假设并查集如下:

如果让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指树的高度。

对于下面这个例子:

基于上面基于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 路径压缩

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

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

路径压缩:

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

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

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

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

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

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

执行完毕之后树的深度从原先的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;
        }
    }
}