Skip to content

并查集-上


基本介绍

相关方法介绍

(1)一开始每个元素都拥有自己的集合,在自己的集合里只有这个元素自己

(2)int find(i):查找 i 所在集合的代表元素,代表元素来代表 i 所在的集合

(3)boolean isSameSet(a, b):判断 a 和 b 在不在一个集合里

(4)void union(a, b):a 所在集合所有元素 与 b 所在集合所有元素合并成一个集合

(5)各种操作单次调用均摊时间复杂度为 O(1)

并查集的两个优化,都发生在 find 方法里

(1)扁平化(一定要做),又叫路径压缩

(2)小挂大(可以不做,原论文中是秩的概念,可以理解为粗略高度或者大小)

⚠️ 注意

并查集的小扩展(下节课的题目重点展示):可以定制信息:并查集目前有多少个集合,以及给每个集合打上标签信息

带权并查集、可持久化并查集、可撤销并查集,都是备战算法竞赛的同学必学的内容,这些内容会在【挺难】阶段的课程里安排讲述

复杂度分析

感性理解单次调用的均摊时间复杂度为 O(1)即可,其实为 α(n),反阿克曼函数

当 n = 1080 次方即可探明宇宙原子量,α(n) 为 0.69 的返回值也不超过 6,那么就可以认为时间复杂度为 O(1)

并查集的发明者 Bernard A. Galler 和 Michael J. Fischer,从 1964 年证明到 1989 年才证明完毕,建议记住即可,理解证明难度很大!

并查集模板代码

基础模板

定义 father 数组,下标表示每个节点的编号,father [ i ] 表示 i 号节点的向上挂接的节点是谁

定义 size 数组,下标表示每个节点的编号,size [ i ] 表示编号为 i 的节点所在集合的元素个数,并采用小挂大的合并方式

定义 stack 数组,做扁平化处理,收集沿途节点,使得每个节点的上级挂接节点直接指向父节点,只有代表节点的 size 信息有用

java
class Disjoint {

    public static int MAXN = 1000001;

    public static int[] father = new int[MAXN];

    // 记录每个集合中元素的个数
    public static int[] size = new int[MAXN];

    // 用于收集沿途节点,实现扁平化处理
    public static int[] stack = new int[MAXN];

    public static int n;

    public static void build() {
        // 初始时都指向自己,且集合中都只有自己一个元素
        for (int i = 0; i <= n; i++) {
            father[i] = i;
            size[i] = 1;
        }
    }

    public static int find(int n) {
        // 沿途收集了几个节点
        int size = 0;
        // 上级节点不是本身,说明还没找到上级节点
        while (n != father[n]) {
            // 收集沿途节点
            stack[size++] = n;
            // n 往上朓,来到当前节点的上级节点位置
            n = father[n];
        }
        // 沿途节点都收集好了,做扁平化处理
        while (size > 0) {
            father[stack[--size]] = n;
        }
        return n;
    }

    public static boolean isSameSet(int x, int y) {
        return find(x) == find(y);
    }

    public static void union(int x, int y) {
        int fx = find(x);
        int fy = find(y);
        // 不在一个结合中才合并,且遵循小挂大的原则
        if (fx != fy) {
            if (size[fx] >= size[fy]) {
                // 小挂大
                father[fy] = fx;
                // 大的集合吸纳新的元素
                size[fx] += size[fy];
            } else {
                father[fx] = fy;
                size[fy] += size[fx];
            }
        }
    }
}

优化模板

java
class Disjoint {

    public static int MAXN = 200001;

    public static int[] father = new int[MAXN];

    public static int n;

    public static void build() {
        for (int i = 0; i <= n; i++) {
            father[i] = i;
        }
    }

    public static int find(int n) {
        // 自身就是根节点,直接返回
        if (n == father[n]) {
            return n;
        }
        // 扁平化处理,将当前节点的上级节点变为父节点
        return father[n] = find(father[n]);
    }

    public static boolean isSameSet(int x, int y) {
        return find(x) == find(y);
    }

    public static void union(int x, int y) {
        int fx = find(x);
        int fy = find(y);
        if (fx == fy) {
            return;
        }
        // x 的根节点挂接到 y 的根节点上
        father[fx] = fy;
    }
}

⚠️ 注意事项

Tip

union 方法一定要用单独的变量接收两个元素的父节点,用变量来判断,这是比较安全的写法,不要直接用方法去判断,在某些题目中可能会出错

模板题

题目链接

洛谷:https://www.luogu.com.cn/problem/P3367

牛客:https://www.nowcoder.com/practice/e7ed657974934a30b2010046536a5372

洛谷代码实现

java
import java.io.*;

public class Main {

    public static int MAXN = 200001;

    public static int[] father = new int[MAXN];

    public static int n;

    public static void build() {
        for (int i = 0; i <= n; i++) {
            father[i] = i;
        }
    }

    public static int find(int n) {
        // 自身就是根节点,直接返回
        if (n == father[n]) {
            return n;
        }
        // 扁平化处理,将当前节点的上级节点变为父节点
        return father[n] = find(father[n]);
    }

    public static boolean isSameSet(int x, int y) {
        return find(x) == find(y);
    }

    public static void union(int x, int y) {
        int fx = find(x);
        int fy = find(y);
        if (fx == fy) {
            return;
        }
        // x 的根节点挂接到 y 的根节点上
        father[fx] = fy;
    }

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StreamTokenizer in = new StreamTokenizer(br);
        PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
        while (in.nextToken() != StreamTokenizer.TT_EOF) {
            n = (int) in.nval;
            build();
            in.nextToken();
            int m = (int) in.nval;
            for (int i = 0; i < m; i++) {
                in.nextToken();
                int z = (int) in.nval;
                in.nextToken();
                int x = (int) in.nval;
                in.nextToken();
                int y = (int) in.nval;
                if (z == 1) {
                    union(x, y);
                } else {
                    out.println(isSameSet(x, y) ? "Y" : "N");
                }
            }
        }
        out.flush();
        out.close();
        br.close();
    }
}

牛客代码实现

java
import java.io.*;

public class Main {

    public static int MAXN = 1000001;

    public static int[] father = new int[MAXN];

    // 记录每个集合中元素的个数
    public static int[] size = new int[MAXN];

    // 用于收集沿途节点,实现扁平化处理
    public static int[] stack = new int[MAXN];

    public static int n;

    public static void build() {
        // 初始时都指向自己,且集合中都只有自己一个元素
        for (int i = 0; i <= n; i++) {
            father[i] = i;
            size[i] = 1;
        }
    }

    public static int find(int n) {
        // 沿途收集了几个节点
        int size = 0;
        // 上级节点不是本身,说明还没找到上级节点
        while (n != father[n]) {
            // 收集沿途节点
            stack[size++] = n;
            // n 往上朓,来到当前节点的上级节点位置
            n = father[n];
        }
        // 沿途节点都收集好了,做扁平化处理
        while (size > 0) {
            father[stack[--size]] = n;
        }
        return n;
    }

    public static boolean isSameSet(int x, int y) {
        return find(x) == find(y);
    }

    public static void union(int x, int y) {
        int fx = find(x);
        int fy = find(y);
        // 不在一个结合中才合并,且遵循小挂大的原则
        if (fx != fy) {
            if (size[fx] >= size[fy]) {
                // 小挂大
                father[fy] = fx;
                // 大的集合吸纳新的元素
                size[fx] += size[fy];
            } else {
                father[fx] = fy;
                size[fy] += size[fx];
            }
        }
    }

    public static void main(String[] args) throws Exception {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StreamTokenizer in = new StreamTokenizer(br);
        PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
        while (in.nextToken() != StreamTokenizer.TT_EOF) {
            n = (int) in.nval;
            build();
            in.nextToken();
            int m = (int) in.nval;
            for (int i = 0; i < m; i++) {
                in.nextToken();
                int op = (int) in.nval;
                in.nextToken();
                int x = (int) in.nval;
                in.nextToken();
                int y = (int) in.nval;
                if (op == 1) {
                    out.println(isSameSet(x, y) ? "Yes" : "No");
                } else {
                    union(x, y);
                }
            }
        }
        out.flush();
        out.close();
        br.close();
    }
}

情侣牵手

题目链接

https://leetcode.cn/problems/couples-holding-hands/

思路分析

根据题目可以得到以下结论:对于第 i 对情侣的编号 n,可以由第 i 对情侣的 a / 2 与 b / 2 得到

并查集中单个集合中存储两个元素,表示二者是情侣,如果发现两个元素所对应的情侣编号不相同,即二者不是情侣,将二者对应的情侣编号合并

⭐ 结论:集合中有 k 对情侣,则执行 k - 1 次调整后可以使得所有情侣配对

进一步简化,对于每个集合都要执行 k - 1 次操作,假设只有三个集合 a、b、c,在未执行合并方法之前,单个集合表示一对情侣,每个集合的元素个数为自己的编号,则 a 集合需要执行 a - 1 次调整,b 集合需要执行 b - 1 次,调整 c 集合需要执行 c - 1 次调整,累加得到总的调整次数,即为 a + b + c - 3,a + b + c 表示情侣的对数,3 表示集合的个数,可以得出如下推论

⭐ 调整次数 = 情侣的对数 - 集合的个数,假设有 n 个人,则情侣对数为 n / 2

代码实现

java
class Solution {

    int MAXN = 31;

    int[] father = new int[MAXN];

    int sets;

    public int minSwapsCouples(int[] row) {
        int n = row.length;
        build(n / 2);
        for (int i = 0; i < n; i += 2) {
            // i 位置的人 / 2 得到该请情侣对应的编号
            union(row[i] / 2, row[i + 1] / 2);
        }
        // 根据推论,调整的次数为情侣的对数 - 集合的个数
        return n / 2 - sets;
    }

    public void build(int n) {
        for (int i = 0; i <= n; i++) {
            father[i] = i;
        }
        // 初始为 n 对情侣,有 n 个集合
        sets = n;
    }

    public int find(int n) {
        if (n == father[n]) {
            return n;
        }
        return father[n] = find(father[n]);
    }

    public void union(int x, int y) {
        int fx = find(x);
        int fy = find(y);
        if (fx == fy) {
            return;
        }
        // x 的根节点挂接到 y 的根节点上
        father[fx] = fy;
        // 合并之后集合的个数要减少
        sets--;
    }
}

相似字符串组

题目链接

https://leetcode.cn/problems/similar-string-groups/

思路分析

明确相似的含义(1)和本身相同是相似(2)本身中的两个字符交换是相似,即两个单词只要有两个对应位置的字符不相同就是相似的

需要求有多少组,即遍历每一个字符,以该字符为基准,遍历其他字符,如果是相似的就放到一组中,统计组数即可,使用并查集实现 union 操作

由于要求是相似的为一组,即给定数组中的每个单词的长度是相同的,要抓住这个特点

时间复杂度:O(m * n2 ),m 是字符串的长度

代码实现

java
class Solution {

    int MAXN = 301;

    int[] father = new int[MAXN];

    int sets;

    public int numSimilarGroups(String[] strs) {
        int n = strs.length;
        int m = strs[0].length();
        build(n);
        // 遍历每个单词,以该单词为基准寻找同组的单词
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j < n; j++) {
                // 两个单词不在一个集合中,判断是否可以合并
                if (find(i) != find(j)) {
                    // 记录差异的位置个数
                    int diff = 0;
                    for (int k = 0; k < m && diff < 3; k++) {
                        if (strs[i].charAt(k) != strs[j].charAt(k)) {
                            diff++;
                        }
                    }
                    // 要么为本身,要么是相似,即 diff = 2
                    if (diff == 0 || diff == 2) {
                        union(i, j);
                    }
                }
            }
        }
        return sets;
    }

    // 一个单词对应一个下标,用下标来判断是否在同一个集合中
    public void build(int n) {
        for (int i = 0; i < n; i++) {
            father[i] = i;
        }
        sets = n;
    }

    public int find(int n) {
        if (n == father[n]) {
            return n;
        }
        return father[n] = find(father[n]);
    }

    public void union(int x, int y) {
        int fx = find(x);
        int fy = find(y);
        if (fx == fy) {
            return;
        }
        // x 的根节点挂接到 y 的根节点上
        father[fx] = fy;
        // 合并后集合的个数需要减少
        sets--;
    }
}

岛屿数量

题目链接

https://leetcode.cn/problems/number-of-islands/

思路分析

本题为二维问题,而在并查集中,处理的都是一维问题,每个元素都对应一个指标,本题中通过给(i,j)位置的元素一个编号,实现从二维问题向一维问题的转化,进而使用并查集实现

(0,0)位置的编号为 0,逐个向后编号,(i,j)位置的编号即为 i * m + jm 表示总的列数

并查集实现思路:检查每个位置的四个方向,如果值相同就 union,最后返回集合的个数即可

优化点:实际上并不需要每次都检查该位置的上下左右四个方向,只需要检查左边和上边,每个位置的右边和下面这两个方向会由后面元素左边和上边检查,即无需重复检查

build 函数逻辑:只需要关注 1 的位置,0 的位置没用,对 1 的位置实现并查集操作

代码实现

java
class Solution {

    int MAXSIZE = 100001;

    int[] father = new int[MAXSIZE];

    // 总列数
    int cols;

    // 集合的个数
    int sets;

    public int numIslands(char[][] grid) {
        int n = grid.length;
        int m = grid[0].length;
        build(n, m, grid);
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (grid[i][j] == '1') {
                    // 只需要关注左边和上边两个方向
                    // 注意判断是否越界
                    if (j > 0 && grid[i][j - 1] == '1') {
                        union(i, j, i, j - 1);
                    }
                    if (i > 0 && grid[i - 1][j] == '1') {
                        union(i, j, i - 1, j);
                    }
                }
            }
        }
        return sets;
    }

    public void build(int n, int m, char[][] board) {
        cols = m;
        sets = 0;
        // 将 1 位置转为一维并查集,0 位置无需关注
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                if (board[i][j] == '1') {
                    int index = index(i, j);
                    // 初始时指向自己
                    father[index] = index;
                    sets++;
                }
            }
        }
    }

    public int index(int x, int y) {
        return x * cols + y;
    }

    public int find(int n) {
        if (n == father[n]) {
            return n;
        }
        return father[n] = find(father[n]);
    }

    public void union(int a, int b, int c, int d) {
        int fa = find(index(a, b));
        int fb = find(index(c, d));
        if (fa == fb) {
            return;
        }
        // a 位置的根节点挂接到 b 位置的根节点上
        father[fa] = fb;
        // 合并之后,集合个数需要减少
        sets--;
    }
}