什么是并查集
假设我们目前有6个样本 a、b、c、d、e、f。每个样本所在的集合都只有自己({a}、{b}、{c}、{d}、{e}、{f})。
并查集是一个维护着很多集合的一个结构(将所有的样本先建成一个集合,集合中只有自己),并且提供2个方法。
isSameSet(V a,V b)这个方法用来判断传进来的a、b样本所在集合是否在同一个集合,返回true和false。
union(V a,V b)这个方法将a所在集合和b所在集合,变成一个集合。
方法并不少见,但并查集的特点在于,如果有N个样本,调用两个方法很频繁,并查集可以做到均摊下来单次时间复杂度为
O
(
1
)
O(1)
O(1)
什么是均摊?
比如说样本量很大,N = 1E,调用两个方法的次数很频繁 , 大于1E,那每次调用的时间复杂度平均下来就是一个常数时间
O
(
1
)
O(1)
O(1)
举例
有{a}、{b}、{c}三个样本,每个样本都有属于自己的集合List A, List B,List C。还有一个记录着样本集合对应关系的HashMap,HashMap<A,List A>,HashMap<B,List B>,HashMap<C,List C>。
此时将A 和 C 的集合进行合并。
合并完之后就是{a,c}、{b}、 List A, List B 和 HashMap<A,List A>、HashMap<B,List B>,HashMap<C,List A>的形式。
a,c样本合并的过程,首先要将List集合进行合并,再将Map中索引修改。如果A,C集合中各有100W数据,两个List合成一个,在将其中100W集合的Map索引进行修改,肯定不是一个
O
(
1
)
O(1)
O(1)的操作。
List能实现,但是效率不好。
并查集实现
来看看并查集实现集合Union的原理。
同样6个样本a、b、c、d、e、f,并查集会在样本中包上一层,每一个样本有一个指针,指向自己。
为什么要指向自己呢?
比如,a,c样本所在集合到底是不是一个集合?
一开始肯定都不是,a,c集合顺着节点一路向上找,向上到不能再向上。a还是a,c还是c。这种一直向上找节点,最终找到的节点叫做代表节点也可以理解为父节点。a的代表节点和c的代表节点不是一个,所以说a和c不在一个集合内。
接下来,将a,c两个集合进行合并。
同样找代表节点,并且如果能够获取到两个集合大小的话,比如说a:1, c:1,此时将c的指针指向a,完成合并。再次判断a,c是否是同一个集合,a的代表节点还是a,但是c的代表节点变成了a,是同一个集合。
再试一次,这回判断c和d是否是同一个集合。
c的代表节点此时是a,d的代表节点是自己d。不是同一个集合,进行合并,c向上到不能再向上找到a,大小 = 2,d向上到不能在向上找到d 大小 = 1,小的挂大的,将d直接挂在a的下面。
再来。
判断b和e是否是同一个集合,不是,进行合并,大小相同谁挂谁无所谓。合并后如图。
此时判断,c所在集合和d所在集合,是否是同一个集合,不是,因为c向上找到的代表节点是a,d找到的代表节点是b,进行合并 a = 3, b = 2小的挂大的。代表节点直接过去。完成合并。
只改了一次指针完成合并,并且通过代表节点来判断是在哪一个集合。
代码
/*
* 将给定的样本V封装成Node,图中样本包含的圈
* */
public static class Node<V> {
V val;
public Node(V value) {
this.val = value;
}
}
public static class UnionFind<V> {
//对于用户来讲,不知道你包Node,依然是通过样本V来实现逻辑交互
//所以nodes是用来存放样本V和封装Node的关系
HashMap<V, Node<V>> nodes;
//key的代表节点(parent)是value
//用这个Map表示图中指针
HashMap<Node<V>, Node<V>> parentMap;
//集合大小
//不是所有节点都有记录,只有代表节点才在这个Map中有记录
HashMap<Node<V>, Integer> sizeMap;
public UnionFind(List<V> values) {
//初始化
nodes = new HashMap<>();
parentMap = new HashMap<>();
sizeMap = new HashMap<>();
//遍历传进来的样本V,并进行赋值操作
for (V val : values) {
//将V封装成Node
Node node = new Node(val);
nodes.put(val, node);
//默认我的parent是我自己
parentMap.put(node, node);
//默认大小为1
sizeMap.put(node, 1);
}
}
/*
* 根据Node找到代表节点
* */
public Node<V> findParent(Node<V> node) {
Stack<Node> stack = new Stack<>();
//如果 != ,说明上面还有代表节点,一直找
//将找到的沿途节点存放到Stack中
//此时找完node就是它的代表节点,return这个node即可
while (node != parentMap.get(node)) {
node = parentMap.get(node);
stack.push(node);
}
//沿途链上所有节点的代表节点都更改为我最上面的代表节点
//作用是使其扁平化,减少层数
while (!stack.isEmpty()) {
parentMap.put(stack.pop(), node);
}
return node;
}
/*
* 对于用户来说,依然是用样本V进行交互
* 看样本a,b的代表节点是否是同一个
* */
public boolean isSameSet(V a, V b) {
return findParent(nodes.get(a)) == findParent(nodes.get(b));
}
/*
* 样本a,b进行合并
* */
public void union(V a, V b) {
Node aNode = findParent(nodes.get(a));
Node bNode = findParent(nodes.get(b));
//代表节点不同,说明不是同一个
if (aNode != bNode) {
//获取a,b集合大小
int aSize = sizeMap.get(aNode);
int bSize = sizeMap.get(bNode);
//找到大节点和小节点。
Node big = aSize >= bSize ? aNode : bNode;
Node small = big == aNode ? bNode : aNode;
//小挂大
parentMap.put(small, big);
//样本中对应集合数量修改
sizeMap.put(big, aSize + bSize);
//两个集合进行了合并,small的代表节点发生了变化,remove掉当前的small的大小
sizeMap.remove(small);
}
}
public int getSize(){
return sizeMap.size();
}
}
总结
并查集的好处就在于小挂大以及代码中的findParent()方法,如果整个集合的链很长,那每次遍历都很耗时,小挂大可以让链增长的缓慢,而findParent()方法在union和isSameSet中都有调用,如果两个方法调用很频繁,每次通过findParent找代表节点,可以使链上结构扁平化的话,每次查找都很快,一次就可以找到代表节点(第一次遍历耗时一些)。
两个操作目的都是减少链长度。
结论
如果样本量为N,如果findParent调用次数到达了
O
(
N
)
O(N)
O(N)或者超过了
O
(
N
)
O(N)
O(N),那么单次平均下来查询代价
O
(
1
)
O(1)
O(1)。