union-find 算法(并查集)

此博客用于个人学习,来源于算法的书籍,对知识点进行一个整理。

1. 概念:

union-find 有些地方也称为并查集,往往用于解决图上的问题,并查集只有两个操作,“并” 和 “查”,但是通过这两个操作可以派生出一些其他的应用:

  • 图的连通性问题
  • 集合的个数
  • 集合中元素的个数

图的连通性很好理解,一个图是不是连通的是指,“如果是连通图,那么从图上的任意节点出发,我们可以遍历到图上所有的节点”, 这里我们只需要将在图上的节点放到相同的集合中去,然后去看是不是所有的节点均指向同一个集合即可。

2. 并查集的API:

对于一组数据,并查集主要需要实现下面几个功能:

  • void unionElements(int p,int q):在p和q之间添加一条连接;
  • int find(int p):p(0到N-1)所在的分量的标识符;
  • boolean isConnected(int p,int q):如果p和q存在于同一个分量中则返回 true;
  • int getSize():;连通分量的数量。

3.分析:

一开始,我们有 N 个分量,每个触点都构成了触点 i,我们讲 find() 方法用来判定它所在的分量所需的信息保存在 id[i] 中。isConnected() 方法的实现只用一条语句 find(p) == find(q),它返回一个布尔值。目前我们的代码如下:

// 我们的第一版Union-Find
public class UnionFind1 implements UF {

    private int[] id;    // 我们的第一版Union-Find本质就是一个数组

    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) {
        //还没实现,后面进行讨论
    }

    // 查看元素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) {
		//还没实现,后面进行讨论
    }
}

注意此时的构造方法,初始化, 每一个id[i]指向自己, 并没有合并的元素

接下来分析 find 方法,目的是从节点 p 中查到对应的集合编号,我们的第一版并查集本质上是一个数组,这个时候可以返回一个 id[i] ,便是我们要找的编号,注意要进行判断,如果 p 本身不属于这个集合,抛出异常提示。

// 查找元素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 属于 “同一类”,即他们所代表的编号相同,可以先通过 find 方法获得各自对应的编号,如果相同就直接 return,如果不同,就需要对整个数组进行遍历,编号是 p 对应编号的,都要将编号改成 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;
}

但是此时存在一个问题,每次合并的时候都要遍历一遍数组,一旦数据较多,所需要的时间也是一个难以计量的量,这个时候需要对该算法进行改进。

4. 改进一(树状结构):

我们的第二版 Union-Find, 使用一个数组构建一棵指向父节点的树,定义为 parent[],初始化与第一版相同,都是指向自己。

// 我们的第二版Union-Find
public class UnionFind2 implements UF {

    // 我们的第二版Union-Find, 使用一个数组构建一棵指向父节点的树
    // 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){
       //还没实现,后面进行讨论
    }

    // 查看元素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){
		//还没实现,后面进行讨论
    }
}

可以看出,目前的方法和第一版几乎一致,这个时候我们需要对 find 方法和 unionElements 方法进行讨论。与第一版不同的是,第一版的思路是合并过程中,p 对应的编号改成 q 对应的编号,而改进的话,是将这个过程类比成树的结构,下端元素指向的编号是上一端的元素,也就是父节点,即 parent[p] = p 的上一个元素,这个时候,我们就可以修改我们的 find 方法,通过循环,一层一层找到最终的根节点,也就是没有父亲的那个节点对应的编号。(根节点的父亲节点就是他本身,即 parent[i] = i)

// 查找过程, 查找元素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;
}

此时,find 的作用变成了查找到元素的根节点,于是我们在 unionElements 中调用 find 方法,可以得到 p 和 q 的根节点,如果不相同,就将 p 的父亲节点改为 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;
}

但存在一种极端情况,因为我们是直接将 p 的父亲节点改为 q 的根节点,假如 p 的节点元素很多,然后接到 q 的根节点上,这个时候就可能造成链式的结构,这个时候如果的效率并没有提高,于是我们需要再次改进,目的——将元素数目小的节点接到数目大的节点上

5. 改进二(考虑数量):

此时,添加一个数组 size[],用于统计以 i 为根节点的集合元素数量,于是此时代码变成了:

// 我们的第三版Union-Find
public class UnionFind3 implements UF{

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

    // 构造函数
    public UnionFind3(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为树的高度
    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){
		//还没实现,后面进行讨论
    }
}

除了构造函数和接下来要讨论的合并函数,其他的与上一版区别并不大,先分析构造方法:一开始每个节点都是指向自己本身,所以每个节点对应的 size 自然也是1。对于合并函数,主要修改的是后面的逻辑:如果 p 的集合元素数量小于 q 的集合元素数量,此时将 p 接入 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];
    }
}

但这个时候出现了新的问题,如果此时 p 是一棵元素多但比较 “矮” 的树,而 q 是元素少但比较 “高” 的树,就不太适合上面这种实现方式,于是我们再次改进。

6. 改进三(考虑层数):

与第三版的思路类似,我们需要构建一个数组 rank[] 用于存储树的层数信息,同样的,一开始的时候,所有元素的层数都为1,于是构造方法一开始需要对 rank[] 每个元素赋值为1。

以相同逻辑修改合并函数:当 p 所在的树的层数小于 q 的时候,p 接入 q,反之亦然,但如果两者的层数相同,默认 p 接入 q。这个时候需要考虑到 rank 的值,如果不相等,即层数低的树接入高的树,接入后层数不会发生改变,但如果两者层数相同,则 q 的层数加一(因为默认 p 接入 q)。

// 我们的第四版Union-Find
public class UnionFind4 implements UF {

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

    // 构造函数
    public UnionFind4(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的值
        }
    }
}

find() 函数在最差情况下的时间复杂度为 O(n),即所有元素的子节点只有一个,类似于链表的形状,这个时候我们可以将这棵树进行一个压缩操作,将其层数降低,提高时间效率。

7. 改进四(压缩路径):

相比于第四版,我们需要修改的是 find 函数,即在循环的过程中,使子元素的指向更高的节点,层数降低,即

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

观察这句语句,p 的父亲变成了自己的爷爷,从而与自己的父亲 “平辈”,此时层数就少了。

但此时,可能会产生一个疑问:需不需要修改层数的值?怎么修改?答案是不需要,一是我们不清楚压缩了几层,我们无法进行修改,二是修改层数在合并函数内的逻辑,而我们的 find 方法先进行调用,在后面的判断中才会增加或者不对层数进行操作,与 find 无关了。

// 我们的第五版Union-Find
public class UnionFind5 implements UF {

    // rank[i]表示以i为根的集合所表示的树的层数
    // 在后续的代码中, 我们并不会维护rank的语意, 也就是rank的值在路径压缩的过程中, 有可能不在是树的层数值
    // 这也是我们的rank不叫height或者depth的原因, 他只是作为比较的一个标准
    private int[] rank;
    private int[] parent; // parent[i]表示第i个元素所指向的父节点

    // 构造函数
    public UnionFind5(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.");

        while( p != parent[p] ){
            parent[p] = parent[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的值
        }
    }
}

同样的,也可以采用递归的方法进行改进:

// 我们的第六版Union-Find
public class UnionFind6 implements UF {

    // rank[i]表示以i为根的集合所表示的树的层数
    // 在后续的代码中, 我们并不会维护rank的语意, 也就是rank的值在路径压缩的过程中, 有可能不在是树的层数值
    // 这也是我们的rank不叫height或者depth的原因, 他只是作为比较的一个标准
    private int[] rank;
    private int[] parent; // parent[i]表示第i个元素所指向的父节点

    // 构造函数
    public UnionFind6(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.");

        // path compression 2, 递归算法
        if(p != parent[p])
            parent[p] = find(parent[p]);
        return parent[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的值
        }
    }
}

8. 并查集的局限:

并查集的合并操作是不可逆的,你可以理解成只合不分,也就是说两个集合合并之后就不会再分开来了,另外并查集只会保存并维护集合和元素的关系,至于元素之间的关系,比如图上节点与节点的边,这种信息并查集是不会维护的,如果遇到题目让你分析诸如此类的问题,那么并查集并不是一个好的出发点,你可能需要往其他的算法上去考虑。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值