概念
并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题。常常在使用中以森林来表示。
应用场景
举两个栗子🌰:
- 朋友圈中,不一定其中的人都互相直接认识。
如果小张的朋友是小王,小王的朋友是小李,则他们属于一个朋友圈。
给定若干朋友关系,判断某两人是否在一个朋友圈。 - 如果小张有一个亲戚小王,小王有一个亲戚小李,则小张和小李是亲戚关系。
给定若干亲戚关系,判断某两人是否是亲戚关系。
所以并查集主要应用于一些连通性问题,处理集合是否相交及查询元素所属集合的问题。
动画演示
主要操作
初始化
把每个点所在的集合初始化为其自身。
通常来说,这个步骤是在该数据结构初始化时执行一次,无论何种实现方式,时间复杂度均为 O(n)。
查找
查找元素所在的集合,即根节点。
合并
将两个元素所在的集合合并为一个集合。
代码实现
染色法
顾名思义,这种方式合并集合的时候,采用将一个集合的元素染成另外一个集合的颜色的方法,颜色相同的元素,即为同一个集合的元素。
代码如下:
class UnionSet {
constructor(n){
// 初始化节点数组
this.colors = Array(n);
// 将每个元素染色为其下标
for(let i = 0;i<n;i++){
this.colors[i] = i;
}
}
// 获取当前元素色值
find(x){
return this.colors[x]
}
merge(a,b){
// 获取元素 a 的颜色,b 的颜色
const ca = this.colors[a],cb = this.colors[b];
// 将颜色为和 b 相同的元素染为 a 的颜色
for(let i =0;i<this.colors.length;i++){
if(this.colors[i]===cb){
this.colors[i] = ca
}
}
}
}
树型结构
染色法的 merge 操作时间复杂度是 O(n) 的,同行并查集使用树型结构来实现,这样我们只需要将一个元素所在的树挂载到另一个元素所在的树即可,合并操作时间复杂度是 O(1) 的。
虽然这种是方式的查找操作(时间复杂度 O(logn))相比染色法(时间复杂度 O(1)) 效率会低一些,但是综合下来还是会更高效一些,而且对于属性结构的并查集,我们还可以进行更多的优化。
代码如下:
class UnionSet {
constructor(n){
// 初始化节点集合数组
this.list = Array(n);
// 把每个元素的集合初始化为其自身
for(let i = 0;i<n;i++){
this.list[i] = i
}
}
// 获取元素所在集合根节点
find(x){
// 如果当前元素为根节点,返回
if(this.list[x]===x) return x;
// 否则递归获取根节点并返回
return this.find(this.list[x])
}
// 集合合并
merge(a,b){
// 获取元素所在集合根节点
const rootA = this.find(a),rootB = this.find(b);
// 如果两元素在同一个集合,取消合并
if(rootA===rootB) return;
// 将b所在集合合并到a所在集合
this.list[rootB] = rootA;
}
}
优化树高
上面这一版代码我们只是很随意的把 b 所在集合合并到 a 所在集合,这种做法,在极端情况下,是可能把树连成一个链表的。为了防止这种情况发生,我们可以判断一下两棵树的节点数量更多,为了优化后续的查找效率,应该把节点更少的树挂载到节点更多的树下面。
代码如下:
class UnionSet {
constructor(n){
// 初始化节点数量数组
this.size = Array(n).fill(1);
// 初始化节点集合数组
this.list = Array(n);
// 把每个元素的集合初始化为其自身
for(let i = 0;i<n;i++){
this.list[i] = i
}
}
// 获取元素所在集合根节点
find(x){
// 如果当前元素为根节点,返回
if(this.list[x]===x) return x;
// 否则递归获取根节点并返回
return this.find(this.list[x])
}
// 集合合并
merge(a,b){
// 获取元素所在集合根节点
const rootA = this.find(a),rootB = this.find(b);
// 如果两元素在同一个集合,取消合并
if(rootA===rootB) return;
// 将节点更少的树挂载到节点更多的树下,并更新该树节点数量
if(this.size[rootA]<this.size[rootB]){
this.list[rootA] = rootB;
this.size[rootB] += this.size[rootA]
}else{
this.list[rootB] = rootA;
this.size[rootA] += this.size[rootB]
}
}
}
路径压缩
经过上面的优化后,我们的并查集已经很高效了,但是对于 find 操作,还可以做进一步优化。
方法是在 find 操作中,获取元素所在集合根节点,然后将当前元素直接挂载到根节点上,这样下一次获取该元素根节点,就只需要进行两次查找,将 O(logn) 的时间复杂度优化到了 O(1)。
class UnionSet {
constructor(n){
// 初始化节点数量数组
this.size = Array(n).fill(1);
// 初始化节点集合数组
this.list = Array(n);
// 把每个元素的集合初始化为其自身
for(let i = 0;i<n;i++){
this.list[i] = i
}
}
// 获取元素所在集合根节点
find(x){
// 如果当前元素为根节点,返回
if(this.list[x]===x) return x;
// 否则递归获取根节点
const root = this.find(this.list[x])
// 将当前节点挂载为根节点子节点,实现路径压缩优化
this.list[x] = root;
// 返回根节点
return root;
}
// 集合合并
merge(a,b){
// 获取元素所在集合根节点
const rootA = this.find(a),rootB = this.find(b);
// 如果两元素在同一个集合,取消合并
if(rootA===rootB) return;
// 将节点更少的树挂载到节点更多的树下,并更新该树节点数量
if(this.size[rootA]<this.size[rootB]){
this.list[rootA] = rootB;
this.size[rootB] += this.size[rootA]
}else{
this.list[rootB] = rootA;
this.size[rootA] += this.size[rootB]
}
}
}
至此,我们就完成了并查集的概念、应用场景以及手写实现的全过程。
如有任何问题或建议,欢迎留言讨论!