并查集

203 阅读2分钟

了解并查集

并查集是一种树形结构。(根节点的父级节点为本身)

提供两种操作:
1、查找根节点
2、合并树

应用场景:
1、查找根节点
2、根据各个节点是否存在相同的根节点判断图是否连通

下边我们以一个实例说明下:

有两个根节点XY,A,B节点分别以XY节点作为父节点, 同时A,B节点各自存在子节点,假设当前A,B互不知道对方的存在,我们分别得到A,B树形结构

A:

B:

各个节点我们用List集合表示为

static List<String> nodes = Arrays.asList("X", "A", "A1", "A2", "Y", "B", "B1", "B2");

每个节点的父级节点也维护在一个List集合pNodes里,与A的索引位对应,例如nodes[0]为pNodes[0]的父节点,若没有父节点,我们记录其父节点为自身

static List<String> pNodes = Arrays.asList("X", "X", "A", "A1", "Y", "Y", "B", "B1");

查找根节点代码为:

    public static String findRoot(int index){
        String pNode;
        //我们根据根节点的父节点为根节点本身的特性判断是否为根节点
        if(nodes.get(index).equals(pNode = pNodes.get(index))){
            return nodes.get(index);
        }
        //继续向上查找父级节点
        return findRoot(nodes.indexOf(pNode));
    }

测试代码如下

    public static void main(String[] args) {
        for(int i=0; i<nodes.size(); i++){
            System.out.println(findRoot(i));
        }
    }

结果如下:

接下来合并A跟B,只需要将X的parent节点改为Y,或者Y的parent节点改为X,即可完成合并

    public static void union(int indexA, int indexB){
        pNodes.set(indexA, findRoot(indexB));
    }
     

测试代码如下

    public static void main(String[] args) {
        System.out.println("合并前-----------------");
        for(int i=0; i<nodes.size(); i++){
            System.out.println(findRoot(i));
        }
        union(0, 4);
        System.out.println("合并后-----------------");
        for(int i=0; i<nodes.size(); i++){
            System.out.println(findRoot(i));
        }
    }   

了解完查找根节点与合并之后,我们发现查找根节点的方法随着树的节点以及高度的增加,效率会越来越低,
而我们其实并关心树的各个节点的父级节点是谁,我们关心的只是各个节点的根节点,
则我们对之前结构做一下改造,即在pNodes中保存对应索引位的根节点
即并查集的路径压缩

PS:

以上示例选择了用两个list集合来表示,实际应用中根据场景自由选择数据结构数组、集合、链表;\

算法中的应用

下边我们结合leetcode中的算法来加深一下理解,做到灵活应用

冗余链接

题意

在本问题中, 树指的是一个连通且无环的无向图。

输入一个图,该图由一个有着N个节点 (节点值不重复1, 2, ..., N) 的树及一条附加的边构成。附加的边的两个顶点包含在1到N中间,这条附加的边不属于树中已存在的边。

结果图是一个以边组成的二维数组。每一个边的元素是一对[u, v] ,满足 u < v,表示连接顶点u 和v的无向图的边。

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

示例 1:

输入: [[1,2], [1,3], [2,3]] 输出: [2,3] 解释: 给定的无向图为: 1 /
2 - 3 示例 2:

输入: [[1,2], [2,3], [3,4], [1,4], [1,5]] 输出: [1,4] 解释: 给定的无向图为: 5 - 1 - 2 | | 4 - 3 注意:

输入的二维数组大小在 3 到 1000。 二维数组中的整数在1到N之间,其中N是输入数组的大小。

来源:力扣(LeetCode) 链接:leetcode-cn.com/problems/re…

解答

首先要理解题意,简单来说:
存在一棵N个节点的树,但在原树的基础上多出了一条边,导致树变为了一个存在回环的图,现在需要找出这条边
另外我们根据题意得到N个节点为数字1 - N

思路:
首先定义父级数组每个节点的父级节点定义为节点本身即为[0,...,N]
然后遍历输入的边,若边的两个顶点存在相同的根节点,则该边导致了回环,即为我们要查找的结果
若不存在,合并

    public static void main(String[] args) {
        RedundantConnection rd = new RedundantConnection();
        int[][] edges = {{1,2},{1,4}, {2,3}, {3,4},  {1,5}};
        int[] ints = rd.findRedundantConnection(edges);
        for(int i : ints){
            System.out.println(i);
        }
    }

    /**
     *  题意理解:
     *     给定一个图,该图为树多了一条边从而出现回环形成,找出该边返回
     *  思路:并查集
     */
    public int[] findRedundantConnection(int[][] edges) {
        int nodeNums = edges.length;
        //存储各个节点对应的父级节点,初始化赋值为自己
        int[] parents = new int[nodeNums+1];
        for(int i=1; i<= nodeNums; i++){
            parents[i] = i;
        }
        //遍历各边 若边的两个顶点存在相同的根节点 则该边会导致回环
        for(int i=0; i<edges.length; i++){
            int n1 = edges[i][0];
            int n2 = edges[i][1];
            if(findRoot(parents, n1) == findRoot(parents, n2)){
                return new int[]{n1, n2};
            }
            //合并 n1节点根节点的父级节点指向n2节点的根节点
            parents[findRoot(parents, n1)] = findRoot(parents, n2);
        }
        return new int[0];
    }

    /**
     * 查找节点n的根节点
     * @param parents 父级节点数组
     * @param n 节点
     * @return n的根节点
     */
    private int findRoot(int[] parents, int n){
        if(parents[n] == n){
            return n;
        }
        return findRoot(parents, parents[n]);
    }

省份数量

题意

有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。

省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。

给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。

返回矩阵中 省份 的数量。

示例 1:

输入:isConnected = [[1,1,0],[1,1,0],[0,0,1]] 输出:2

示例 2:

输入:isConnected = [[1,0,0],[0,1,0],[0,0,1]] 输出:3  

提示:

1 <= n <= 200
n == isConnected.length
n == isConnected[i].length
isConnected[i][j] 为 1 或 0
isConnected[i][i] == 1
isConnected[i][j] == isConnected[j][i]

来源:力扣(LeetCode)
链接:leetcode-cn.com/problems/nu…

解答

理解题意 :

存在n个城市,通过二维数组来记录城市两两之间是否相连,例如isConnected[2][3]=1 表示第二个城市跟第三个城市相连
相连的城市群构成一个省,求一共有多少省。

思路:

考虑并查集,共n个节点,遍历isConnected数组,若两个节点相连,做合并,遍历结束后得到并查集, 根据根节点的特性(根节点的父节点为自身)统计根节点数量,结果即为省份的数量

    public int findCircleNum(int[][] isConnected) {
        //节点数量
        int count = isConnected.length;
        //使用数组记录对应的父节点,开始初始化为各节点本身
        int[] pNodes = new int[count];
        for(int i=0; i<count; i++){
            pNodes[i] = i;
        }
        //遍历记录相连关系的二维数组
        for(int i=0; i<count; i++){
            for(int j=0; j<isConnected[i].length; j++){
                if(i == j){
                    continue;
                }
                if(isConnected[i][j] == 1){
                    //若相连 合并
                    union(i, j, pNodes);
                }
            }
        }
        int circles = 0;
        //根节点的数量即为省的数量
        for(int i=0; i<count; i++){
            if(i == pNodes[i]){
                //根节点:节点==parent[节点]
                circles++;
            }
        }
        return circles;
    }


    /**
     * 查找节点node的根节点(注意:node即为节点的值,也表示了在数组中的索引位置,pNodes[node]=node的父级节点)
     * @param node -
     * @return 根节点
     */
    private int findRoot(int node, int[] pNodes){
        if(node == pNodes[node]){
            //节点 == 父级节点 =》 node为根节点
            return node;
        }
        return findRoot(pNodes[node], pNodes);
    }

    private void union(int node1, int node2, int[] pNodes){
        if(node1 > node2){
            pNodes[findRoot(node1, pNodes)] = findRoot(node2, pNodes);
        }else {
            pNodes[findRoot(node2, pNodes)] = findRoot(node1, pNodes);
        }
    }

等式方程的可满足性

题意

给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程 equations[i] 的长度为 4,并采用两种不同的形式之一:"a==b" 或 "a!=b"。在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。

只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回 true,否则返回 false。 

 

示例 1:

输入:["a==b","b!=a"] 输出:false 解释:如果我们指定,a = 1 且 b = 1,那么可以满足第一个方程,但无法满足第二个方程。没有办法分配变量同时满足这两个方程。 示例 2:

输入:["b==a","a==b"] 输出:true 解释:我们可以指定 a = 1 且 b = 1 以满足满足这两个方程。 示例 3:

输入:["a==b","b==c","a==c"] 输出:true 示例 4:

输入:["a==b","b!=c","c==a"] 输出:false 示例 5:

输入:["c==c","b==d","x!=z"] 输出:true  

提示:

1 <= equations.length <= 500 equations[i].length == 4 equations[i][0] 和 equations[i][3] 是小写字母 equations[i][1] 要么是 '=',要么是 '!' equations[i][2] 是 '='

来源:力扣(LeetCode)
链接:leetcode-cn.com/problems/sa…

题解

理解题意:

根据提示:
1、只存在==和!=两种情况
2、单字母变量的范围为26个小写字母
3、== 是会传递的,即若 a==b, b==c, 那么 a==c

考虑并查集,==以及相互传递全部变量构成一个连通图,遍历完全部的==后,
我们便得到了全部的连通图。

然后遍历全部 != 的节点, 若!=的两个节点同时出现在了同一个连通图中,
即相当于两个节点即!=又==,此时结果为false。

public boolean equationsPossible(String[] equations) {
        //默认父级节点为自身 我们将小写字符转为从0-26的整数处理
        int length = 26;
        int[] pNodes = new int[length];
        for(int i=0; i<length; i++){
            pNodes[i] = i;
        }
        //遍历全部合并全部==的两个节点
        for(String c : equations){
            if(c.charAt(1) == '='){
                union(c.charAt(0)-'a', c.charAt(3)-'a', pNodes);
            }
        }
        //遍历全部!=的节点
        for(String c : equations){
            if(c.charAt(1) == '!'){
                if(find(c.charAt(0)-'a', pNodes) == find(c.charAt(3)-'a', pNodes)){
                    return false;
                }
            }
        }
        return true;
    }

    private int find(int i, int[] pNodes){
        return pNodes[i] == i ? i : find(pNodes[i], pNodes);
    }

    private void union(int i, int j, int[] pNodes){
        pNodes[find(i, pNodes)] = find(j, pNodes);
    }

婴儿名字

题意

每年,政府都会公布一万个最常见的婴儿名字和它们出现的频率,也就是同名婴儿的数量。有些名字有多种拼法,例如,John 和 Jon 本质上是相同的名字,但被当成了两个名字公布出来。给定两个列表,一个是名字及对应的频率,另一个是本质相同的名字对。设计一个算法打印出每个真实名字的实际频率。注意,如果 John 和 Jon 是相同的,并且 Jon 和 Johnny 相同,则 John 与 Johnny 也相同,即它们有传递和对称性。

在结果列表中,选择 字典序最小 的名字作为真实名字。

 

示例:

输入:names = ["John(15)","Jon(12)","Chris(13)","Kris(4)","Christopher(19)"], synonyms = ["(Jon,John)","(John,Johnny)","(Chris,Kris)","(Chris,Christopher)"] 输出:["John(27)","Chris(36)"]  

提示:

names.length <= 100000

来源:力扣(LeetCode)

链接:leetcode-cn.com/problems/ba…

题解

   /**
     * 整体思路:
     * 根据synonyms分组,名字相同的为同一组,遍历names,取同组的全部名字,数量求和,并标记已经求和的名字
     *
     */
    public String[] trulyMostPopular(String[] names, String[] synonyms) {
        NameItem[] nameItems = new NameItem[names.length];
        Map<String, NameItem> cacheMap = new HashMap<>(names.length);
        Map<NameItem, Set<NameItem>> cacheMapGroup = new HashMap<>();
        //初始化
        for(int i=0; i<names.length; i++){
            NameItem nameItem = new NameItem(names[i]);
            nameItems[i] = nameItem;
            cacheMap.put(nameItem.name, nameItem);
            Set<NameItem> set = new HashSet<>(); set.add(nameItem);
            cacheMapGroup.put(nameItem, set);
        }
        //构建连通图
        for(String s : synonyms){
            String[] split = s.substring(1, s.length() - 1).split(",");
            union(cacheMap.get(split[0]), cacheMap.get(split[1]), nameItems);
        }


        List<String> resList = new ArrayList<>();
        for(NameItem nameItem : nameItems){
            if(nameItem.parent == nameItem){
                if(!nameItem.visited){
                    nameItem.isVisited();
                    sum(nameItem, nameItems);
                    resList.add(nameItem.name + "(" + nameItem.count + ")");
                }
            }
        }
        //累积求和 查找存在相同根节点的为同名的
        String[] res = new String[resList.size()];
        resList.toArray(res);
        return  res;
    }

    private void sum(NameItem item, NameItem[] items){
        for(int j=0; j<items.length; j++){
            NameItem nameItemJ = items[j];
            if(!nameItemJ.visited && item == find(nameItemJ, items)){
                nameItemJ.isVisited();
                item.sum(nameItemJ.count);
            }
        }
    }

    private NameItem find(NameItem item, NameItem[] nameItems){
        return item.parent == item ? item : find(item.parent, nameItems);
    }

    private void  union(NameItem item1, NameItem item2, NameItem[] nameItems){
        if(item1 == null || item2 == null){
            return;
        }
        //字典大的往小的合并
        NameItem p1 = find(item1, nameItems);
        NameItem p2 = find(item2, nameItems);

        if(p1.name.compareTo(p2.name)>0){
            p1.parent = p2;
        }else {
            p2.parent = p1;
        }

    }



    static class NameItem{
        public String name;
        public int count;
        public NameItem parent;
        public boolean visited;

        public NameItem(String name){
            String[] split = name.split("\\(");
            this.name = split[0];
            this.count = Integer.parseInt(split[1].split("\\)")[0]);
            this.parent = this;
        }

        public void isVisited(){
            this.visited = true;
        }

        public void sum(int count){
            this.count += count;
        }

    }

略微生硬,执行时间超过了限制,待优化 //TODO