数据结构——并查集

Posted 活跃的咸鱼

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构——并查集相关的知识,希望对你有一定的参考价值。

畅通工程

某省调查城镇交通状况,得到现有城镇道路统计表,表中列出了每条道路直接连通的城镇。省政府“畅通工程”的目标是使全省任何两个城镇间都可以实现交通(但不一定有直接的道路相连,只要互相间接通过道路可达即可)。问最少还需要建设多少条道路?
在我们的测试数据文件夹中有一个trffic_project.txt文件,它就是诚征道路统计表,下面是对数据的解释:
在这里插入图片描述
总共有20个城市,目前已经修改好了7条道路,问还需要修建多少条道路,才能让这20个城市之间全部相通?

我们先不对本题进行解答,当我们对并查集了解之后便可以解决该问题。

并查集

并查集是一种树型的数据结构 ,正如其名一样并查集用于处理一些不相交集合的合并及查询问题,并查集可以高效地进行如下操作:

查询元素p和元素q是否属于同一组

合并元素p和元素q所在的组

在这里插入图片描述

判断一个森林中有几颗树

某个节点是否属于某棵树

并查集结构

并查集也是一种树型结构,但这棵树和二叉树、红黑树、B树等都不一样,这种树的要求比较简单:

  1. 每个元素都唯一的对应一个结点;
  2. 每一组数据中的多个元素都在同一颗树中;
  3. 一个组中的数据对应的树和另外一个组中的数据对应的树之间没有任何联系;
  4. 元素在树中并没有子父级关系的硬性要求;
    在这里插入图片描述

并查集(UF类)API设计
在这里插入图片描述

并查集是通过数组来实现的数组的索引代表一个结点所存储的元素,索引的值代表结点所在的组标识符(也就是组号)

在这里插入图片描述

1).UF(int N)构造方法实现

  1. 初始情况下,每个元素都在一个独立的分组中,所以,初始情况下,并查集中的数据默认分为N个组;
  2. 初始化数组eleAndGroup;
  3. 把eleAndGroup数组的索引看做是每个结点存储的元素,把eleAndGroup数组每个索引处的值看做是该结点所在的分组,那么初始化情况下,i索引处存储的值就是i
 public UF(int N){
        //初始化分组的数量,默认情况下,有N个分组
        this.count = N;
        
        //初始化eleAndGroup数组
        this.eleAndGroup = new int[N];

        //初始化eleAndGroup中的元素及其所在的组的标识符,
        //让eleAndGroup数组的索引作为并查集的每个结点的元素,
        //并且让每个索引处的值(该元素所在的组的标识符)就是该索引

        for (int i = 0; i < eleAndGroup.length; i++) {
            eleAndGroup[i] = i;
        }

    }

2). public int count():获取当前并查集中的数据有多少个分组

    public int count(){
        return count;
    }

3). public boolean connected(int p,int q):判断并查集中元素p和元素q是否在同一分组中

    public boolean connected(int p,int q){
        return find(p) == find(q);
    }

4). public int find(int p):元素p所在分组的组号

  public int find(int p){
        return eleAndGroup[p];
    }

5). public void union(int p,int q):把p元素所在分组和q元素所在分组合并

如图所示:即将p所在的组中的所有元素的组号都归到q元素所在的分组

  1. 如果p和q已经在同一个分组中,则无需合并
  2. 如果p和q不在同一个分组,则只需要将p元素所在组的所有的元素的组标识符修改为q元素所在组的标识符即可
  3. 分组数量-1
    在这里插入图片描述
 public void union(int p,int q){
        //判断元素q和p是否已经在同一分组中,如果已经在同一分组中,则结束方法就可以了
        if (connected(p,q)){
            return;
        }

        //找到p所在分组的标识符
        int pGroup = find(p);

        //找到q所在分组的标识符
        int qGroup = find(q);

        //合并组:让p所在组的所有元素的组标识符变为q所在分组的标识符
        for (int i = 0; i < eleAndGroup.length; i++) {
            if (eleAndGroup[i]==pGroup){
                eleAndGroup[i] = qGroup;
            }
        }

        //分组个数-1
        this.count--;

    }

完整实现及测试

public class UF {
    //记录结点元素和该元素所在分组的标识
    private int[] eleAndGroup;
    //记录并查集中数据的分组个数
    private int count;
    //初始化并查集
    public UF(int N){
        //初始化分组的数量,默认情况下,有N个分组
        this.count = N;



        //初始化eleAndGroup数组
        this.eleAndGroup = new int[N];

        //初始化eleAndGroup中的元素及其所在的组的标识符,让eleAndGroup数组的索引作为并查集的每个结点的元素,并且让每个索引处的值(该元素所在的组的标识符)就是该索引

        for (int i = 0; i < eleAndGroup.length; i++) {
            eleAndGroup[i] = i;
        }

    }

    //获取当前并查集中的数据有多少个分组
    public int count(){
        return count;
    }

    //元素p所在分组的标识符
    public int find(int p){
        return eleAndGroup[p];
    }

    //判断并查集中元素p和元素q是否在同一分组中
    public boolean connected(int p,int q){
        return find(p) == find(q);
    }

    //把p元素所在分组和q元素所在分组合并
    public void union(int p,int q){
        //判断元素q和p是否已经在同一分组中,如果已经在同一分组中,则结束方法就可以了
        if (connected(p,q)){
            return;
        }

        //找到p所在分组的标识符
        int pGroup = find(p);

        //找到q所在分组的标识符
        int qGroup = find(q);

        //合并组:让p所在组的所有元素的组标识符变为q所在分组的标识符
        for (int i = 0; i < eleAndGroup.length; i++) {
            if (eleAndGroup[i]==pGroup){
                eleAndGroup[i] = qGroup;
            }
        }

        //分组个数-1
        this.count--;

    }

}

public class UFTest {

    public static void main(String[] args) {

        //创建并查集对象
        UF uf = new UF(5);
        System.out.println("默认情况下,并查集中有:"+uf.count()+"个分组");

        //从控制台录入两个要合并的元素,调用union方法合并,观察合并后并查集中的分组是否减少
        Scanner sc = new Scanner(System.in);

        while(true){
            System.out.println("请输入第一个要合并的元素:");
            int p = sc.nextInt();
            System.out.println("请输入第二个要合并的元素:");
            int q = sc.nextInt();

            //判断这两个元素是否已经在同一组了
            if (uf.connected(p,q)){
                System.out.println(p+"元素和"+q+"元素已经在同一个组中了");
                continue;
            }

            uf.union(p,q);
            System.out.println("当前并查集中还有:"+uf.count()+"个分组");

        }


    }
}
测试结果:

默认情况下,并查集中有:5个分组
请输入第一个要合并的元素:
0
请输入第二个要合并的元素:
1
当前并查集中还有:4个分组
请输入第一个要合并的元素:
1
请输入第二个要合并的元素:
2
当前并查集中还有:3个分组
请输入第一个要合并的元素:
2
请输入第二个要合并的元素:
3
当前并查集中还有:2个分组
请输入第一个要合并的元素:
3
请输入第二个要合并的元素:
4
当前并查集中还有:1个分组
请输入第一个要合并的元素:
4
请输入第二个要合并的元素:
1
4元素和1元素已经在同一个组中了
请输入第一个要合并的元素:

并查集应用举例

如果我们并查集存储的每一个整数表示的是一个大型计算机网络中的计算机,则我们就可以通过connected(int p,int q)来检测,该网络中的某两台计算机之间是否连通?如果连通,则他们之间可以通信,如果不连通,则不能通信,此时我们又可以调用union(int p,int q)使得p和q之间连通,这样两台计算机之间就可以通信了。

一般像计算机这样网络型的数据,我们要求网络中的每两个数据之间都是相连通的,也就是说,我们需要调用很多次union方法,使得网络中所有数据相连,其实我们很容易可以得出,如果要让网络中的数据都相连,则我们至少要调用N-1次union方法才可以,但由于我们的union方法中使用for循环遍历了所有的元素,所以很明显,我们之前实现的合并算法的时间复杂度是O(N^2),如果要解决大规模问题,它是不合适的,所以我们需要对算法进行优化。

为了提升union算法的性能,我们需要重新设计find方法和union方法的实现,此时我们先需要对我们的之前数据结构中的eleAndGourp数组的含义进行重新设定:

  1. 我们仍然让eleAndGroup数组的索引作为某个结点的元素;
  2. eleAndGroup[i]的值不再是当前结点所在的分组标识,而是该结点的父结点;

在这里插入图片描述
find(int p)查询优化方法实现

  1. 判断当前元素p的父结点eleAndGroup[p]是不是自己,如果是自己则证明已经是根结点了;
  2. 如果当前元素p的父结点不是自己,则让p=eleAndGroup[p],继续找父结点的父结点,直到找到根结点为止;
    在这里插入图片描述
    //元素p所在分组的标识符
    public int find(int p){
        while(true){

            if (p == eleAndGroup[p]){
                return p;
            }

            p = eleAndGroup[p];
        }

    }

union(int p,int q)合并优化方法实现

  1. 找到p元素所在树的根结点
  2. 找到q元素所在树的根结点
  3. 如果p和q已经在同一个树中,则无需合并;
  4. 如果p和q不在同一个分组,则只需要将p元素所在树根结点的父结点设置为q元素的根结点即可;
  5. 分组数量-1
    在这里插入图片描述

//我们要判断元素是不是在一个分组中(即是否在同一棵树中我们只需要找到其根结点若根结点相同则在同一个分组中)
  //把p元素所在分组和q元素所在分组合并
    public void union(int p,int q){
        //找到p元素和q元素所在组对应的树的根结点

        int pRoot = find(p);
        int qRoot = find(q);

        //如果p和q已经在同一分组,则不需要合并了
        if (pRoot==qRoot){
            return;
        }

        //让p所在的树的根结点的父结点为q所在树的根结点即可
        eleAndGroup[pRoot] = qRoot;

        //组的数量-1

        this.count--;

    }

完整实现及测试

public class UF_Tree {
    //记录结点元素和该元素所在分组的标识
    private int[] eleAndGroup;
    //记录并查集中数据的分组个数
    private int count;
    //初始化并查集
    public UF_Tree(int N){
        //初始化分组的数量,默认情况下,有N个分组
        this.count = N;
        //初始化eleAndGroup数组
        this.eleAndGroup = new int[N];

        //初始化eleAndGroup中的元素及其所在的组的标识符,让eleAndGroup数组的索引作为并查集的每个结点的元素,并且让每个索引处的值(该元素所在的组的标识符)就是该索引

        for (int i = 0; i < eleAndGroup.length; i++) {
            eleAndGroup[i] = i;
        }

    }

    //获取当前并查集中的数据有多少个分组
    public int count(){
        return count;
    }

    //判断并查集中元素p和元素q是否在同一分组中
    public boolean connected(int p,int q){
        return find(p) == find(q);
    }

    //元素p所在分组的标识符
    public int find(int p){
        while(true){

            if (p == eleAndGroup[p]){
                return p;
            }

            p = eleAndGroup[p];
        }

    }

    //把p元素所在分组和q元素所在分组合并
    public void union(int p,int q){
        //找到p元素和q元素所在组对应的树的根结点

        int pRoot = find(p);
        int qRoot = find(q);

        //如果p和q已经在同一分组,则不需要合并了
        if (pRoot==qRoot){
            return;
        }

        //让p所在的树的根结点的父结点为q所在树的根结点即可
        eleAndGroup[pRoot] = qRoot;

        //组的数量-1

        this.count--;

    }

}

public class UFTeeTest {

    public static void main(String[] args) {

        //创建并查集对象
        UF_Tree uf = new UF_Tree(5);
        System.out.println("默认情况下,并查集中有:"+uf.count()+"个分组");

        //从控制台录入两个要合并的元素,调用union方法合并,观察合并后并查集中的分组是否减少
        Scanner sc = new Scanner(System.in);

        while(true){
            System.out.println("请输入第一个要合并的元素:");
            int p = sc.nextInt();
            System.out.println("请输入第二个要合并的元素:");
            int q = sc.nextInt();

            //判断这两个元素是否已经在同一组了
            if (uf.connected(p,q)){
                System.out.println(p+"元素和"+q+"元素已经在同一个组中了");
                continue;
            }

            uf.union(p,q);
            System.out.println("当前并查集中还有:"+uf.count()+"个分组");

        }


    }
}
测试结果:

默认情况下,并查集中有:5个分组
请输入第一个要合并的元素:
0
请输入第二个要合并的元素:
1
当前并查集中还有:4个分组
请输入第一个要合并的元素:
1
请输入第二个要合并的元素:
2
当前并查集中还有:3个分组
请输入第一个要合并的元素:
2
请输入第二个要合并的元素:
3
当前并查集中还有:2个分组
请输入第一个要合并的元素:
3
请输入第二个要合并的元素:
4
当前并查集中还有:1个分组
请输入第一个要合并的元素:
4
请输入第二个要合并的元素:
1
4元素和1元素已经在同一个组中了

优化后的性能分析

我们优化后的算法union,如果要把并查集中所有的数据连通,仍然至少要调用N-1次union方法,但是,我们发现union方法中已经没有了for循环,所以union算法的时间复杂度由O(N^2)变为了O(N)。
但是这个算法仍然有问题,因为我们之前不仅修改了union算法,还修改了find算法。我们修改前的find算法的时间复杂度在任何情况下都为O(1),但修改后的find算法在最坏情况下是O(N):
在这里插入图片描述
在union方法中调用了find方法,所以在最坏情况下union算法的时间复杂度仍然为O(N^2)。上面的树是线性的,查找速度依旧很慢,解决办法就是将线性树转换成非线性的。

路径压缩

UF_Tree中最坏情况下union算法的时间复杂度为O(N^2),其最主要的问题在于最坏情况下,树的深度和数组的大小一样,如果我们能够通过一些算法让合并时,生成的树的深度尽可能的小,就可以优化find方法。

结合如下图分析:之前我们在union算法中,合并树的时候将任意的一棵树连接到了另外一棵树,这种合并方法是比较暴力的,如果我们把并查集中每一棵树的大小记录下来,然后在每次合并树的时候,把较小的树连接到较大的树上,就可以减小树的深度。
在这里插入图片描述
只要我们保证每次合并,都能把小树合并到大树上,就能够压缩合并后新树的路径,这样就能提高find方法的效率。为了完成这个需求,我们需要另外一个数组来记录存储每个根结点对应的树中元素的个数,并且需要一些代码调整数组中的值。

UF_Tree_Weighted API设计

和之前的UF_Tree整体差不多对个别进行修改增加了一个int[] sz数组: 存储每个根结点对应的树中元素的个数,这样我们在合并的时候就可以保证每次合并,都能把小树合并到大树上。
在这里插入图片描述
代码实现如下

public class UF_Tree_Weighted {
    //记录结点元素和该元素所在分组的标识
    private int[] eleAndGroup;
    //记录并查集中数据的分组个数
    private int count;


    //用来存储每一个根结点对应的树中保存的结点的个数
    private int[] sz;
    //初始化并查集
    public UF_Tree_Weighted(int N){
        //初始化分组的数量,默认情况下,有N个分组
        this.count = N;
        //初始化eleAndGroup数组
        this.eleAndGroup = new int[N];

        //初始化eleAndGroup中的元素及其所在的组的标识符,
        //让eleAndGroup数组的索引作为并查集的每个结点的元素,
        //并且让每个索引处的值(该元素所在的组的标识符)就是该索引

        for (int i = 0; i < eleAndGroup.length; i++) {
            eleAndGroup[i] = i;
        }

        this.sz = new int[N];
        //默认情况下,sz中每个索引处的值都是1
        for (int i = 0; i < sz.length; i++) {
            sz[i] = 1;
        }
        
    }

    //获取当前并查集中的数据有多少个分组
    public int count(){
        return count;
    }

    //判断并查集中元素p和元素q是否在同一分组中
    public boolean connected(int p,int q){
        return find(p) == find(q);
    }

    //元素p所在分组的标识符
    public int find(int p){
        while(true){

            if (p == eleAndGroup[p]){
                return p;
            }

            p = eleAndGroup[p];
        }

    }

    //把p元素所在分组和q元素所在分组合并
    public void union(int p,int q){
        //找到p元素和q元素所在组对应的树的根结点

        int pRoot = find(p);
        int qRoot = find(q);

        //如果p和q已经在同一分组,则不需要合并了
        if (pRoot==qRoot){
            return;
        }

        //判断proot对应的树大还是qroot对应的树大,
        //最终需要把较小的树合并到较大的树中

        if (sz[pRoot]<sz[qRoot]){
            eleAndGroup[pRoot] = qRoot;
            sz[qRoot]+=sz[pRoot];
        }else{
            eleAndGroup[qRoot] = pRoot;
            sz[pRoot]+= sz[qRoot];
        }

        //组的数量-1

        this.count--;

    }

}

解决畅通工程

我们把开始的那个问题用并查集来解决:

解题思路

  1. 创建一个并查集UF_Tree_Weighted(20);
  2. 分别调用union(0,1),union(6,9),union(3,8),union(5,11),union(2,12),union(6,10),union(4,8),表示已经修建好的道路把对应的城市连接起来;
  3. 如果城市全部连接起来,那么并查集中剩余的分组数目为1,所有的城市都在一个树中,所以,只需要获取当前并查集中剩余的数目,减去1,就是还需要修建的道路数目;
public class Traffic_Project_Test {

    public static void main(String[] args) throws Exception{


        //构建一个缓冲读取流BufferedReader
        BufferedReader br = new BufferedReader(new 以上是关于数据结构——并查集的主要内容,如果未能解决你的问题,请参考以下文章

树--12---并查集

数据结构与算法—并查集Kruskal算法求最小生成树

数据结构 ---[实现并查集(UnionFind)]

14 并查集

带权并查集——食物链

❤️数据结构入门❤️(2 - 5)- 并查集