使用HashMap实现并查集算法
一、等价关系简介
如果给定一个集合
S
,S
中有N
个元素,分别是{n1, n2, n3, ...}
,如果对于任意i
和j
,ni
和nj
有关系R
,记为nj R ni
,如果R
满足如下三个关系,则称R
为S
上的一个等价关系:(1)自反性,即
ni R ni
成立(2)对称性,即
ni R nj
则nj R ni
,其中(i !=j
)(3)传递性,
ni R nj
且nj R nk
,则ni R nk
,其中(i != j且j != k
)
二、等价类简介
如果R是S上的一个等价关系,对于任意n属于S,对于S中所有和n满足等价关系R的元素构成一个集合,这个集合就是n在S上关于R的一个等价类,很明显,对于任意一个在S上的等价关系,我们可以通过使用等价类的概念,对集合S进行划分,其中每一个划分就是一个等价类
三、等价类和并查集如何快速确定S中的任意两个元素是否属于同一个等价类呢?
对于两个等价类S1和S2,如果想将二者合并成一个等价类,该如何做呢?
答案就是使用并查集算法
四、并查集算法解决上述问题
-
对于含有N个元素的集合S,初始时,我们将S中的每一个元素,划分为一个等价类,即每一个等价类中只包含一个元素
-
如果两个元素之间存在等价关系,那么我们将这两个元素合并成一个集合,假设合并的是元素1和元素2,以及元素4和元素5,具体的做法是采用二叉树结构表示集合,并且在合并之前先判断两个元素是否在已经在同一个等价关系中了,如果在的话,就不进行合并,否则就将其中一个元素所在的等价类挂载到另外一个元素所在的等价类的孩子节点上。
- 如果最后发现元素2和元素5也存在等价关系,那么只需要将这两个等价关系合并到一棵二叉树中即可,这里将元素1和2所在的二叉树合并到元素4和5所在的二叉树中
五、关于四中存在的几点疑问
-
以二叉树结构表示集合,二叉树有很多表示形式,我们采用哪一种呢?
-
如何判断两个元素是否已经在同一个等价类中了呢?
-
如何两个元素不在同一个等价类中,那么将两个元素所在的等价类S1和S2进行合并的时候,到底应该将S1合并到S2还是应该将S2合并到S1呢?如果选择的是S1合并到S2,那么S1应该成为S2中哪个节点的孩子节点?
六、并查集疑问解答
6.1 关于5.1中的问题
并查集算法在实现的时候,并不是使用常见的左右孩子指针的形式来实现,而是使用的是双亲表示法,即每一个节点只记录它的父亲节点,至于为什么这么表示,因为在并查集中我们只需要查找某个节点的父节点是谁,并不关心该节点的孩子节点的查找,这和并查集的算法思想相关
6.2 关于5.2中的问题
判断两个元素是否已经在同一个等价类中了,这个算法其实是并查集的一个核心算法,它需要借助并查集的
find
算法实现,如果我们在实现并查集算法的时候,能够给每一个等价类一个标签,那么我们只需要比较两个元素所在等价类的标签是否相等,即可判断这两个元素是否在同一个等价类中了,那么问题来了,这个标签该如何给出呢?我们不妨联想一下树形结构的特点,因为S是一个集合,集合中的元素是不允许重复的,因此每一个等价类构成的二叉树中的元素也是不会重复的,所以我们可以以每一个等价类构成的二叉树的根节点的值作为该等价类的标签,因为我们使用的是双亲表示法,从某个节点出发向上找到它所在集合的根节点是十分容易的
ps:我们会让等价类的根节点的双亲节点等于它自己,为什么要这样做,因为这样在回溯的过程中可以借助这个信息判断是否到达了根节点
6.3 关于5.3中的问题
如何将两个等价类合并为一个等价类,这个算法也是并查集的核心算法,它需要借助并查集的
union
算法实现
6.3.1 关于5.3中的第一个问题,S1合并到S2还是S2合并到S1该如何选择?
假设n1属于S1中的元素,n2属于S2中的元素,现在想将n1所在的S1合并到n2所在的S2,让这两个等价类变为一个,我们有两种方案选择是将S1合并到S2还是S2合并到S1
(1)元素个数少的合并到元素个数多的(一般采用这种方法实现)
(2)高度矮的合并到高度高的
为什么推荐使用(1)中的方法,其实和后面对find算法的优化有关系,对于find算法,为了提高它的速度,我们后面会讲到对它的路径压缩优化,而路径压缩优化,会改变节点的高度信息,这使得高度的更新比较麻烦,但是路径优化能很好的兼容大小合并的方法。
从这两个方法中,我们也可以看出来,我们还需要在我们的并查集算法中额外的维护每一个集合的大小或者高度等信息
综上,并查集算法需要维护三两个基本信息
(1)每一个元素的父亲元素是谁(向上回溯可以找到该元素所在等价类的根节点,辅助(3))
(2)当前元素所在的等价类的大小或者高度是多少(决定谁合并到谁)
(3)当前元素所在的等价类的根节点是谁(当前等价类的唯一标识)
七、并查集算法的HashMap实现(采用比较大小的方法合并)
7.1 使用HashSet表示集合S
因为S是集合元素不重复,而HashSet刚好保存不重复的元素
7.2 使用HashMap结构记录每一个节点的双亲节点
HashMap的key是集合元素类型,值是该元素对应的父元素,初始时,每一个元素的父元素是其本身
7.3 使用HashMap结构记录每一个元素所在等价类的大小
HashMap的key是集合中的元素类型,值是该元素所在等价类的大小,在并查集的实现过程中,只有根节点的元素key-value键值对是有效的,其他非根节点是无效的(即在更新的过程中可以不用维护),为什么可以这样?原因很简单,一旦一个元素成为了别的元素的子节点,那么这个元素将永远不会成为根节点,因为并查集的合并就是将一个集合挂到另外一个集合的孩子节点下面,因此我们只需要维护根节点所在的集合大小值就可以了
7.4 代码实现
这是leetcode128题的题解,使用并查集解的,效率不是很好,解决这道题,但是可以借助这道题很好的理解并查集算法
class Solution {
public int longestConsecutive(int[] nums) {
if (nums == null) {
return 0;
}
if (nums.length == 0) {
return 0;
}
Integer[] integers = new Integer[nums.length];
HashSet<Integer> set = new HashSet<>();
for (int i = 0; i < integers.length; i++) {
integers[i] = nums[i];
set.add(integers[i]);
}
UF<Integer> uf = new UF<>(integers);
for (int n : nums) {
if (set.contains(n - 1)) {
uf.union(n, n - 1);
}
if (set.contains(n + 1)) {
uf.union(n, n + 1);
}
}
Map<Integer, Integer> sizeMap = uf.getSizeMap();
int max = Integer.MIN_VALUE;
for (Integer n : sizeMap.keySet()) {
if (max < sizeMap.get(n)) {
max = sizeMap.get(n);
}
}
return max;
}
public static void main(String[] args) {
Solution solution = new Solution();
System.out.println(solution.longestConsecutive(new int[]{0, 0, -1}));
}
/**
* 并查集算法的实现
*/
private static class UF<T> {
// 记录每一个元素的父元素是谁
// 本质上就是一个双亲表示法表示的二叉树
private final Map<T, T> parentMap;
// 记录每一个元素所在等价类的大小,记住sizeMap只维护根节点的
// 的大小,其他节点的值并不是正确和有效的,它正不正确不影响算法
// 的正确性
private final Map<T, Integer> sizeMap;
public Map<T, Integer> getSizeMap() {
return sizeMap;
}
public Map<T, T> getParentMap() {
return parentMap;
}
// 初始化,必须保证items的元素是不重复的
public UF(T[] items) {
parentMap = new HashMap<>();
sizeMap = new HashMap<>();
for (T item : items) {
// 初始时每一个元素就是一个等价类
// 它的父亲元素就是它自己
parentMap.put(item, item);
sizeMap.put(item, 1);
}
}
// 返回item所在集合的名称
public T find(T item) {
// 得到当前item的父亲元素
T parent = parentMap.get(item);
// 如果它的父亲元素不是它自己,那么说明它已经成为了别的元素的孩子节点
// 如果是自己,那么说明它就是自己所在等价类的根节点
if (parent.equals(item)) {
return parent;
}
// 递归找自己所在等价类的跟节点
// find算法可以很容易改成迭代的方式
// 这里的parent其实是item所在等价类的根元素
parent = find(parent);
// 如果找到了根元素,那么这里采用路径压缩
// 直接将item的父亲元素设置为根元素,路径压缩并不会改变根元素所在等价类的
// size信息,因此可以很好的和大小比较兼容
parentMap.put(item, parent);
return parent;
}
// 合并算法
public boolean union(T item1, T item2) {
// 找到item1和item2所在等价类的根元素
T item1Parent = find(item1);
T item2Parent = find(item2);
// 如果两个元素的根元素是相等的,那么说明已经在一个等价类中了
// 不用合并
if (item1Parent.equals(item2Parent)) {
return false;
}
// 查询item1和item2所在等价类的大小
int item1Size = sizeMap.get(item1Parent);
int item2Size = sizeMap.get(item2Parent);
if (item1Size < item2Size) {
// 如果item1所在等价类更小
// 首先修改item1所在等价类的根节点指向item2所在等价类的根节点
parentMap.put(item1Parent, item2Parent);
// 修改item2所在等价类的大小
sizeMap.put(item2Parent, item1Size + item2Size);
} else {
// 同上分析,是对称的
parentMap.put(item2Parent, item1Parent);
sizeMap.put(item1Parent, item1Size + item2Size);
}
return true;
}
}
}