并查集可以认为是一种数据结构,也可以认为是一种奇妙的思想.为什么奇妙呢,下面我们就知道了.
并查集的目的:
并 --> 将两个元素链接('并')在一起
查 --> 检查两个元素是否'并'在一起
看着很无聊的内容,但是却有很大的用途与很广的拓展方法.比如我们进入迷宫后怎么找到迷宫的出口,或是围棋博弈中,我想看我该怎样套路对方,并查集都是它们的基础.
下面我将会从以下几个方面叙述:
- 并查集接口
- 通俗易懂的第一版
- 第一次优化(权衡并与查的操作)
- 第二次优化(尽量使高度降低)
- 第三次优化(基于 rank 的优化)
- 路径压缩(基于高度的优化)
我习惯把主要方法写在接口中,这是我们并查集需要实现的方法:
1.并查集接口:
package com.tree;
public interface IUnionFind {
//将 p 与 q 合并到一个组别中
void union(int p, int q);
//返回元素 p 所在的组别
int find(int p);
//判断元素 p 与 q 是否属于一个组别
boolean isConnected(int p, int q);
}
参考树形结构,我们可以这样认为:在一个树上的所有元素,都是一个小组(组别)的.但是引入树形结构的Node有点大材小用,因此在并查集中,要实现这样一个方法:
我们在并查集中使用了数组 unionArr[],它的下标表示当前元素,而它的值,即unionArr[i]则表示元素i的父节点.
下面我给出容易理解的版本:
2.通俗易懂的第一版:
package com.tree;
/**
* 并查集 : 主要用来对节点进行快速连接,并判断任意两个节点是否相连接
*/
public class UnionFindImpl_1 implements IUnionFind {
private int[] unionArr; //使用数组下标表示元素,数组值表示关系
private int count; //表示元素个数
/*合并操作
* 注意两个不同的组别的元素进行合并时,
* 它们两个分别隶属的小组也要进行合并,
* 这样它们才是真正的合并
* */
@Override
public void union(int p, int q) {
int pUnion = find(p); //找到 p 的组别
int qUnion = find(q); //找到 q 的组别
if (pUnion != qUnion) {
for (int i = 0; i < count; i++) {
if (unionArr[i] == qUnion) {
unionArr[i] = pUnion;
}
}
}
}
@Override
public int find(int p) {
assert (p >= 0 && p < count);
return unionArr[p];
}
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
public UnionFindImpl_1(int count) {
this.count = count;
unionArr = new int[count];
//在初始条件下,每个数据都属于不同的组别
for (int i = 0; i < count; i++) {
unionArr[i] = i;
}
}
}
这里我们只需要一步就可以找到一个元素的组别,但是在链接两个小组时,却要遍历整个数组,因此我们需要权衡这两部的操作
3.第一次优化(权衡并与查的操作):
package com.tree;
/**
* 第一次优化:
* 通过基本的代码实现,可以知道,在进行合并操作时是十分复杂的,
* 在每次合并时都既要遍历一遍数组,还要逐一判断,并且对部分数据赋值.
* <p>
* 进行改进:
* 数组中保存的内容不再是自己的组别,而是随机的一个与自己连接的元素的下标,
* 这次改进后的数组更像是一颗从孩子节点指向父节点的数
*/
public class UnionFindImpl_2 implements IUnionFind {
int[] unionArr; //使用数组下标表示元素,数组值表示与自己连接的一个元素的下标
int count; //表示元素个数
@Override
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot != qRoot) {
//只需要让 p 与 q 的父节点中的一个指向另一个即可(unionArr[pRoot] = qRoot)
//这样,p 与 q 就有相同的父节点,表示他们两个连接在一起
unionArr[pRoot] = qRoot;
}
}
@Override
//通过不断的深入搜索自己的根节点
public int find(int p) {
assert (p >= 0 && p < count);
//如果 当前的元素 = 当前元素下标 ,表示这是这个树的根节点
while (unionArr[p] != p) {
//从这里可以看出,每一层的 unionArr[] 都表示 内层的节点 与 外层的节点相连接
p = unionArr[p];
}
return p;
}
@Override
public boolean isConnected(int p, int q) {
//判断 p 与 q 的父节点是否相等,就可以得到这两个节点是否已经
return find(p) == find(q);
}
public UnionFindImpl_2(int count) {
this.count = count;
unionArr = new int[count];
//在初始条件下,每个数据都属于不同的组别
for (int i = 0; i < count; i++) {
unionArr[i] = i;
}
}
}
这里,一个基本的并查集就完成了,之后的几次优化,就都很具有艺术性,让我脑洞大开
4.第二次优化(尽量使高度降低):
package com.tree;
/**
* 第二次优化:
* 由于从孩子指向父节点,在 find() 操作时,可能会持续多次 unionArr[unionArr[unionArr[unionArr[...........]]]]
* 导致整个树的高度很高,不利于层层寻找父节点,因此本次优化,使得在连接时尽量让整个树的高度减少
*/
public class UnionFindImpl_3 extends UnionFindImpl_2 implements IUnionFind {
private int[] size; //size[k] 表示以 k 为根的集合中元素的个数
@Override
public void union(int p, int q) {
//通过比较 size[] ,就可以将元素少的根,连接到元素多的根上,尽可能减少根的高度
int pRoot = find(p);
int qRoot = find(q);
if (size[pRoot] < size[qRoot]) {
size[pRoot] += size[qRoot];
super.union(pRoot, qRoot);
} else {
size[pRoot] += size[qRoot];
super.union(qRoot, pRoot);
}
}
public UnionFindImpl_3(int count) {
super(count);
size = new int[count];
//在初始条件下,每个数据都属于不同的组别
for (int i = 0; i < count; i++) {
size[i] = 1;
}
}
}
这里只更改了union()操作,使得两个树在合并的时候,可以尽可能的根据两个树中的元素个数,来默认元素多的那个树有更高的层次
5.第三次优化(基于 rank 的优化):
package com.tree;
/**
* 第三次优化:
* 并查集基于 rank 的优化:
* 我们之前根据整个树中元素的数量来使元素少的连接到元素多的根上,但是其实这样也是有风险的
* 比如平躺着的10个元素为一棵树,垂直放着的5个元素为一棵树,根据3代中的逻辑,垂直的树元素少,因此要连接在平躺着的树上,
* 这样无形之中增加了树的高度,因此这次优化,我们通过记录数的高度(rank),来判断谁要连接在谁上
*/
public class UnionFindImpl_4 extends UnionFindImpl_2 implements IUnionFind {
private int[] rank;//rank[k] 表示以 k 为根的集合所表示的数的层数
/*假如一个树层数为3,另一个层数为5,这时连接两棵树时
*只需要把 3 的连接到 5 上,并不需要修改他们的层数.
*而只有两个树的层数相等时,被连接的那个数会使原来的层数 +1 */
@Override
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (rank[pRoot] < rank[qRoot]) {
super.union(pRoot, qRoot);
} else if (rank[pRoot] > rank[qRoot]) {
super.union(qRoot, pRoot);
} else {
super.union(pRoot, qRoot);
rank[qRoot] += 1;
}
}
public UnionFindImpl_4(int count) {
super(count);
rank = new int[count];
for (int i = 0; i < count; i++) {
rank[i] = 1;
}
}
}
这步优化从依据元素的量估计层次,到真正的使用树的层次来进行比较,这是一个思想上很大的进步6.路径压缩(基于高度的优化):
这是大名鼎鼎的路径压缩,简直是把整个树压扁了!
package com.tree;
/**
* 路径压缩:
* 压缩,就是把垂直的东西压扁,所以对于一个垂直的一个树,我们需要让他的层数减少
* 方法很简单,如果想压缩 k 节点,就让 "k的父节点" 成为 "k的父节点的父节点" 就好
* 即 unionArr[k] = unionArr[unionArr[k]]
*/
public class UnionFindImpl_5 extends UnionFindImpl_2 implements IUnionFind {
@Override
public int find(int p) {
assert (p >= 0 && p < count);
while (unionArr[p] != p) {
/*增加下边这一步就好,这里不会有下标越界的问题,因为 unionArr[root] == unionArr[unionArr[root]]*/
unionArr[p] = unionArr[unionArr[p]];
p = unionArr[p];
}
return p;
}
/*这是一种最极端的路径压缩,将所有除根节点外的节点平铺,在执行这部操作后,find[p]只需要1步*/
public int pathCompression(int p) {
if (p != unionArr[p]) {
//如果 p 的父节点 不等于 p,那么就一直递归的寻找 p 的根节点,到最后让 unionArr[p] = p的根节点
unionArr[p] = pathCompression(unionArr[p]);
}
return unionArr[p];
}
public UnionFindImpl_5(int count) {
super(count);
}
}
如果全部结点都执行了 pathCompression 只需要一步,即可找到他们的并集,并且同样只需要一步,就可以将他们进行连接.这比起之前的算法,简直是令人瞠目结舌的解决方案.但是它在递归过程中的开销却也是不可忽略的.