并查集

119 阅读9分钟

操作

  • 将两个集合合并
  • 查询两个元素是否在同一个集合当中
  • 时间复杂度, 接近O(1), 因为集合合并的过程需要一些时间

基本原理

每个集合用一棵树来进行表示。树根的编号就是整个集合的编号。每个结点存储它的父节点。p[x] 表示x的父结点。

问题

  • 如何判断树根: if(p[x] == x)
  • 如何求x的集合编号: while(p[x] != x) x = p[x]
  • 如何合并两个集合: p[a] = b; 即可, 把a集合连接到以b为根的集合中,其中p[a]是集合a的编号, p[b]是集合b的编号

实例

image.png

对于这个集合,如果要找到结点f的根节点,那么需要一直往上找。按照f->e->d->c->b->a这个流程,查找次数等于集合树的高度。 如何优化?

路径压缩优化

使用递归的方式,找到根节点直接,递归返回根节点,让整个路径上的结点都直接指向根节点。 代码是非常的简洁的,不理解找个简单的例子去走一遍逻辑就知道了。

int find(int a){
    if(p[a] != a) p[a] = find(p[a]);
    return p[a];
}

例题

AcWing 836. 合并集合

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

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

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

输入格式

第一行输入整数 n 和 m。

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

输出格式

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

每个结果占一行。

数据范围

1≤n,m≤10^5

输入样例:

4 5
M 1 2
M 3 4
Q 1 2
Q 1 3
Q 3 4

输出样例:

Yes
No
Yes

Code:

import java.util.*;
import java.lang.*;
import java.io.*;

public class Main{
    
    static int [] p = new int[100010];
    
    public static int find(int x){
        if(x != p[x]) p[x] = find(p[x]);
        return p[x];
    }
    
    public static void main(String [] args) throws IOException{
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
        int n, m;
        String [] a = br.readLine().split(" ");
        n = Integer.parseInt(a[0]);
        for(int i = 1; i <= n;i++) p[i] = i;
        m = Integer.parseInt(a[1]);
        for(int i = 0 ; i < m;i++){
            String [] s = br.readLine().split(" ");
            int c = Integer.parseInt(s[1]), b = Integer.parseInt(s[2]);
            if(s[0].equals("M")){
                // 找到根节点
                int pc = find(c);
                int pb = find(b);
                
                if(pc != pb){
                    p[pc] = pb;
                }
                
            }else {
                int pc = find(c);
                int pb = find(b);
                // 两个根节点相同说明在一个集合
                if(pc!= pb){
                    bw.write("No" + "\n");
                }else bw.write("Yes" +"\n");
                
            }
        }
        
        bw.flush();
        br.close();
        bw.close();
    }
    
}

AcWing 837. 连通块中点的数量

给定一个包含 n 个点(编号为 1∼n)的无向图,初始时图中没有边。

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

  1. C a b,在点 a 和点 b 之间连一条边,a 和 b 可能相等;
  2. Q1 a b,询问点 a 和点 b 是否在同一个连通块中,a 和 b 可能相等;
  3. Q2 a,询问点 a 所在连通块中点的数量;

输入格式

第一行输入整数 n 和 m。

接下来 m 行,每行包含一个操作指令,指令为 C a bQ1 a b 或 Q2 a 中的一种。

输出格式

对于每个询问指令 Q1 a b,如果 a 和 b 在同一个连通块中,则输出 Yes,否则输出 No

对于每个询问指令 Q2 a,输出一个整数表示点 a 所在连通块中点的数量

每个结果占一行。

数据范围

1≤n,m≤105

输入样例:

5 5
C 1 2
Q1 1 2
Q2 1
C 2 5
Q2 5

输出样例:

Yes
2
3

Code:

import java.util.*;
import java.lang.*;
import java.io.*;

public class Main{
    
    static int [] p = new int[100010];
    // 维护每个结合的点数
    static int [] size = new int[100010];
    public static int find(int x){
        if(x != p[x]) p[x] = find(p[x]);
        return p[x];
    }
    
    public static void main(String [] args) throws IOException{
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
        int n, m;
        String [] a = br.readLine().split(" ");
        n = Integer.parseInt(a[0]);
        for(int i = 1; i <= n;i++) {
            p[i] = i;
        }
        // 初始每个集合的点数都只有自己,也就是1,我们只需要保证根节点的size有意义就可以,每次查根节点,因为我们每次插入集合都是直接插入到根结点下,更新数量就好了
        Arrays.fill(size, 1);
        m = Integer.parseInt(a[1]);
        for(int i = 0 ; i < m;i++){
            String [] s = br.readLine().split(" ");
            int c = Integer.parseInt(s[1]);
            if(s[0].equals("C")){
                int b = Integer.parseInt(s[2]);
                int pc = find(c);
                int pb = find(b);
                
                if(pc != pb){
                    p[pc] = pb;
                    size[pb] += size[pc];
                }
                
                
            }else if(s[0].equals("Q1")){
                int b = Integer.parseInt(s[2]);
                int pc = find(c);
                int pb = find(b);
                
                if(pc != pb){
                    bw.write("No" + "\n");
                }else bw.write("Yes" +"\n");
                
            }else {
                int pc = find(c);
            
                bw.write(size[pc] + "\n");
                
            }
        }
        
        bw.flush();
        br.close();
        bw.close();
    }
    
}

AcWing 240. 食物链

动物王国中有三类动物 A,B,C,这三类动物的食物链构成了有趣的环形。

A 吃 B,B 吃 C,C 吃 A。

现有 N 个动物,以 1∼N 编号。

每个动物都是 A,B,C 中的一种,但是我们并不知道它到底是哪一种。

有人用两种说法对这 N 个动物所构成的食物链关系进行描述:

第一种说法是 1 X Y,表示 X 和 Y 是同类。

第二种说法是 2 X Y,表示 X 吃 Y。

此人对 N个动物,用上述两种说法,一句接一句地说出 K 句话,这 K 句话有的是真的,有的是假的。

当一句话满足下列三条之一时,这句话就是假话,否则就是真话。

  1. 当前的话与前面的某些真的话冲突,就是假话;
  2. 当前的话中 X 或 Y 比 N 大,就是假话;
  3. 当前的话表示 X 吃 X,就是假话。

你的任务是根据给定的 N 和 K 句话,输出假话的总数。

输入格式

第一行是两个整数 N 和 K,以一个空格分隔。

以下 K 行每行是三个正整数 D,X,Y,两数之间用一个空格隔开,其中 D 表示说法的种类。

若 D=1,则表示 X 和 Y 是同类。

若 D=2,则表示 X 吃 Y。

输出格式

只有一个整数,表示假话的数目。

数据范围

1≤N≤50000,
0≤K≤100000

输入样例:

100 7
1 101 1 
2 1 2
2 2 3 
2 3 3 
1 1 3 
2 3 1 
1 5 5

输出样例:

3

解法1:带扩展域的并查集

  • 规定在带扩展域的并查集当中 x不再只表示一个值,而是一个事件
  • 规定 x为 "事件x的同类"
  • 规定 x + N 为"事件x的捕食类"
  • 规定 x + 2 * N 为 "事件x的天敌类"

p[find(x)] = find(y)表示 事件x为A类动物和事件y为A类动物可以同时发生 so:

  1. X 与 Y 为同种动物 等价于 下面三种都在同一类

  2.  p[ find(X) ] = find(Y); // x和y的同类在一起
     p[ find(X + N)] = find(Y + N); // x和y的捕食类在一起
     p[ find(X + N * 2)] = find(Y + N * 2); // x和y的天敌也在一起
     
    
  3. p[find(X)] = find(Y + N) 表示 事件 X 为 A 类动物 和 事件 Y 为 B 类动物 同时发生

  4. X 吃 Y 等价于

  5.  p[ find(X + N) ] = find(Y); // Y同类中加入X的捕食类
     p[ find(X)] = find(Y + N * 2); // y的天敌类中加入
     p[ find(X + N * 2)] = find(Y + N); // Y的捕食类的捕食类是X的天敌类
    

Code1: 带扩展域的并查集。

import java.util.Scanner;

public class Main {
    static final int N = 50010;
    static final int M = N * 3;

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

        for (int i = 1; i < M; i++) {
            p[i] = i;
        }

        int res = 0;
        while (m-- > 0) {
            int d = scanner.nextInt();
            int a = scanner.nextInt();
            int b = scanner.nextInt();
            // a 或者b大于n是假话
            if (a > n || b > n) {
                res++;
                continue;
            }

            if (a == b) {
                // a和b是同类的情况下, 如果说a吃b的话就是假话
                if (d == 2) {
                    res++;
                }
                continue;
            }

            if (d == 1) {
                //  如果 b的捕食类有a或者a的捕食类有b就是假话
                if (find(p, a) == find(p, b + N) || find(p, a + N) == find(p, b)) {
                    res++;
                    continue;
                }
                // 是同类的话就更新几种状态
                p[find(p, a)] = find(p, b);
                p[find(p, a + N)] = find(p, b + N);
                p[find(p, a + N * 2)] = find(p, b + N * 2);
            } else {
                // 如果b的捕食类有a或者a和b是同类 ,则 a 吃 b 是假话
                if (find(p, a + N) == find(p, b) || find(p, a) == find(p, b)) {
                    res++;
                    continue;
                }

                p[find(p, a)] = find(p, b + N);
                p[find(p, a + N)] = find(p, b + N * 2); 
                p[find(p, a + N * 2)] = find(p, b);
            }
        }

        System.out.println(res);
    }

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

解法2:带边权的并查集

import java.util.Scanner;

public class Main {
    static final int N = 50010;

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        int k = scanner.nextInt();
        int[] p = new int[N];
        //d数组保存的是到根结点的距离,初始到自己的距离为0
        int[] d = new int[N];
        int res = 0;

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

        while (k-- > 0) {
            int c = scanner.nextInt();
            int x = scanner.nextInt();
            int y = scanner.nextInt();

            int px = find(p, x, d); // px, py分别储存根节点
            int py = find(p, y, d);

            if (x > n || y > n) {
                res++;
            } else if (c == 1) {
                if (px == py && (d[x] - d[y]) % 3 != 0) { // X和y在一颗树上,但是x和y不是同类,表示说的是假话
                    res++;
                }
                if (px != py) { // 如果不在一颗树上但是是同类,更新px和py之间的距离
                    p[px] = py;
                    d[px] = d[y] - d[x]; // 如果把x的根连到y的根上的话,x到py的距离 设为k,那么d[x] + k 和d[y]模三都为0,所以
                    // k = d[y] - d[x];
                    
                }
            } else if (c == 2) {
                if (px == py && (d[x] - d[y] - 1) % 3 != 0) {
                    res++;
                    // X和y在一颗树上,但是x不能吃y,表示说的是假话
                    //为什么-1,因为距离差1吗,那么在模的运算上位距离之差-1再取模运算
                }
                if (px != py) {
                    p[px] = py;
                    d[px] = d[y] + 1 - d[x];
                    // 如果把x的根连到y的根上的话,x到py的距离 设为k,那么d[x] + k 和d[y]模三都为0,所以 k = d[y] - d[x] + 1;
                }
            }
        }

        System.out.println(res);
    }

    static int find(int[] p, int x, int[] d) {
        if (p[x] != x) {
            int t = find(p, p[x], d); // 开始递归;最终用t储存根节点
            d[x] += d[p[x]]; // 在递归过程中求得每个点到根节点的距离
            p[x] = t; // 将每个点的父节点指向根节点
        }

        return p[x]; // 返回父节点
    }
}

参考

[目目目大佬](AcWing 240. 食物链(带扩展域 和 带边权 并查集 两种做法) - AcWing)和[秦淮大佬](AcWing 240. 食物链 - AcWing)对并查集的理解