本文已参与「新人创作礼」活动,一起开启掘金创作之路。
并查集(Union Find)
定义
孩子节点指向父亲节点的一种很不一样的树形结构,对于一组数据,主要支持两个动作:
- :把元素p和元素q所在的两个不相交的集合合并为一个集合
- :查询元素p和元素q是否在同一个集合中
基本的数据表示
表示不同的集合,上图中元素属于同一个集合,元素的属于同一个集合
接口定义
/**
* 并查集支持的操作
*/
public interface UF {
boolean isConnected(int p, int q);
void unionElements(int p, int q);
int getSize();
}
版本一:Quick Find
初始化
public class UnionFind1 implements UF{
private int[] id; // 节点x的父节点是id[x]
public UnionFind1(int size) {
id = new int[size];
for (int i = 0; i < id.length; i++) {
id[i] = i; // 此时 每个元素都所属不同的集合
}
}
@Override
public int getSize() {
return id.length;
}
}
isConnected
/**
* 查找元素p所对应的集合编号
* O(1)
* @param p
* @return
*/
private int find(int p) {
if (p < 0 || p >= id.length) {
throw new IllegalArgumentException("p is out of bound.");
}
return id[p];
}
/**
* 元素p和元素q是否属于同一个集合
* O(1)
* @param p
* @param q
* @return
*/
@Override
public boolean isConnected(int p, int q) {
return find(p)==find(q);
}
Union
可以将4所属集合中的元素都合并到1所属集合中,也可以将1所属集合中的元素都合并到2所属集合中:
/**
* 合并元素pq所属的集合
* O(n)
* @param p
* @param q
*/
@Override
public void unionElements(int p, int q) {
int pId = find(p);
int qId = find(q);
if (pId == qId) {
return ;
}
for (int i = 0; i < id.length; i++) {
if (id[i] == pId) {
id[i] = qId;
}
}
}
时间复杂度分析
Quick Find本质上属于用数组模拟并查集的操作
unionElements(p,q):时间复杂度为isConnected(p,q):时间复杂度为
版本二:Quick Union
基本思想
将每一个元素,看做是一个节点,节点之间相连接形成树形结构,该树形结构中是孩子节点指向父亲节点:
上图的数据表示如下:
Quick Union下的数据表示
初始化时,相当于有10颗树
public class UnionFind2 implements UF{
private int[] parent;
public UnionFind2(int size) {
parent = new int[size];
for (int i = 0; i < size; i++) {
parent[i] = i; // 初始化:每个节点指向本身,即每个节点都是一棵独立的树
}
}
@Override
public int getSize() {
return parent.length;
}
}
Union
:让4节点指向3节点,数组中表示即为parent[4]=3
:让3节点指向8节点,数组中表示即为parent[3]=8
:让6节点指向5节点,数组中表示即为parent[6]=5
:让9节点指向4节点所在树的根节点,涉及查询操作,查询4所在树的根节点,如果让9直接指向4就形成了链表,体现不出树的优势,数组中表示即为parent[9]=8
:让6节点所在树的根节点指向2节点所在树的根节点
/**
* 查找元素p所对应的集合编号
* O(h),h为树的高度
* @param p
* @return
*/
private int find(int p) {
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
while (p != parent[p]) { // 寻找根节点
p = parent[p];
}
return p;
}
/**
* 合并元素pq所属的集合
* O(h),h为树的高度
* @param p
* @param q
*/
@Override
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) {
return ;
}
parent[pRoot] = qRoot;
}
isConnected
/**
* 元素p和元素q是否属于同一个集合
* O(h),h为树的高度
* @param p
* @param q
* @return
*/
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
时间复杂度分析
unionElements(p,q)和isConnected(p,q)的时间复杂度均为,为树的高度,通常树高远远小于数据总量
版本三:Quick Union基于size的优化
版本二中由于在合并两个树时不对两个树的形状做判断,因此有可能形成链表,例如下图经由、、形成:
一个简单的解决方案是考虑树的节点数目size
例如上图考虑,如果让8节点直接指向9,那么新的树高到达了4,但如果让9节点指向8,那么新的树高仍然为3
因此让节点数目少的那颗树的根节点指向节点数目多的那颗树的根节点,这样有更高的概率让形成的新树的高度比较低
/**
* 优化第二版的unionElements方法
* 基于size的优化
*/
public class UnionFind3 implements UF {
private int[] parent;
private int[] sz; // sz[i]表示以i为根的集合中元素个数
public UnionFind3(int size) {
parent = new int[size];
sz = new int[size];
for (int i = 0; i < size; i++) {
parent[i] = i;
sz[i] = 1;
}
}
@Override
public int getSize() {
return parent.length;
}
/**
* 查找元素p所对应的集合编号
* O(h),h为树的高度
* @param p
* @return
*/
private int find(int p) {
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
while (p != parent[p]) {
p = parent[p];
}
return p;
}
/**
* 元素p和元素q是否属于同一个集合
* O(h)
* @param p
* @param q
* @return
*/
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
/**
* 合并元素pq所属的集合
* O(h)
* @param p
* @param q
*/
@Override
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) {
return ;
}
/*
* 让元素个数比较少的根节点指向元素个数比较多的根节点
* 避免形成链表
*/
if (sz[pRoot] < sz[qRoot]) {
parent[pRoot] = qRoot; // pRoot指向qRoot
sz[qRoot] += sz[pRoot];
} else {
parent[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
}
}
}
版本四:Quick Union基于rank的优化
版本三的优化思路是在每次合并两颗树时,尽量保证形成的新树的高度不会增加
考虑对上图所示的并查集进行操作,根据版本三的执行逻辑,应该是节点8指向节点7,但是明显树高增加了
所以更加合理的合并方案应该是让树高比较低的树的根节点指向树高比较高的根节点:
因此合并时应该让深度比较低的那颗树向深度比较高的那颗树合并,使用rank[i]记录根节点为i的树的高度
/**
* 基于rank的优化
*/
public class UnionFind4 implements UF {
private int[] parent;
private int[] rank; // rank[i]表示以i为根的集合所表示的树的层数
public UnionFind4(int size) {
parent = new int[size];
rank = new int[size];
for (int i = 0; i < size; i++) {
parent[i] = i;
rank[i] = 1;
}
}
/**
* 查找元素p所对应的集合编号
* O(h),h为树的高度
*
* @param p
* @return
*/
private int find(int p) {
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
while (p != parent[p]) {
p = parent[p];
}
return p;
}
@Override
public int getSize() {
return parent.length;
}
/**
* 元素p和元素q是否属于同一个集合
* O(h)
*
* @param p
* @param q
* @return
*/
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
/**
* 合并元素pq所属的集合
* O(h)
*
* @param p
* @param q
*/
@Override
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) {
return;
}
/*
* 根据两个元素所在树的rank不同判断合并方向
* 将rank低的集合合并到rank高的集合上
*/
if (rank[pRoot] < rank[qRoot]) {
parent[pRoot] = qRoot; // pRoot指向qRoot
} else if (rank[qRoot] < rank[pRoot]) {
parent[qRoot] = pRoot;
} else {
parent[qRoot] = pRoot;
rank[pRoot] += 1;
}
}
}
版本五:路径压缩Path Compression
对于如上的三棵树,虽然表示的是同一个集合,但是由于树高不同,因此执行find操作的效率也不同,最理想的情况当然是最下面那种树,树高为2,路径压缩就是在并查集中将高树压缩为矮树,路径压缩发生在执行find操作时,在查找根节点的过程中,顺便让树高降低:
parent[p]=parent[parent[p]]表示让p节点指向其父节点的父节点
例如查找4节点的父节点,那么执行完parent[p]=parent[parent[p]]后,4节点的父节点为2节点:
此时2节点仍然不是根节点,继续向上遍历:
至此就找到了4的根节点0,同时在查找的过程中将树的高度由5降为3,这个过程就叫做路径压缩。
/**
* 路径压缩
* 优化find方法
*/
public class UnionFind5 implements UF{
private int[] parent;
private int[] rank; // rank[i]表示以i为根的集合所表示的树的层数
// 不反应高度/深度
public UnionFind5(int size) {
parent = new int[size];
rank = new int[size];
for (int i = 0; i < size; i++) {
parent[i] = i;
rank[i] = 1;
}
}
@Override
public int getSize() {
return parent.length;
}
/**
* 查找元素p所对应的集合编号
* O(h),h为树的高度
* @param p
* @return
*/
private int find(int p) {
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
while (p != parent[p]) {
// 相对于版本四,仅增加如下一行代码
parent[p] = parent[parent[p]]; // 路径压缩
p = parent[p]; // 继续查找根节点
}
return p;
}
/**
* 元素p和元素q是否属于同一个集合
* O(h)
* @param p
* @param q
* @return
*/
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
/**
* 合并元素pq所属的集合
* O(h)
* @param p
* @param q
*/
@Override
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) {
return ;
}
/*
* 根据两个元素所在树的rank不同判断合并方向
* 将rank低的集合合并到rank高的集合上
* 并不实际反应节点的深度/高度值
*/
if (rank[pRoot] < rank[qRoot]) {
parent[pRoot] = qRoot; // pRoot指向qRoot
} else if (rank[qRoot] < rank[pRoot]) {
parent[qRoot] = pRoot;
} else {
parent[qRoot] = pRoot;
rank[pRoot] += 1;
}
}
}
版本六:利用递归优化路径压缩
在上一版本中,利用路径压缩将下图中左边的树压缩成右边的形状,性能已经有了较大提升
但是在最理想的情况下, 希望将上图左边的树直接压缩成高度为2的树:
这样一种路径压缩可以利用递归来实现,在查找4的根节点过程中,将4以及之前的节点全部指向根节点。
/**
* 路径压缩
* 利用递归 优化find方法
*/
public class UnionFind6 implements UF{
private int[] parent;
private int[] rank; // rank[i]表示以i为根的集合所表示的树的层数
// 不反应高度/深度
public UnionFind6(int size) {
parent = new int[size];
rank = new int[size];
for (int i = 0; i < size; i++) {
parent[i] = i;
rank[i] = 1;
}
}
@Override
public int getSize() {
return parent.length;
}
/**
* 查找元素p所对应的集合编号
* O(h),h为树的高度
* @param p
* @return 根节点
*/
private int find(int p) {
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}
// p 节点以及p节点之前的节点都将指向根节点
if (p != parent[p]) {
parent[p] = find(parent[p]);
}
return parent[p];
}
/**
* 元素p和元素q是否属于同一个集合
* O(h)
* @param p
* @param q
* @return
*/
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}
/**
* 合并元素pq所属的集合
* O(h)
* @param p
* @param q
*/
@Override
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) {
return ;
}
/*
* 根据两个元素所在树的rank不同判断合并方向
* 将rank低的集合合并到rank高的集合上
* 并不实际反应节点的深度/高度值
*/
if (rank[pRoot] < rank[qRoot]) {
parent[pRoot] = qRoot; // pRoot指向qRoot
} else if (rank[qRoot] < rank[pRoot]) {
parent[qRoot] = pRoot;
} else {
parent[qRoot] = pRoot;
rank[pRoot] += 1;
}
}
}
版本六的性能不一定高于版本五,递归是有一定的性能开销的
在版本五中,多执行几次find操作也是可以将树的高度压缩为2的,例如再一次执行find(4)+find(3):
时间复杂度分析
加入路径压缩后并查集的时间复杂度严格意义上为:
近乎是级别的。
应用
-
连接问题 Connectivity Problem
- 网络中节点间的连接状态
- 数学中的集合类实现