Skip to content

并查集-下


基本介绍

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

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

移除最多的同行或同列石头

题目链接

https://leetcode.cn/problems/most-stones-removed-with-same-row-or-column/

思路分析

⭐ 结论:同行或者同列的元素合并在一起,从边缘开始往中间消,一定会剩下一个石头

将同行或者同列的元素加入一个集合中,然后每个并查集最终会剩余一块石头,需要消除的石头数量为总的石头数量减去集合的个数

本题中石头的坐标很大,但是石头的个数不多,可以用两个 hash 表处理,key 为石头的坐标,value 为石头编号,一个为行表,记录 x 坐标处是否有石头,一个为列表,记录 y 坐标处是否有石头,后续判断是否同行或者同列只需要查表中的 key 是否存在,若存在,则说明同行或者同列

代码实现

java
class Solution {

    // key : 某行,value : 第一次遇到的石头编号
    HashMap<Integer, Integer> rowFirst = new HashMap<>();

    HashMap<Integer, Integer> colFirst = new HashMap<>();

    int MAXN = 1001;

    int[] father = new int[MAXN];

    int sets;

    public int removeStones(int[][] stones) {
        int n = stones.length;
        build(n);
        for (int i = 0; i < n; i++) {
            int row = stones[i][0];
            int col = stones[i][1];

            // 将同行或者同列石头的编号 union
            // 如果之间没有就放入,否则 union
            if (!rowFirst.containsKey(row)) {
                rowFirst.put(row, i);
            } else {
                union(i, rowFirst.get(row));
            }
            if (!colFirst.containsKey(col)) {
                colFirst.put(col, i);
            } else {
                union(i, colFirst.get(col));
            }
        }
        // 将同行或者同列的石头放入一个集合中
        // 每个集合中消除后最后一定会剩余一快石头
        // 消除石头的个数 = 总的石头数量 - 集合的个数
        return n - 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;
        }
        father[fx] = fy;
        // 合并和之后集合个数需要减少
        sets--;
    }
}

找出知晓秘密的所有专家

题目链接

https://leetcode.cn/problems/find-all-people-with-secret/

思路分析

本题采用给集合打标签的技巧,类似于并查集基础模板代码中的 size 数组,size [ i ] 表示 i 位置的元素所在集合的大小

本题简历 secret 数组,以该集合的根节点为代表,打标签,表示该集合的人是否知道秘密

根据题意,同一时间开会的人会传播秘密,按开会时间分组处理,所以首先需要将数组按照开会时间从小到大排序

接下来处理不同的组,同一时刻开会的人都放在一个集合中,如果集合中的某个人知道秘密,则该集合的人都会知道秘密,给该集合打上标签

如果该集合中无人知道秘密,会议结束,需要做小的撤销操作,将集合中的每个人还原为 build 的状态,即每个人单独一个集合

时间复杂度:O(m * log m) + O(m) + O(n),m 表示有几场会议

代码实现

java
class Solution {

    int MAXN = 100001;

    int[] father = new int[MAXN];

    // 打标签,secret[代表元素] 表示该集合的人是否知道秘密
    boolean[] secret = new boolean[MAXN];

    public List<Integer> findAllPeople(int n, int[][] meetings, int firstPerson) {
        build(n, firstPerson);
        // 按照开会时间排序
        Arrays.sort(meetings, (a, b) -> a[2] - b[2]);
        int m = meetings.length;

        for (int l = 0, r; l < m;) {
            // 首先进行分组,[l,r] 的元素都是在同一时间开会的
            r = l;
            while (r + 1 < m && meetings[l][2] == meetings[r + 1][2]) {
                r++;
            }

            // 同一时间开会的加入一个集合中
            for (int i = l; i <= r; i++) {
                union(meetings[i][0], meetings[i][1]);
            }

            // 小的撤销行为,如果集合中无人知晓秘密,拆散集合
            // 即将元素从集合中移除,回到 build 的状态,父节点为自己
            for (int i = l, a, b; i <= r; i++) {
                a = meetings[i][0];
                b = meetings[i][1];
                if (!secret[find(a)]) {
                    father[a] = a;
                }
                if (!secret[find(b)]) {
                    father[b] = b;
                }
            }
            // 来到下一组
            l = r + 1;
        }

        // 遍历每个专家,收集知晓秘密的专家,返回
        List<Integer> ans = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            if (secret[find(i)]) {
                ans.add(i);
            }
        }
        return ans;
    }

    public void build(int n, int first) {
        for (int i = 0; i < n; i++) {
            father[i] = i;
            // 初始时都不知道秘密
            secret[i] = false;
        }
        // 初始时,专家 0 把秘密告诉给了 firstPerson
        father[first] = 0;
        // 以集合的根节点为代表,打标签,表示该集合的人是否知道秘密
        secret[0] = true;
    }

    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;
        }
        father[fx] = fy;
        // 链接之后,查看该集合的根节点是否知道秘密
        // 是否知道秘密取决于该集合是否需要被拆散
        // 这里的 | 运算解释如下
        // (1)如果 y 集合知晓秘密,| 运算之后整个集合仍然知晓秘密
        // (2)如果 y 集合不知晓秘密,但是 x 集合知晓秘密,| 运算之后
        //     合并后的整个大集合中的人都知晓秘密
        secret[fy] |= secret[fx];
    }
}

好路径的数目(难)

题目链接

https://leetcode.cn/problems/number-of-good-paths/

思路分析

按照边中两个顶点的最大值从小到大排序,边中两个顶点最大值较小的边先处理

好路径的定义:(1)本节点到本节点的边,因为符合边的两个节点值相同(2)边的两个节点的值相同,且中间节点的值小于等于两个节点的值

打标签:每个集合记录(1)最大值(2)最大值的个数

遍历每一条边,判断两个节点是否相同,如果相同,则好路径的条数为两个节点所在集合的最大值个数相乘,如果不存在,什么也不做,最后都要将两个节点所在的集合合并

每个集合中以节点值较大的节点做代表节点,这样做的目的是好处理,如果是集合合并,则以两个集合中最大节点值较大的那个集合为基准,将最大节点值较小的集合合并到最大节点值较大的集合中

代码实现

java
class Solution {

    int MAXN = 30001;

    // 需要保证集合中,代表节点的值,一定是整个集合的最大值
    int[] father = new int[MAXN];

    // 集合中最大值的个数,也就是集合中代表节点的值有几个
    int[] maxcnt = new int[MAXN];

    public int numberOfGoodPaths(int[] vals, int[][] edges) {
        int n = vals.length;
        build(n);
        int ans = n;
        // 按照边中两个顶点的最大值从小到大排序
        Arrays.sort(edges, (e1, e2) -> (Math.max(vals[e1[0]], vals[e1[1]]) - Math.max(vals[e2[0]], vals[e2[1]])));
        for (int[] edge : edges) {
            ans += union(edge[0], edge[1], vals);
        }
        return ans;
    }

    public void build(int n) {
        for (int i = 0; i < n; i++) {
            father[i] = i;
            // 初始时自己为一个集合,代表节点为自己
            // 集合中的最大值个数为 maxcnt[代表节点]
            maxcnt[i] = 1;
        }
    }

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

    public int union(int x, int y, int[] vals) {
        // fx 表示 x 所在集合的代表节点,同时也是 x 所在集合最大值的下标
        int fx = find(x);
        // fy 表示 y 所在集合的代表节点,同时也是 y 所在集合最大值的下标
        int fy = find(y);

        int path = 0;
        // 边的两个节点值相同的才是好边
        // 不是好边,什么也不做,合并
        if (vals[fx] > vals[fy]) {
            // 遵循小挂大原则
            father[fy] = fx;
        } else if (vals[fx] < vals[fy]) {
            father[fx] = fy;
        } else if (vals[fx] == vals[fy]) {
            // 是好边,计算 path,合并
            path = maxcnt[fx] * maxcnt[fy];
            // 由于相等,谁挂谁都无所谓
            father[fy] = fx;
            // 集合合并,更新最大值的个数
            maxcnt[fx] += maxcnt[fy];
        }
        return path;
    }
}

尽量减少恶意软件的传播

题目链接

https://leetcode.cn/problems/minimize-malware-spread-ii/

思路分析

题目中指明 graph [ i ] [ j ] == graph [ j ] [ i ],则一定是无向图

将图中的节点分为两类,普通点,源头点(病毒点),普通点合并到一个集合中,如果一个集合被两个病毒点关联,则认为这个集合是无效的,因为移除一个病毒点之后还是会被感染

本题就是问移除感染源头点后,能拯救节点的最大个数,返回多个感染源头点中较小的编号

代码实现

java
class Solution {

    // 如果测试数据变大,就改变这个值
    int MAXN = 301;

    // 存储每个点是不是病毒
    boolean[] virus = new boolean[MAXN];

    // 删掉源头点之后,能拯救多少个节点
    int[] cnts = new int[MAXN];

    // 集合的标签 : 感染整个集合的点是什么点
    // a 是集合的代表点,整个集合的感染源头点是 infect[a]
    // infect[a] == -1,目前这个集合没有发现感染源头点
    // infect[a] >= 0,目前这个集合感染源头点是 infect[a]
    // infect[a] == -2,目前这个集合感染源头点不止一个,无法拯救
    int[] infect = new int[MAXN];

    int[] father = new int[MAXN];

    // 集合的标签 : 集合的大小是多少,并没有小挂大的含义
    int[] size = new int[MAXN];

    // 集合一定只放普通点,源头点根本不参与集合,也不是元素!

    public int minMalwareSpread(int[][] graph, int[] initial) {
        int n = graph.length;
        build(n, initial);

        // 不是病毒的点合并到一个集合中
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (graph[i][j] == 1 && !virus[i] && !virus[j]) {
                    union(i, j);
                }
            }
        }

        // 设置每个集合的感染源头点
        for (int sick : initial) {
            // 0 1 2 3
            // 1
            // 2 -> 病毒点
            // 3
            // 以上是节点的编号,题意是无向图,通过邻接矩阵表示是否相连
            // 遍历节点,判断是否与病毒点相连
            for (int neighbor = 0; neighbor < n; neighbor++) {
                // 邻居不能是自己,同时也不能是病毒,并且邻居与病毒点相连
                if (sick != neighbor && !virus[neighbor] && graph[sick][neighbor] == 1) {
                    // 找到邻居所在集合的代表点
                    int fn = find(neighbor);
                    // 给集合设置感染源头点
                    if (infect[fn] == -1) {
                        infect[fn] = sick;
                    } else if (infect[fn] != -2 && infect[fn] != sick) {
                        // 不等于 -1,则可能 > 0 或者 = -2
                        // 即说明(1)该集合已经有感染源头点了(2)已经无效了
                        // infect[fn] != -2 表示之前已经有感染源头点了,现在
                        // 又遇到一个感染源头点,该集合无法拯救,设置为 -2
                        // 1. 如果已经无效了,维持无效即可
                        // 2. 如果之前有感染源头点了( > 0 ),分两种情况讨论
                        // (1)源头点 = x:发现新的源头点是之前的源头点,维持 x
                        // (2)源头点 != x:集合中的一个元素已经连接了源头点,又发现
                        //      集合中的另一个元素连接了源头点 x,此时这个集合是拯救的
                        infect[fn] = -2;
                    }
                }
            }
        }

        // 统计拯救节点
        for (int i = 0; i < n; i++) {
            // 只关注集合的代表点,并且该集合有感染源头点
            if (i == find(i) && infect[i] >= 0) {
                // cnts 数组表示删掉源头点之后,能拯救多少个节点
                // 该感染源头点的贡献需要类加上该集合的大小
                cnts[infect[i]] += size[i];
            }
        }

        // 对所有感染源头点的编号排序,返回较小的那个
        Arrays.sort(initial);
        int ans = initial[0];
        int max = cnts[ans];
        for (int i : initial) {
            if (cnts[i] > max) {
                ans = i;
                max = cnts[i];
            }
        }
        return ans;
    }

    public void build(int n, int[] initial) {
        for (int i = 0; i < n; i++) {
            father[i] = i;
            size[i] = 1;
            virus[i] = false;
            cnts[i] = 0;
            infect[i] = -1;
        }
        for (int i : initial) {
            virus[i] = true;
        }
    }

    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;
        }
        father[fx] = fy;
        // 合并集合,更新集合的元素个数
        size[fy] += size[fx];
    }
}