基础_并查集_合并集合

什么是并查集?

逐字拆解一下,并、查、集。这个三个字,其中前面两个字都是动词,第三个字是个名词。

先看名词,因为只有知道了这个东西是什么,才能去理解它能干什么。

集就是集合,中学时期就学过这个东西,集合用大白话说就是将一堆元素没有顺序地摆放在同一个地方。

其实并查集本质就是集合

那它能做什么呢?这就要看前两个字 - “并” 和 “查”。

集合的一些操作,例如,交集,并集等等,这里的 “并” 其实就指的是并集操作,两个集合合并后就会变成一个集合。例如:

{1,3,5,7} U {2,4,6,8} = {1,2,3,4,5,6,7,8}

那 “查” 又是什么呢?集合本身只是容器,最终还是要知道里面存的是什么元素,因此这里的 “查” 是对于集合中存放的元素来说的,即要查找这个元素在不在集合中,还要确定这个元素是在哪个集合中。

好了,现在知道并查集是什么,以及它能干什么了,总结下来就是:

  1. 并查集可以进行集合合并的操作(并)
  2. 并查集可以查找元素在哪个集合中(查)
  3. 并查集维护的是一堆集合(集)

举个例子:

有8个元素:14, 35, 48, 87, 65, 20

:把个位相同的数字归在同一个集合。集合划分为如下:

{14}, {35, 65}, {48}, {87}, {20}

:给定一个元素,得到这个元素属于哪个集合。例如:

  1. 给定元素14,可以得出:14 位于第一个集合。
  2. 给定元素65,可以得出:65 位于第二个集合。
  3. 给定元素20,可以得出:20 位于第五个集合。

并:将两个集合合并,例如将个位为偶数的集合合并的到第一个集合,个位为奇数的集合合并到第二个集合,结果如下:

{14, 48, 20}, {35, 65, 87}

相信通过上面的表述,已经知道,并查集维护的是一堆集合。用什么样的数据结构表示并查集?

对于并查集,有两个信息是必须要知道的:

  1. 元素的值。
  2. 集合的标号。

一个元素必须仅存在于一个集合中,一个集合中可以有多个元素。

元素对集合来说,是多对一的关系。这么看来可以用一个健值对的结构来表示并查集(键是元素,值时所属集合)。

但是如果对元素本身没有特定要求的话,可以使用数组,这样速度更快,使用起来也更加简单:

{0}, {1}, {2}, {3}, {4}, {5} => [0,1,2,3,4,5]

{0,1,2}, {3,4,5} => [0,0,0,3,3,3] or [1,1,1,4,4,4] 

在解释上面的数组表示方式之前,不知道有没有发现一个事实:

  1. 元素本身的值是固定不变的,但是元素所属的集合是可以变化的”

因此可以使用两个数组:

  1. 第一个数组保存所有元素
  2. 第二个数组使用数组的 下标 来代表数组一中元素,对应位置上 存放的值 表示元素所属的集合。

例如:{0,1,2}, {3,4,5} => [0,0,0,3,3,3]:第一个数组是0, 1, 2, 3, 4, 5,第二个数组是0, 0, 0, 3, 3, 3。第一个数组保存了所有元素,第二个数组保存了元素所属集合。

  1. 第二个数组中,第一个元素是0,含义是:第一个数组的第一个元素属于 0 号集合。
  2. 第二个数组中,第二个元素是0,含义是:第一个数组的第二个元素属于 0 号集合。
  3. 第二个数组中,第三个元素是0,含义是:第一个数组的第三个元素属于 0 号集合。
  4. 第二个数组中,第四个元素是3,含义是:第一个数组的第四个元素属于 3 号集合。
  5. 第二个数组中,第五个元素是3,含义是:第一个数组的第五个元素属于 3 号集合。
  6. 第二个数组中,第六个元素是3,含义是:第一个数组的第六个元素属于 3 号集合。

说完了集合的表示,来看看如何基于这种表示去实现 “并” 和 “查”,也就是集合的合并和元素的查找,这两个操作是相互影响的。合并其实就是改变第二个数组中存放的值,这个值表示的是第一个数组对应位置元素所在的集合。

上述实现并查集方法很直观。但是将连个集合合并的时候,需要修改其中一个集合中的所有元素对应的数组二中的值,有没有办法优化下呢?

这个问题的源是:第二个数组中保存的是第一个数组中各个元素所属集合,所以合并集合的时候,第二个数组中需要修改元素比较多。

可以为每个元素选出一个代表它的元素,数组二中存放代表元素

例如:{0,1,2}, {3,4,5} => [0,0,0,3,3,3]:第一个数组是0, 1, 2, 3, 4, 5,第二个数组是0, 0, 0, 3, 3, 3。第一个数组保存了所有元素,第二个数组保存了能代表该元素的元素。

  1. 第二个数组中,0号位置保存的是0,含义是:第一个数组的0号位置保存的元素和 第一个数组中的0号位置保存的元素属于同一个集合。

  2. 第二个数组中,1号位置保存的是0,含义是:第一个数组的1号位置保存的元素和 第一个数组中的0号位置保存的元素属于同一个集合。

  3. 第二个数组中,2号位置保存的是0,含义是:第一个数组的2号位置保存的元素和 第一个数组中的0号位置保存的元素属于同一个集合。

  4. 第二个数组中,3号位置保存的是3,含义是:第一个数组的3号位置保存的元素和 第一个数组中的3号位置保存的元素属于同一个集合。

  5. 第二个数组中,4号位置保存的是3,含义是:第一个数组的4号位置保存的元素和 第一个数组中的3号位置保存的元素属于同一个集合。

  6. 第二个数组中,5号位置保存的是3,含义是:第一个数组的5号位置保存的元素和 第一个数组中的3号位置保存的元素属于同一个集合。

这个时候,如果要合并两个集合,只需要修改代表元素即可。

例如:将{0,1,2}, {3,4,5} => [0,0,0,3,3,3]中的第二个集合合并到第一个集合中,。只需要修改第二个集合的代表元素集合,合并后为:{0,1,2,3,4,5} => [0,0,0,0,3,3]

这个时候,问:5这个元素位于哪个集合?查找过程如下:

  1. 在数组一中找到 5 这个元素的位置下标是:5
  2. 在第二个数组中查看下标5位置保存的元素是3。说明5这个元素和3这个元素在同一个集合。
  3. 在数组一中找到 3 这个元素的位置下标是:3。在第二个数组中查看下标3位置保存的元素是0。说明5这个元素和0这个元素在同一个集合。
  4. 在数组一中找到 0 这个元素的位置下标是:0。在第二个数组中查看下标0位置保存的元素是0,也就是找到了代表元素。得出结论5这个元素的代表元素是0,他们在同一个集合。

第二个数组保存代表元素,就能简化集合的合并。

总结

  1. 用一个数组保存对应位置元素所属集合的代表元素。
  2. AB两个集合合并:将B集合代表元素的代表元素设置为A集合的代表元素。
  3. 查找C元素属于哪个集合:找C元素的代表元素,如果不是他自己,就重复查找代表元素的代表元素,知道查找到一个元素的代表元素是它自己,C就属于整个代表元素所代表的集合。(啊啊啊,绕口令吗!!!)

题目(合并集合)

一共有 n 个数,编号是 1∼n ,最开始每个数各自在一个集合中。

现在要进行 m 个操作,操作共有两种:

  1. M a b,将编号为 a 和 b 的两个数所在的集合合并,如果两个数已经在同一个集合中,则忽略这个操作;
  2. Q a b,询问编号为 a
    和 b 的两个数是否在同一个集合中;

输入格式
第一行输入整数 n 和 m 。

接下来 m 行,每行包含一个操作指令,指令为 M a bQ a b 中的一种。

输出格式
对于每个询问指令 Q a b,都要输出一个结果,如果 a 和 b 在同一集合内,则输出 Yes,否则输出 No。

每个结果占一行。

数据范围
1 ≤ n,m ≤ 105
输入样例:
4 5
M 1 2
M 3 4
Q 1 2
Q 1 3
Q 3 4
输出样例:
Yes
No
Yes

思路

并查集
从代码的角度分析

初始化

for(int i = 0; i < 8; i ++) p[i] = i;

上面的代码实现的结果如下图所示
在这里插入图片描述

很容易理解,就是将当前数据的父节点指向自己

查找 + 路径压缩

int find(int x){ //返回x的祖先节点 + 路径压缩
    //祖先节点的父节点是自己本身
    if(p[x] != x){
        //将x的父亲置为x父亲的祖先节点,实现路径的压缩
        p[x] = find(p[x]);    
    }
    return p[x]; 
}

find的功能是用于查找祖先节点,那么路径压缩又是怎么完成的
在这里插入图片描述

注意图,当我们在查找1的父节点的过程中,路径压缩的实现

针对 x = 1

find(1) p[1] = 2  p[1] = find(2)
find(2) p[2] = 3  p[2] = find(3)
find(3) p[3] = 4  p[3] = find(4)
find(4) p[4] = 4  将p[4]返回

退到上一层
find(3) p[3] = 4  p[3] = 4 将p[3]返回
退到上一层
find(2) p[2] = 3  p[2] = 4 将p[2]返回
退到上一层
find(1) p[1] = 2  p[1] = 4 将p[1]返回

至此,我们发现所有的123的父节点全部置为了4,实现路径压缩;同时也实现了1的父节点的返回
nice!!

合并操作

if(op[0] == ‘M’) p[find(a)] = find(b); //将a的祖先点的父节点置为b的祖先节点
假设有两个集合
在这里插入图片描述

合并1, 5
find(1) = 3 find(5) = 4
p[find(1)] = find(5) –> p[3] = 4
如下图所示
在这里插入图片描述

查找

find(a) == find(b) 这很简单,就不介绍了

其他路径压缩方法

  1. 路径分裂:使路径上的每个节点都指向其祖父节点(parent的parent)
    在这里插入图片描述
int find(int x){
    while(x != p[x]){
        int parent = p[x];
        p[x] = p[p[x]];
        x = parent;
    }
    return x;
}
  1. 路径减半:使路径上每隔一个节点就指向其祖父节点(parent的parent)
    在这里插入图片描述
int find(int x){
    while(x != p[x]){
        p[x] = p[p[x]];
        x = p[x];
    }
    return x;
}

总结

并查集

  1. 将两个集合合并
  2. 询问两个元素是否在一个集合中

基本原理:每个集合用一棵树来表示。树的编号就是整个集合的编号。每个节点存储它的父节点,p[x]表示x的父节点

  1. 判断树根 if(p[x] = x)
  2. 求x的集合编号 while(p[x] != x) x = p[x]
  3. 合并两个集合,这两将x的根节点嫁接到y的根节点, px为x的根节点, py为y的根节点,嫁接p[px] = py

代码

java

import java.util.*;

public class Main{
    static int n, m;
    static int N = 100010;
    static int[] p = new int[N];


    public static int find(int x){
        if(x != p[x]) p[x] = find(p[x]);
        return p[x];
    }

    public static void main(String[] args){
        Scanner sc = new Scanner(System.in);
        n = sc.nextInt();
        m = sc.nextInt();

        for(int i = 0; i < n; i ++) p[i] = i;

        String opt;
        int a, b;
        while(m -- > 0){
            opt = sc.next();
            a = sc.nextInt();
            b = sc.nextInt();
            if(opt.equals("M")) p[find(a)] = find(b);
            else{
                if(find(a) == find(b)) System.out.println("Yes");
                else System.out.println("No");
            }
        }
    }
}

c++

#include <iostream>

using namespace std;

const int N = 100010;

int n, m;
int p[N];

int find(int x){ //返回x的祖先节点 + 路径压缩
    //祖先节点的父节点是自己本身
    if(p[x] != x){
        //将x的父亲置为x父亲的父亲,实现路径的压缩
        p[x] = find(p[x]);    
    }
    return p[x]; 
}

int main(){
    scanf("%d%d", &n, &m);
    for(int i = 1; i <= n; i ++) p[i] = i; //初始化,让数x的父节点指向自己
    while(m --){
        char op[2];
        int a, b;
        scanf("%s%d%d", op, &a, &b);

        if(op[0] == 'M') p[find(a)] = find(b); //将a的祖先点的父节点置为b的祖先节点
        else{
            if(find(a) == find(b)) puts("Yes");
            else puts("No");
        }
    }
    return 0;
}
  • 25
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值