【算法】并查集

一、算法理解

并查集:给定一组元素,及其元素之间的关系,把存在直接、间接关系的元素组成一个集合。常用于解决如下类的问题:
(1)存在几个集合?
(2)最大集合容积?
(3)判断A、B是否在一个集合内?

样例场景【朋友圈】:一个班级有56名学生,存在直接好友关系(A、B是好友关系)、间接好友关系(A、B是好友,B、C是好友,则A、C是间接好友)的同学形成一个朋友圈。问:这个班级一共有几个朋友圈?


并查集主要有3个基本操作:

  1. makeSet(size):建立一个管理并查集的空间,包含 size 个集合元素。该操作通常比较隐晦,常用数组句int[] parent = new int[size] 直接替代makeSet。
  2. unionSet(x, y):把元素 x 和 y 所在“集合”合并成一个"集合",要求 x 和 y 所在原集合不相交,如果相交则不合并。
  3. find(x):找到元素x所在的集合的“代表”(通常在“集合”中选一个根元素作为代表)。
    • 该操作也可以用于判断两个元素是否在同一个集合,两个元素所在集合的“代表”是同一个,就表示在一个集合。
    • find(x)有两种实现方法:一种是 递归,一种是 非递归形式。

【并查集的实现算法]

  1. 常用int[] parent = new int[size]的 数组 来管理“并查集”结果。
  2. 每个“集合”都需要一个“代表”,集合中其他元素关联到这个 “代表”
    • 通常选择集合中某个元素来“代表”这个集合,该元素称为集合的"代表元"
    • 一个集合内所有元素组织成以 “代表元”为根 的树形结构。对于每一个元素x来说,parent[x]指向x在树形结构上的父亲节点,如果x是 根节点 则令 parent[x]指向自己,即parent[x] = x
    • 对于find(x)操作,可以沿着parent[x]不断在树形结构中向上移动,直到到达根节点(parent[x]=x的节点)

通过图示展示采用数组管理的并查集结构:
image
【注意】如上:

  • 并查集数组中值 表示的是集合树关系。通过并查集数组下标<—>原始数据下标隐式表达结果 和 原始数据元素映射关系。
  • 合并时,把集合2的“根节点” 变更成 指向 集合1“根节点”,即把集合2 挂到 集合1的根上。

【集合树均衡策略】
因为创建的集合树可能会严重不平衡,并查集可以用两种优化策略:

  1. 按秩合并 Union by Rank
    “按秩合并”,即总是将更小的树连接至更大的树上。在这个算法中,术语“秩”替代了“深度”,因为同时应用了路径压缩时秩将不会与高度相同。

  2. 路径压缩 Path Compression
    路径压缩:为了加快查找速度,查找时将x到根节点路径上的所有点的parent设为根节点,该优化方法称为压缩路径。


【并查集算法用途】:

  1. 维护无向图的连通性。支持判断两个点是否在同一连通块(集合)内。
  2. 判断增加一条边是否会产生环:用在求解最小生成树的Kruskal算法里。

二、注意事项

  1. 确认 孤立元素 是否 按照一个集合计算。此影响并查集 初始值 设置。如:
  • 孤立元素作为一个集合。初始值可设置为-1,代表元也是值为-1的元素。孤立元素也是自身集合的代表元。
  • 孤立元素不作为一个集合。初始值可设置为-1,代表元可设置为Index自身(u[index]==index)。
  1. 如果是求集合内元素个数,集合并完毕后,需遍历集合内元素,为统计效率需进行路径压缩(find时所有点的parent设为根节点

三、使用案例

1. 【第一类】找集合个数

1)朋友圈

班上有N名学生。其中有些人是朋友,有些则不是。他们的友谊具有传递性。如果已知 A 是 B 的朋友,B 是 C 的朋友,那么我们可以认为 A 也是 C 的朋友。所谓的朋友圈,是指所有朋友的集合。
给定一个 N * N 的矩阵 M,表示班级中学生之间的朋友关系。如果M/[i]/[j] = 1,表示已知第 i 个和 j 个学生互为朋友关系,否则为不知道。你必须输出所有学生中的已知的朋友圈总数。

示例 1:
输入: [[1,1,0],
[1,1,0],
[0,0,1]]
输出: 2

【思路分析】:

  1. 数组array[N]存放并查集分析结果。
  2. 单独一个学生也算一个朋友圈,所以数组初始值全部为-1,代表元也是值为-1。
  3. 遍历二维数组,如果M/[i]/[j] = 1,则Union(i, j)。
    • 如果array[i]、array[j]都是-1,说明两个元素都没在任何集合,则合并为一个新集合:array[j]=i;
    • 如果其中1个有集合,把无集合元素 挂到 已有集合根节点。
    • 如果2个都有集合,则把array[j]的“根节点” 挂到 array[i]所在集合根节点(代表元)。
  4. 遍历array[],统计array[x] = -1的数量。

代码参考:

    public static int findCircleNum ( int[][] matrix){
            if (matrix.length == 0 || matrix[0].length == 0 || matrix.length != matrix[0].length) {
                return 0;
            }

            int maxNum = matrix.length;
            int[] retArray = new int[maxNum];
            Arrays.fill(retArray, -1);
            for (int i = 0; i < maxNum; i++) {
                for (int j = 0; j < maxNum; j++) {
                    if (matrix[i][j] == 1 && i != j) {
                        union(retArray, i, j);
                    }
                }
            }

            int retNum = 0;
            for (int k = 0; k < maxNum; k++) {
                if (retArray[k] == -1) {
                    retNum++;
                }
            }
            return retNum;
    }

    public static void union (int[] retArray, int i, int j){
            int iParent = findRoot(retArray, i);
            int jParent = findRoot(retArray, j);

            if(iParent != jParent) {
                retArray[jParent] = iParent;
            }
    }

    public static int findRoot(int[] retArray, int i) {
            if(i < 0 || i > retArray.length) {
                return -1;
            }

            int index = i;
            while(retArray[index] != -1) {
               index = retArray[index];
            }

            return index;
    }

【扩展】如果不统计孤立元素,那么数组初始值-1,代表元为retArray[index]=index。如此:最终孤立元素值为-1,不会统计为集合。

代码差异点主要在:

  • findRoot判断逻辑:find到的孤立元素,设置retArray[index]=index。
  • 统计集合数条件。
    public static int findCircleNum ( int[][] matrix){
        if (matrix.length == 0 || matrix[0].length == 0 || matrix.length != matrix[0].length) {
            return 0;
        }

        int maxNum = matrix.length;
        int[] retArray = new int[maxNum];
        Arrays.fill(retArray, -1);
        for (int i = 0; i < maxNum; i++) {
            for (int j = 0; j < maxNum; j++) {
                if (matrix[i][j] == 1 && i != j) {
                    union(retArray, i, j);
                }
            }
        }

        int retNum = 0;
        for (int k = 0; k < maxNum; k++) {
            if (retArray[k] == k) {
                retNum++;
            }
        }

        return retNum;
    }

    public static void union (int[] retArray, int i, int j){
        int iParent = findRoot(retArray, i);
        int jParent = findRoot(retArray, j);

        if(iParent != jParent) {
            retArray[jParent] = iParent;
        }
    }

    public static int findRoot(int[] retArray, int i) {
        if(i < 0 || i > retArray.length) {
            return -1;
        }

        //如果是孤立节点,需要刷新其值我index
        if(retArray[index] == -1) {
            retArray[index] = index;
        }

        int index = i;
        while(retArray[index] != index) {
            index = retArray[index];
        }

        return index;
    }

2)冗余连接

输入一个图,该图由一个有着N个节点(节点值不重复1, 2, ..., N)的树及一条附加的边构成。附加的边的两个顶点包含在1到N中间,这条附加的边不属于树中已存在的边。
结果图是一个以边组成的二维数组。每一个边的元素是一对[u, v] ,满足u < v,表示连接顶点u 和v的无向图的边。

返回一条可以删去的边,使得结果图是一个有着N个节点的树。如果有多个答案,则返回二维数组中最后出现的边。答案边 [u, v] 应满足相同的格式 u < v。

样例:
输入: [[1,2], [1,3], [2,3]]

无向图为:
  1
 / \
2 - 3

输出: [2,3]

【题目分析】:

  1. 本题目可以考虑:存在边关系的节点,加入并查集。如果遍历到某条边[x,y],发现x、y节点已经在一个集合中,则这个边就是多余的。
  2. “如果有多个答案,则返回二维数组中最后出现的边”,上例中,[1,2], [1,3], [2,3]中任何一个边当做多余边,都是可以的,但是要求给出最后出现的边。So,对输入的处理过程是从前->后。

源码参考:

    public static int[] findCircleNum ( int[][] edges){
        if (edges.length == 0 || edges[0].length != 2) {
            return null;
        }

        int maxNum = edges.length * edges[0].length;
        int[] retArray = new int[maxNum];
        Arrays.fill(retArray, -1);

        for (int i = 0; i < edges.length; i++) {
            if (true == union(retArray, edges[i][0], edges[i][1])) {
                return edges[i];
            }
        }

        return null;
    }

    public static boolean union (int[] retArray, int i, int j){
        int iRoot = findRoot(retArray, i);
        int jRoot = findRoot(retArray, j);

        if(iRoot != jRoot) {
            retArray[jRoot] = iRoot;
            return false;
        }

        return true;
    }

    public static int findRoot(int[] retArray, int i) {
        if(i < 0 || i > retArray.length) {
            return -1;
        }

        int index = i;
        while(retArray[index] != -1) {
            index = retArray[index];
        }

        return index;
    }

3)岛屿数量

给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。

示例 1:
输入:grid = [
["1","1","1","1","0"],
["1","1","0","1","0"],
["1","1","0","0","0"],
["0","0","0","0","0"]
]
输出:1

【分析】:

  1. 这是个典型求集合个数的问题。
  2. 矩阵中每个点都是一个单独元素,用一个rowLength x colLenght长度的数组管理并查集。
  3. 孤立元素(0)的元素不做统计,代表元 和 孤立元素采用不同值。
  4. 合并条件是,[i][j]为1,并且上、左是1([i-1][j]/[i][j-1])。因为是从上到下、从做到右遍历,不需要判断下、右元素是否为1,后续会遍历到。

【注意】:

  1. 输入是"1"会转换成'1'字符比较,判断中用了数字1,导致未出结果。
  2. 自己处理中漏了一个场景,孤立的[1]点,这个时要设置arr[i] = i;但是不union;
  3. 自己和上、左元素比较时,选择了if-else if,“上”='1',则不再比较“左”,导致如下情况没处理
   arr[2][1]和arr[1][1]比较了,arr[2][1]和arr[2][0]未比较,导致漏掉了arr[2][0]
   [["1","1","0","0","0"],
    ["0","1","0","0","0"],
    ["1","1","1","0","0"],
    ["0","0","0","1","1"]]

代码参考:

    public static int numIslands(char[][] grid) {
        if (grid.length == 0) {
            return 0;
        }

        int rowNum = grid.length;
        int colNum = grid[0].length;
        int maxNum = rowNum * colNum;
        int[] retArray = new int[maxNum];
        Arrays.fill(retArray, -1);

        for (int i = 0; i < rowNum; i++) {
            for(int j=0; j < colNum; j++) {
                if(grid[i][j] == '1') {
                    int retIndex = i*colNum + j;

                    //先设置本节点为单点元素代表元,避免单点元素与无效元素混淆。
                    if(retArray[retIndex] == -1) {
                        retArray[retIndex] = retIndex;
                    }

                    //Here,上面,左边元素都需要比较 
                    if(i > 0 && grid[i-1][j] == '1') {
                        union(retArray, (i-1)*colNum + j,retIndex);
                    }
                    if(j > 0 && grid[i][j-1] == '1') {
                        union(retArray, i*colNum + j -1, retIndex);
                    }
                }
            }
        }

        int sum = 0;
        for(int k = 0; k < maxNum; k++) {
            if(retArray[k] == k) {
                sum += 1;
            }
        }

        return sum;
    }

    public static void union (int[] retArray, int i, int j){
        int iRoot = findRoot(retArray, i);
        int jRoot = findRoot(retArray, j);

        if(iRoot != jRoot) {
            retArray[jRoot] = iRoot;
        }
    }

    public static int findRoot(int[] retArray, int i) {
        if(i < 0 || i > retArray.length) {
            return -1;
        }

        int index = i;
        while(retArray[index] != index && retArray[index] != -1) {
            index = retArray[index];
        }

        return index;
    }

4)句子相似性

image

【分析】:

  1. 把Pairs转换成并查集。
  2. 2个句子长度一致,否则不相似
  3. 对比两个句子中同一位置的单词,属于同一并查集(pairs中代表元相同),相似;否则(单词不在pairs中,或 两者不在同一并查集),不相似。

代码参考:

   public static boolean numIslands(String[] words1, String[] words2, String[][] pairs) {
        if(words1.length != words2.length) {
            return false;
        }

        if(words1.length == 0) {
            return true;
        }

        if(pairs.length == 0 || pairs[0].length == 0) {
            return false;
        }

        int[] retArrays =  new int[2 * pairs.length];
        Arrays.fill(retArrays, -1);

        ArrayList<String> allPairsWords = new ArrayList<String>();
        for(int i=0;i < pairs.length; i++) {
            if(allPairsWords.indexOf(pairs[i][0]) == -1) {
                allPairsWords.add(pairs[i][0]);
            }

            if(allPairsWords.indexOf(pairs[i][1]) == -1) {
                allPairsWords.add(pairs[i][1]);
            }

            union(retArrays, allPairsWords.indexOf(pairs[i][0]), allPairsWords.indexOf(pairs[i][1]));
        }

        for(int k=0;k < words1.length; k++) {
            int rootIndex1 = allPairsWords.indexOf(words1[k]);
            int rootIndex2 = allPairsWords.indexOf(words2[k]);
            
            if(findRoot(retArrays, rootIndex1) != findRoot(retArrays, rootIndex2))
                return false;
        }

        return true;
    }

    public static void union (int[] retArray, int i, int j){
        int iRoot = findRoot(retArray, i);
        int jRoot = findRoot(retArray, j);

        if(iRoot != jRoot) {
            retArray[jRoot] = iRoot;
        }
    }

    public static int findRoot(int[] retArray, int i) {
        if(i < 0 || i > retArray.length) {
            return -1;
        }

        int index = i;
        while(retArray[index] != index && retArray[index] != -1) {
            index = retArray[index];
        }

        return index;
    }

3.基于集合找路径中最小元素(力扣)

1) ★★★得分最高路径

【题目描述】:给你一个 R 行 C 列的整数矩阵 A。矩阵上的路径从 [0,0] 开始,在 [R-1,C-1] 结束。 路径沿四个基本方向(上、下、左、右)展开,从一个已访问单元格移动到任一相邻的未访问单元格。路径的得分是该路径上的最小值。例如,路径 8 → 4 → 5 → 9 的值为 4 。
找出所有路径中得分最高的那条路径,返回其得分。

【思路】:
使用一个同样大小的集合[n]/[m]来标注路径寻找过程。
image

  1. 首先把[0]/[0]和[n]/[m]标注为1。
  2. 按照原始矩阵中各单元数值大小,从值 最大 的单元开始,逐个标准为1;(因为找的是最高分,所以从最大到小标准元素,只到[0]/[0]和[n]/[m]连通,其包含的元素值一定是所有路径中最大的
  3. 标注过程中,如果存在上、下、左、右单元同样为1,则把这些同时为1的元素合并集。
  4. 直到[0]/[0]和[n]/[m]两个单元在一个集合中,找到这个路径(集合)中的最小元素。

【数据结构】:

  1. 需要一个包含n x m个元素的队列,记录元素在原始矩阵中的位置,然后根据矩阵中的值从小到大排序。用于后续从最大元素开始标注1。可以考虑采用数组,每个单元内容为原始矩阵的下标:
    image
  2. 一个新的[n]/[m]矩阵,里面元素的值只能是0、或1,默认全部是0,按照原始矩阵单元值,从大到小逐个标注为1,用于根据上、下、左、右是否为1,决定是否合并集。
  3. 一包含n x m个元素的队列,用于记录并查集树。其元素索引 和 原始[n][m]矩阵的关系是,其index = i*n+j。

代码实现如下:

    //按照单个元素不选集合,需要统计最大集合数量书写逻辑
    public static int findMinNum(int[][] lists) {
        if(lists.length == 0 || lists[0].length == 0) {
            return -1;
        }

        int rowNum = lists.length;
        int colNum = lists[0].length;

        //定义一个n*m元素的数组NodesList,每个单元值是原始矩阵中单元的坐标,通过原始矩阵中单元的值从大到小,对nodeLists进行排序,用于后续根据值大小在标注矩阵中逐个标注
        Integer[] nodesList = new Integer[rowNum*colNum];
        for(int j = 0; j < nodesList.length; j++) {
            nodesList[j] = j; //j的下标,刚好是原始[n][m]矩阵坐标的i*cloNum + j
        }
        //按照其在原始矩阵中值按照从大到小排序
        Arrays.sort(nodesList, (i, j) -> ((Integer)lists[j/colNum][j%colNum]).compareTo((Integer)lists[i/colNum][i%colNum]));

        //创建Tags(标注)矩阵,默认值都是0,需查找的起点和终点是1
        int[][] tags = new int[rowNum][colNum];
        for(int i = 0; i < rowNum; i++) {
            Arrays.fill(tags[i], 0);
        }
        tags[0][0] = 1;
        tags[rowNum-1][colNum-1] = 1;

        //创建合并集管理集合,默认值都是-1
        int[] nodesTree = new int[rowNum*colNum];
        Arrays.fill(nodesTree, -1);

        //开始根据NodesList中顺序,按照原始矩阵中单元内值的从大到小,逐个在Tag[][]矩阵标注1,并把相邻的1单元合并集,直到[0][0]、[n-1][m-1]在同一集合
        int tmpRowIdx = 0;
        int tmpColIdx = 0;
        int tmpRowDirIdx = 0;
        int tmpColDirIdx = 0;
        int rootNode1 = 0;
        int rootNode2 = 0;
        //定义一个上、下、左、右集合index计算的数组,用于下面for循环查找
        int[][] dirs = new int[][]{{-1,0},{1,0},{0,-1},{0,1}};

        int minNum = Math.min(lists[0][0], lists[rowNum-1][colNum-1]);
        for(int l=0; l<nodesList.length;l++) {
            //从大到小遍历到的节点标注1
            tmpRowIdx = nodesList[l]/colNum;
            tmpColIdx = nodesList[l]%colNum;
            tags[tmpRowIdx][tmpColIdx] = 1;

            //遍历当前节点上、下、左、右,看是否合并集
            for(int m=0; m<dirs.length; m++) {
                tmpRowDirIdx = tmpRowIdx + dirs[m][0];
                tmpColDirIdx = tmpColIdx + dirs[m][1];

                if(tmpRowDirIdx >= 0 && tmpRowDirIdx < rowNum
                    && tmpColDirIdx >= 0 && tmpColDirIdx < colNum
                        && tags[tmpRowDirIdx][tmpColDirIdx] == 1) {
                    union(nodesTree, nodesList[l],tmpRowDirIdx*colNum + tmpColDirIdx);

                    //每合并完一次,都检查是否联通
                    rootNode1 = findRoot(nodesTree, 0);
                    rootNode2 = findRoot(nodesTree, rowNum*colNum - 1);
                    //如果连通,直接找集合中最小路径
                    if(rootNode1 == rootNode2) {
                        for(int g=0; g<nodesTree.length; g++) {
                            if(findRoot(nodesTree, g) == rootNode1) {
                                minNum = Math.min(minNum, lists[g/colNum][g%colNum]);
                            }
                        }
                        return minNum;
                    }
                }
            }

        }

        return -1;
    }

    public static void union(int[] retArray, int i, int j){
        int iRoot = findRoot(retArray, i);
        int jRoot = findRoot(retArray, j);

        if(iRoot != jRoot) {
            retArray[jRoot] = iRoot;
        }
    }

    public static int findRoot(int[] retArray, int i) {
        if(i < 0 || i > retArray.length) {
            return -1;
        }

        if(retArray[i] == -1) {
            return i;
        }

        //【重点】刷新所有节点都挂载到根节点下,加快后续遍历并查集速度。
        retArray[i] = findRoot(retArray, retArray[i]);
        return retArray[i];
    }

2)连通所有城市最小代价(力扣)

image

【思路】:
按照提供的路径代价从小到大排序,然后基于此顺序遍历输入的所有路径,直到所有城市成为1个集合。

注意点:

  1. 遍历某条路径时,如果发现两个城市已在1个合并集,忽略此多余路径。
  2. 数据结构:
    输入:u[m][3]
    中间数据结构:
    (1)Integer[m],用于路径代价排序。
    (2)cityList, 记录所有城市列表,建议用ArrayList。
    (3)cityTree[],记录城市的并查集树,下标对应城市在cityList索引位置。
    (4)int sumCort 累加联通代价。
  3. 过程
    可以遍历原始数据过程中,边向cityList[]中添加记录,边进行cityTree合并集。

3)以图辨树(力扣)

image

【分析】
本质上就是根据edgs进行合并集,最终判断是否所有节点都在一个集合中。
注意:

  1. 最终要遍历所有节点的root相同,为了减低复杂度,find时要尽量减少树的深度(把所有节点的parent都刷新为root)。

4)最小等级字符(力扣)

image

【思路】

  1. 遍历A、B字符串,把字符串中字符解析成多个合并集。
  2. 遍历合并集,把root节点调整为本合并集中最小元素。
  3. 把S字符串中每个字符替换为所在并查集的root。

5)无向图连通分量数目

image

【分析】:
本质就上找最终合并集数目。

6)减少恶意软件传播

image

【分析】:

  1. 遍历initial中元素
    • 把graph中对应元素位置置0,然后合并并查集,最终查询initial中剩余元素对应并查集大小。
  2. 重复1,找并查集最小的时刻。
posted @ 2021-07-22 15:51  小拙  阅读(164)  评论(0编辑  收藏  举报