开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情
概述
并查集(英文:Disjoint-set data structure,直译为不交集数据结构)常用于处理元素之间的相交关联问题。他在基础的树形数据结构上增加部分定义操作,以实现存储结点之间的关联关系和查询结点之间的关联关系。
一般的并查集实现往往遵循实现以下基础操作:
- 添加:在并查集中添加单个结点
- 合并:将并查集中的两个结点相关联
- 查询:寻找当前结点的关联结点
基础定义过于抽象,咱一步一步的来,逐步的吃透这个特殊的数据结构:并查集
抛出问题
为了引出并查集,此处先抛出一个关联关系处理问题:
- 我们定义二维关系组[[userA, userB], [userB, userC] ....]
- 关系组中的每对关系代表两个用户之间可以相互说话, 但如果
userA能够和userB说话,userB能够和userC说话, 则userA与userC即使不存在关系, 也视为他们是可以互相说话的(通过userB传话)- 要求实现方法: 通过输入的关系组
linkList, 判断userA与userB是否能够互相说话
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程序一定就想到了: 加缓存!
2. 缓存优化
那以上的例子如何加缓存呢? 哎~ 可不要小瞧了那个为了防止你重复BFS而设置的Set<String> mark = new HashSet<>(); 我们假设遍历结束,user1和user2关联,那么大声的告诉我这意味着什么?
这意味着user1所处的图所有结点都能够和user2沟通,故这个mark中的所有结点也都能与user2沟通,如果我们将其缓存下来,当下次遇到可以直接或间接的通过缓存加快查询的速度。
我们都知道缓存是空间换时间,因此我们还不得不评估一下空间复杂度: 在查询次数足够多的极端情况下,缓存中任意结点都将互相之间存在一个缓存,因此为O(n^2)
3. 优化缓存
上面的缓存很明显不可取,如果缓存各个结点之间的关联关系将导致缓存数据过多。 但由题分析,我们实际讨论的是图与图之间是否相互关联,那是否只需要缓存用户与图之间的关系就可以了呢?
换句话说: 如果各个用户都知道自己所属图的编号,那只需要对比两个人的编号便可知道两个人是否在同一个图里了。
图和编号不容易带入题目,我们可以结合题目再换句话说: 每个能够互相联系的团体都选取一个社牛, 保证任意能够互相联系的两个人心中的社牛是同一个人。 那么要知道两个人是否能够说话,只需要看他们选中的社牛是否是同一个人即可。
这便是并查集的诞生: 将图问题转化为树问题,图演化为树主动推举一个根结点,由根节点来决策相交问题。
基础定义
并查集中维护着多颗树,通过对树进行关联、查询、添加操作,以提供集合关联查询的结果
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);
}
但对于深度较深的树,如果每次都完整递归将浪费大量的性能,因此我们可以优化一下,已知最终递归返回的一定是根结点,何不修改一下直接将自己关联到根节点下,这样下次查询时只需要进行单次查询即可:
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)
}
此处聪明的网友一定也能发现问题: 总是将左边的树作为右边的子树貌似不是最优的方案。 如上图中,将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次求执行平均值
统计实际执行时长如下:
浅练一下
talk is cheap, 各位有兴趣的可以自己练练手哦~(tips:LC684原题)
- 树可以看成是一个连通且无环的无向图。
- 给定往一棵 n 个结点 (节点值 1~n) 的树中添加一条边后的图。添加的边的两个顶点包含在 1 到 n 中间,且这条附加的边不属于树中已存在的边。图的信息记录于长度为 n 的二维数组 edges ,edges[i] = [ai, bi] 表示图中在 ai 和 bi 之间存在一条边。
- 请找出一条可以删去的边,删除后可使得剩余部分是一个有着 n 个节点的树。如果有多个答案,则返回数组 edges 中最后出现的边。