什么!你还没掌握简单好用的并查集?

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情

概述

并查集(英文:Disjoint-set data structure,直译为不交集数据结构)常用于处理元素之间的相交关联问题。他在基础的树形数据结构上增加部分定义操作,以实现存储结点之间的关联关系和查询结点之间的关联关系。

一般的并查集实现往往遵循实现以下基础操作:

  1. 添加:在并查集中添加单个结点
  2. 合并:将并查集中的两个结点相关联
  3. 查询:寻找当前结点的关联结点

基础定义过于抽象,咱一步一步的来,逐步的吃透这个特殊的数据结构:并查集

抛出问题

为了引出并查集,此处先抛出一个关联关系处理问题:

  • 我们定义二维关系组[[userA, userB], [userB, userC] ....]
  • 关系组中的每对关系代表两个用户之间可以相互说话, 但如果userA能够和userB说话, userB能够和userC说话, 则userAuserC即使不存在关系, 也视为他们是可以互相说话的(通过userB传话)
  • 要求实现方法: 通过输入的关系组linkList, 判断userAuserB是否能够互相说话
public static boolean canTalk(List<List<String>> linkList, String userA, String userB);

1. 图论实现

简单分析题目,简化而言就是: 将linkList中的每两个String字符串相连,形成多个图,最终判断两个字符串是否位于同一个图上。

因此我们可以通过完成模拟的方式,将所有数据划为多张图,再通过对userA所在的图进行BFS,寻找是否存在userB即可:

public static boolean canTalk(List<List<String>> linkList, String userA, String userB) {
    Map<String, Set<String>> linkMap = new HashMap<>(16);

    // 记录为抽象的图
    for (List<String> link : linkList) {
        String user1 = link.get(0);
        String user2 = link.get(1);
        linkMap.computeIfAbsent(user1, str -> new HashSet<>()).add(user2);
        linkMap.computeIfAbsent(user2, str -> new HashSet<>()).add(user1);
    }

    // 从userA开始, BFS找userB
    Queue<String> queue = new ArrayDeque<>();
    // 记录, 避免因为图的环导致重复走
    Set<String> mark = new HashSet<>();
    
    queue.offer(userA);
    while(!queue.isEmpty()) {
        String pollItem = queue.poll();
        
        // 如果找到了userB, 则说明AB在同一个图中, 他们互相能够联通
        if(userB.equals(pollItem)) {
            return true;
        }

        // 否则记录一下已经找过当前节点了, 并筛选当前结点的后续结点进行继续遍历
        mark.add(pollItem);
        linkMap.get(pollItem).stream().filter(str -> !mark.contains(str)).forEach(queue::offer);
    }
    return false;
}

看似问题完美解决,执行起来也符合期望,确实输出了正确的答案。 但这里仅仅是进行了单次的查询,如果我们提高查询的量:

public static List<Boolean> canTalk(List<List<String>> linkList, List<List<String>> searchList);

每次请求都将进行一次图的BFS,在极端情况下时间复杂度为O(n * m),性能不差但也不是最乐观的。

那该如何进行优化呢? 这时聪明的crud程序一定就想到了: 加缓存! image.png

2. 缓存优化

那以上的例子如何加缓存呢? 哎~ 可不要小瞧了那个为了防止你重复BFS而设置的Set<String> mark = new HashSet<>(); 我们假设遍历结束,user1和user2关联,那么大声的告诉我这意味着什么?

image.png

这意味着user1所处的图所有结点都能够和user2沟通,故这个mark中的所有结点也都能与user2沟通,如果我们将其缓存下来,当下次遇到可以直接或间接的通过缓存加快查询的速度。

我们都知道缓存是空间换时间,因此我们还不得不评估一下空间复杂度: 在查询次数足够多的极端情况下,缓存中任意结点都将互相之间存在一个缓存,因此为O(n^2)

image.png

3. 优化缓存

上面的缓存很明显不可取,如果缓存各个结点之间的关联关系将导致缓存数据过多。 但由题分析,我们实际讨论的是图与图之间是否相互关联,那是否只需要缓存用户与图之间的关系就可以了呢?

换句话说: 如果各个用户都知道自己所属图的编号,那只需要对比两个人的编号便可知道两个人是否在同一个图里了。

编号不容易带入题目,我们可以结合题目再换句话说: 每个能够互相联系的团体都选取一个社牛, 保证任意能够互相联系的两个人心中的社牛是同一个人。 那么要知道两个人是否能够说话,只需要看他们选中的社牛是否是同一个人即可。

这便是并查集的诞生: 将图问题转化为树问题,图演化为树主动推举一个根结点,由根节点来决策相交问题。

image-20230202145850024.png

基础定义

并查集中维护着多颗树,通过对树进行关联、查询、添加操作,以提供集合关联查询的结果

1. 并查集的新增

较为简单,新增单个接点即新增一个高度为1的树,不赘述

class DisjointSetUnion {
    /**
     * 记录 子节点 - 父节点, 当key==value时代表当前为根结点
     */
    Map<String, String> treeMap = new HashMap<>();
    
    
    public void add(String str) {
        if(str == null || str.length() == 0) {
            return;
        }
        treeMap.put(str, str);
    }
}

2. 并查集的查询

并查集的查询为其根结点的查询,即我们上面举例的“社牛”的查询,只需要递归的向上进行查找,直到找到根进行返回即可

public String find(String str) {
    String father = treeMap.get(str);
    return father.equals(str) ? father : find(father);
}

但对于深度较深的树,如果每次都完整递归将浪费大量的性能,因此我们可以优化一下,已知最终递归返回的一定是根结点,何不修改一下直接将自己关联到根节点下,这样下次查询时只需要进行单次查询即可:

image-20230202151723389.png

public String find(String str) {
    String father = treeMap.get(str);
    if(father == null) {
        return null;
    }
    
    if(!father.equals(str)) {
        father = find(father);
        treeMap.put(str, father);
    }
    return father;
}

针对这样的优化,我们将其称之为并查集的路径压缩

3. 并查集的关联

并查集的关联为并查集中较为重要的一块内容,由上分析我们也已知,需要关联两个结点,只需要让他们的根结点相关联即可

public void union(String str1, String str2) {
    String root1 = find(str1);
    String root2 = find(str2);
    if(root1 == null || root2 == null || root1.equals(root2)) {
        return;
    }
    
    treeMap.put(root1, root2)
}

image-20230202152604389.png

此处聪明的网友一定也能发现问题: 总是将左边的树作为右边的子树貌似不是最优的方案。 如上图中,将user3作为user7的子节点,导致了user3下所有节点(共6个)均发生了层级增加。层级增加即意味着调用find方法时会导致多一次递归。

层级的增加是无法避免的,但我们可以选择相对节点较少的一方作为子树,减少其增加层级的作用基数:

Map<String, Integer> countMap = new HashMap<>();

public void union(String str1, String str2) {
    String root1 = find(str1);
    String root2 = find(str2);

    if (root1 == null || root2 == null || root1.equals(root2)) {
        return;
    }

    Integer count1 = countMap.get(root1);
    Integer count2 = countMap.get(root2);
    if(count2 < count1) {
        // 总是将结点更少的放在root1
        String temp = root1;
        root1 = root2;
        root2 = temp;
    }
    // 将root1接在root2上
    treeMap.put(root1, root2);
    countMap.put(root2, count1 + count2);
}

public void add(String str) {
    if(str == null || str.length() == 0) {
        return;
    }
    treeMap.put(str, str);
    countMap.put(str, 1);
}

这样的优化被成为并查集的按秩合并

回到问题

通过引入并查集,我们可以将代码修改为如下:

public static List<Boolean> canTalk2(List<List<String>> linkList, List<List<String>> searchList) {
    DisjointSetUnion union = new DisjointSetUnion();

    for (List<String> link : linkList) {
        String user1 = link.get(0);
        String user2 = link.get(1);
        union.add(user1);
        union.add(user2);
        union.union(user1, user2);
    }

    return searchList.stream()
        .map(s -> union.find(s.get(0)).equals(union.find(s.get(1))))
        .collect(Collectors.toList());
}

通过使用并查集,在相同的空间复杂度的情况下提高了查询的效率。呐~ 光凭嘴巴说没用,我写了一个随机生成测试用例并统计执行时间的程序:

  • 定义n为linkList大小,m为searchList大小
  • 其中样本数据为随机user0 ~ user999999, searchList中string一定包含在linkList中
  • 为了避免特殊用例造成的数据不准确,此处对于每一个n和m,都会执行5次求执行平均值

统计实际执行时长如下:

image-20230202162611598.png

浅练一下

talk is cheap, 各位有兴趣的可以自己练练手哦~(tips:LC684原题)

  • 树可以看成是一个连通且无环的无向图。
  • 给定往一棵 n 个结点 (节点值 1~n) 的树中添加一条边后的图。添加的边的两个顶点包含在 1 到 n 中间,且这条附加的边不属于树中已存在的边。图的信息记录于长度为 n 的二维数组 edges ,edges[i] = [ai, bi] 表示图中在 ai 和 bi 之间存在一条边。
  • 请找出一条可以删去的边,删除后可使得剩余部分是一个有着 n 个节点的树。如果有多个答案,则返回数组 edges 中最后出现的边。