并查集——Java语言实现
并查集(Union Set)介绍
并查集是一种树型数据结构,常用于处理一些不相交集合的合并及查询问题。每个并查集内都有若干个集合,每个集合都有一个代表节点。若将并查集看作森林,则其内部每个集合都是一颗树,代表节点即为树根。但和普通树不同的是,这里的树不再是父节点拥有子节点的引用,而是子节点拥有父节点的引用。也就是说,可以通过任何一个子节点找到最终的树根,也是该集合的代表节点。此外,根节点中的 next指针指向它自己。
过程
初始化
初始化一个并查集的时候,必须在一开始的时候便将问题域中的所有元素交给它。并查集一开始将每个元素单独作为一个集合,每个节点都是自己集合的代表节点。 假设有一个并查集开始时的元素为自然数 1~5。
查询元素所属集合
由于并查集的特殊结构,要查询一个元素属于哪个集合十分简单,只需要顺着节点的指针(每个节点有其父节点的引用)向上寻找。当发现有一个节点的父节点仍然是其本身,那么该节点就是这个集合的代表节点,找到了集合的代表节点,也就等同于查询到了元素所属的集合。如下图所示,元素 3 所属集合的代表节点为节点 1。
两个集合合并
并查集中要合并两个集合,只需给出任意两个元素即可,而未必要给出两个集合的代表节点。原因是这样可以增强合并功能且并查集可以很方便的通过任意元素查询到该元素所属的集合。每个集合的代表节点会有一个域表示该集合的元素数量,将元素较少的集合的代表节点的 next 指针指向另一个集合的代表节点,即完成了两个集合的合并。
原始并查集
合并 1、2 与 3、4、5
合并 1、3
存在的问题
很容易发现,在并查集中查询某个元素所属集合的代表节点的操作非常常用。然而,当前的合并策略与查询手段会存在一个问题:当并查集中初始集合非常多,则经过多次合并后树可能会变得很深,这会大大降低查询操作的效率。
查询优化
为了使查询效率更高,有一种优化策略是在每次查询操作时,都进行一次树结构优化,优化目标是增加树的分支以减少树的高度。由于每次查询操作本身就会遍历被查询节点到树根的所有节点,将遍历到的每个节点的 next 指针指向根节点,便在完成查询的同时也完成了结构优化。
原始树结构
查询节点 4
实现
构造器
public HashMap<Node, Node> fatherMap; // Key: 节点 Value: 节点的父亲
public HashMap<Node, Integer> sizeMap; // Key: 集合的代表节点 Value: 集合中元数的个数
public UnionFindSet() {
fatherMap = new HashMap<Node, Node>();
sizeMap = new HashMap<Node, Integer>();
}
并查集有多种实现,本文使用 HashMap 来实现并查集,这种实现最为快速,适合笔试中使用。HashMap 允许通过键来寻找某个值,刚好可以使得键为某个节点,而值为该节点的父节点,fatherMap 便用来记录并查集中所有节点的父节点。另外,还需要一个 HashMap 用来记录并查集中每个集合的元数个数,sizeMap 的键为集合的代表节点,值为代表节点所在集合的元素个数。需要注意的是,并查集刚构建的时候,每个节点都代表一个集合,但随着合并操作发生后,有些节点不再是代表节点,因此虽然在 sizeMap 中仍然可以查到非代表节点对应的值,但这个值已经没有意义了。
节点类
// 节点类
private static class Node {
private int element;
public Node(int element) {
this.element = element;
}
public int getElement() {
return element;
}
}
由于使用了 HashMap,故节点类中已经无需父节点的引用了。节点类可以按照需要任意定义。
初始化集合
public void initSet(List<Node> nodes) {
fatherMap.clear(); // 清空 HashMap
sizeMap.clear();
for (Node node : nodes) { // 遍历所有节点
fatherMap.put(node, node); // 一开始每个节点都属于不同的集合,每个节点都是各自集合的代表节点,因此每个节点的父节点都是自己
sizeMap.put(node, 1); // 一开始每个集合的节点个数都是 1
}
}
initSet 方法用以初始化一个并查集。由于并查集在使用的时候,必须一次性把所有节点都传给并查集,才能正常初始化。因此,该方法的参数为一个存入所有节点的 List。方法会遍历 List 中的每一个节点,并添加到 fatherMap 和 sizeMap 中。一开始,每个节点都是代表节点,因此每个节点的父节点都是自己,而所代表的集合的元素个数都是 1。
查询节点所属集合
private Node findHead(Node node) {
Node father = fatherMap.get(node);
if (father != node) {
father = findHead(father); // 递归找到根节点
}
fatherMap.put(node, father); // 将每层递归的当前节点的父节点改为根节点 father
return father;
}
findHead 方法可以找到任意节点所属集合的代表节点,并同时优化树的结构。本方法使用递归的方式找到根节点并将所有递归遍历到的节点的父节点改成根节点。
判断任意两个节点是否属于同一个集合
public boolean isSameSet(Node a, Node b) {
return findHead(a) == findHead(b);
}
只需要看两个节点所属集合的代表节点是否是同一个节点,就可以判断两个节点是否属于同一个集合了。
合并两个集合
public void union(Node a, Node b) {
if (a == null || b == null) {
return;
}
Node aHead = findHead(a); // 找到各自所属集合的代表节点
Node bHead = findHead(b);
if (aHead != bHead) { // 如果两个节点不属于同一个集合,再合并
int aSetSize = sizeMap.get(aHead);
int bSetSize = sizeMap.get(bHead);
if (aSetSize <= bSetSize) { // 将规模较小的集合并入规模较大的集合
fatherMap.put(aHead, bHead);
sizeMap.put(bHead, aSetSize + bSetSize);
} else {
fatherMap.put(bHead, aHead);
sizeMap.put(aHead, aSetSize + bSetSize);
}
}
}
合并两个集合遵循一个原则,那便是将规模小的集合并入规模大的集合,这样做的好处是可以节省优化结构的工作量。合并的时候只需要将小规模集合的代表节点的父节点设置为大规模集合的代表节点即可。此外,合并后小规模集合的代表节点不再作为任何集合的代表节点,因此其在 sizeMap 中对应的值已经没有意义。而大规模集合的节点数量进一步扩大,因此需要将大规模集合的代表节点在 sizeMap 中对应的值加上小规模集合的节点个数作为新的值。
完整代码及测试
class UnionFindSet {
public HashMap<Node, Node> fatherMap; // Key: 节点 Value: 节点的父亲
public HashMap<Node, Integer> sizeMap; // Key: 集合的代表节点 Value: 集合中元数的个数
public UnionFindSet() {
fatherMap = new HashMap<Node, Node>();
sizeMap = new HashMap<Node, Integer>();
}
public void initSet(List<Node> nodes) {
fatherMap.clear(); // 清空 HashMap
sizeMap.clear();
for (Node node : nodes) { // 遍历所有节点
fatherMap.put(node, node); // 一开始每个节点都属于不同的集合,每个节点都是各自集合的代表节点,因此每个节点的父节点都是自己
sizeMap.put(node, 1); // 一开始每个集合的节点个数都是 1
}
}
private Node findHead(Node node) {
Node father = fatherMap.get(node);
if (father != node) {
father = findHead(father);
}
fatherMap.put(node, father);
return father;
}
public boolean isSameSet(Node a, Node b) {
return findHead(a) == findHead(b);
}
public void union(Node a, Node b) {
if (a == null || b == null) {
return;
}
Node aHead = findHead(a); // 找到各自所属集合的代表节点
Node bHead = findHead(b);
if (aHead != bHead) { // 如果两个节点不属于同一个集合,再合并
int aSetSize = sizeMap.get(aHead);
int bSetSize = sizeMap.get(bHead);
if (aSetSize <= bSetSize) { // 将元数个数较少的集合挂在元素个数较多的集合上
fatherMap.put(aHead, bHead);
sizeMap.put(bHead, aSetSize + bSetSize);
} else {
fatherMap.put(bHead, aHead);
sizeMap.put(aHead, aSetSize + bSetSize);
}
}
}
// 节点类
private static class Node {
private int element;
public Node(int element) {
this.element = element;
}
public int getElement() {
return element;
}
}
public static void main(String[] args) {
Node[] nodes = new Node[10];
for (int i = 1; i <= 10; i++) { // 初始化所有节点
nodes[i-1] = new Node(i);
}
UnionFindSet ufs = new UnionFindSet(); // 初始化并查集
ufs.initSet(Arrays.asList(nodes));
ufs.union(nodes[0], nodes[1]); // 合并节点 0 和 1 所属的集合
ufs.union(nodes[0], nodes[2]); // 合并节点 0 和 2 所属的集合
ufs.union(nodes[1], nodes[3]); // 合并节点 1 和 3 所属的集合
System.out.println(ufs.isSameSet(nodes[0], nodes[3])); // 查询节点 0、3 是否属于同一个集合
System.out.println(ufs.isSameSet(nodes[3], nodes[4])); // 查询节点 3、4 是否属于同一个集合
System.out.println(ufs.findHead(nodes[1]).getElement()); // 查询节点 1 所属集合的代表节点
}
}
out:
true
false
2