前言
今天我们来用两种方法实现并查集,一种是 quick find,一种是 quick union。
并查集中,主要有三种操作:
- 查找元素所属的集合
- 判断两个元素是否是同一集合
- 合并两个集合中的元素
而实现并查集的方法不同,主要在于操作并查集的时间复杂度不相同。对于 quick find,查找和判断的时间复杂度是 O(1), 而合并的时间复杂度是 O(n)。对于 quick union, 上面三种操作的复杂度都是 O(log n)
并查集有什么用呢,举个简单的例子,在地图上有多个村庄,其中一些村庄是属于一个县,另一些村庄属于另一个县,如果要将两个县的村庄合并,应该如何用数据结构表示这种关系呢?
除了这个抽象例子,并查集还在其他的算法中有运用,比如在最小生成树 prim, 或者 Dijkstra算法中,都有运用。
下面来用代码实现并查集
Quick Find
javascript
// 创建并查集,初始化每个元素的集合标识为自身
const unionArray = Array(8) // 创建一个包含8个元素的数组,表示8个节点
.fill(-1) // 用-1填充数组,初始化每个元素的集合标识为-1
.map((item, index) => index); // 使用map函数将每个元素初始化为自身的索引
// 查找节点所属的集合的根节点
const findRoot = (unionArray, node) => {
return unionArray[node]; // 返回节点的集合标识,这里就是节点自身
};
// 检查两个节点是否属于同一个集合
const checkSameUnion = (unionArray, node1, node2) => {
return findRoot(unionArray, node1) == findRoot(unionArray, node2); //检查两个节点的根节点是否相同
};
// 合并两个节点所在的集合
const combineUnion = (unionArray, node1, node2) => {
const union1 = findRoot(unionArray, node1); // 查找node1所属集合的根节点
const union2 = findRoot(unionArray, node2); // 查找node2所属集合的根节点
if (union1 == union2) return; // 如果两个节点已经属于同一个集合,无需合并
// 将node2所在的集合标识为node1所在的集合
unionArray[node2] = union1;
// 更新其他节点的集合标识,将属于node2集合的节点标识为node1集合
for (let i = 1; i < unionArray.length; i++) {
if (unionArray[i] == union2) {
unionArray[i] = union1;
}
}
};
代码中,用数组来表示一个个集合。数组的下标表示节点的值,数组的值表示该节点属于哪个集合。在初始化的时候,每个点属于各自的集合,所以数组每个值都是与数组的下标相同。
当我们要查找一个节点所属的集合,只需访问该节点在数组中的位置,并读取其值,即集合的标识。这样查找起来非常方便,只需要 O(1)时间复杂度就可以判断一个节点所属的集合。
当我们要对节点进行合并的时候,就会去改变数组的值。假设我们要合并节点 1 和 2 ,就会讲数组下标位为 2 的值设为 1,同时数组下标为 1 的值也是 1,也就意味着节点 1 和 2 同属于集合 1。
在代码中合并集合操作,有个需要注意的点。combineUnion
是将 node1 和 node2 所属的集合合并,而不是仅仅将 node1 和 node2 放在一个集合里。所以在改变 node2 的集合之后,还需要将 node2 所属集合中其他节点的值都做修改。这就是为什么 quick find 方式在合并集合上的时间复杂度是 O(n)
测试代码:
javascript
// 执行一系列的合并操作
combineUnion(unionArray, 1, 2);
combineUnion(unionArray, 2, 3);
combineUnion(unionArray, 4, 5);
combineUnion(unionArray, 6, 7);
// 打印并查集的状态
console.log(unionArray);
// 检查两个节点是否属于同一个集合
console.log(checkSameUnion(unionArray, 3, 6)); // false
// 合并两个集合
combineUnion(unionArray, 6, 3);
// 打印并查集的状态
console.log(unionArray);
// 再次检查两个节点是否属于同一个集合
console.log(checkSameUnion(unionArray, 3, 6)); // true
执行结果:
输出了两次,我们分别来看每一次执行combineUnion
之后的情况:
原始的树形结构:
latex
|__1
|__2
|__3
|__4
|__5
|__6
|__7
表示有 7 个集合,每个结点属于单独的集合
第一次执行 combineUnion(unionArray, 1, 2); 后的集合变化:
- 原集合情况:[0, 1, 2, 3, 4, 5, 6, 7]
- 合并节点1和节点2,将它们合并到同一个集合中。节点 2 的值将会指向 1,表示同属于集合 1
- 新集合情况:[0, 1, 1, 3, 4, 5, 6, 7]
latex
|__1
| |__2
|__3
|__4
|__5
|__6
|__7
第二次执行 combineUnion(unionArray, 2, 3); 后的集合变化:
- 原集合情况:[0, 1, 1, 3, 4, 5, 6, 7]
- 合并节点2和节点3,将它们合并到同一个集合中。节点 3 的值将会指向 1,表示同属于集合 1
- 新集合情况:[0, 1, 1, 1, 4, 5, 6, 7]
latex
|__1
| |__2
| |__3
|__4
|__5
|__6
|__7
第三次执行 combineUnion(unionArray, 4, 5); 后的集合变化:
- 原集合情况:[0, 1, 1, 1, 4, 5, 6, 7]
- 合并节点4和节点5,将它们合并到同一个集合中。节点 5 的值指向 4,表示同属于集合 4
- 新集合情况:[0, 1, 1, 1, 4, 4, 6, 7]
latex
|__1
| |__2
| |__3
|__4
| |__5
|__6
|__7
第四次执行 combineUnion(unionArray, 6, 7); 后的集合变化:
- 原集合情况:[0, 1, 1, 1, 4, 4, 6, 7]
- 合并节点6和节点7,将它们合并到同一个集合中。节点 7 的值将会指向 6,表示同属于集合 6
- 新集合情况:[0, 1, 1, 1, 4, 4, 6, 6]
latex
|__1
| |__2
| |__3
|__4
| |__5
|__6
| |__7
现在的共有三个集合,分别是集合 1,集合 4,集合 6
第五次执行 combineUnion(unionArray, 6, 3); 后的集合变化:
- 原集合情况:[0, 1, 1, 1, 4, 4, 6, 6]
- 合并节点6和节点3,将它们合并到同一个集合中。节点 1,2,3 的值都会指向 6,表示同一个集合
- 新集合情况:[0, 6, 6, 6, 4, 4, 6, 6]
latex
|__4
| |__5
|__6
| |__1
| |__2
| |__3
| |__7
可以很明显的看到,合并之后的树结构的高度只有 2,这是 quick find 的法门所在。
打印树形结构的代码,在本文结尾有,可以 copy 直接执行
Quick Union
javascript
// 创建并查集,初始化每个元素的集合标识为自身
const unionArray = Array(8)
.fill(-1)
.map((item, index) => index);
// 创建一个数组用于存储集合的秩(rank),用于优化合并操作
const unionRank = Array(8).fill(1);
// 查找节点所属的集合的根节点(快速 Union 版本)
const findRoot = (unionArray, node) => {
// 使用循环找到根节点,直到根节点的集合标识等于自身
while (unionArray[node] !== node) {
node = unionArray[node];
}
return node; // 返回根节点的集合标识
};
// 检查两个节点是否属于同一个集合
const checkSameUnion = (unionArray, node1, node2) => {
return findRoot(unionArray, node1) == findRoot(unionArray, node2);
};
const combineUnion = (unionArray, node1, node2) => {
const union1 = findRoot(unionArray, node1);
const union2 = findRoot(unionArray, node2);
if (union1 == union2) return;
unionArray[union2] = union1;
};
quick union 和 quick find 一致,都是用数组表示集合,也是数组的下标表示节点的值,不相同的是,在 quick find 中,数组的值不仅表示节点的所属集合,也表示父节点;而在 quick union 中只表示父节点,不表示所属的集合。
举个例子,[0, 1, 1, 2]
中,节点 1 的父节点是它自己,也是节点 1;节点 2 的父节点是节点 1;节点 3 的父节点是节点 2;这三个节点是同一棵树,并且默认是以父节点为自己的节点作为这棵树的父节点。
这棵树的形状是这样:
latex
|__1
| |__2
| |__3
默认从下标 1 开始,下标 0 直接忽略
因此在查找节点所属的集合过程,就不太一样,需要找到这棵树的父节点才行。这就是为什么查询节点所属集合的时间复杂度是O(log n)(树的高度和树的节点关系就是O(log n)
)
在集合的合并过程,也不一样,在查找了节点所属的集合后,也就是两棵树的父节点。合并就是将一个树的父节点连到另一颗树的父节点上
现在你知道为什么合并的时间复杂度也是O(log n)吗
测试代码
javascript
/**
* 进行一系列合并操作
* 1,2
* 2,3
* 4,5
* 6,7
*/
combineUnion(unionArray, 1, 2);
combineUnion(unionArray, 2, 3);
combineUnion(unionArray, 4, 5);
combineUnion(unionArray, 6, 7);
combineUnion(unionArray, 5, 6);
console.log(unionArray); // 打印并查集数组
console.log(checkSameUnion(unionArray, 3, 6)); // 检查节点3和节点6是否属于同一个集合
// 再次合并节点6和节点3所在的集合
combineUnion(unionArray, 6, 3);
console.log(unionArray); // 打印并查集数组
console.log(checkSameUnion(unionArray, 3, 6)); // 再次检查节点3和节点6是否属于同一个集合
结果没问题,正确
为了更好理解 unionArray 的内容,下面给出了两次 console.log
时 unionArray 所表示的树形结构
- 第一次
console.log
latex
|__1
| |__2
| |__3
|__4
| |__5
| |__6
| |__7
当前有两颗树,一颗树的根节点时 1,另一颗树的根节点是 4
- 第二次
console.log
text
|__4
| |__1
| | |__2
| | |__3
| |__5
| |__6
| |__7
在第一次 console.log 之后就将 6 和 3 所属的集合合并了,所以现在只有一颗树了
完整代码
打印树形结构
javascript
// 将 Quick Find 和 Quick Union 版本的并查集数组表示转换为树形结构的数据
// 函数用于将并查集数组转换为树形结构的数据
const unionArray2TreeData = (unionArray) => {
// 创建一个空的对象来表示树形结构
const tree = {};
// 第一次遍历,创建树的节点
unionArray.forEach((item, index) => {
if (index == 0) return; // 忽略索引为 0 的元素
// 创建一个节点对象,并赋予节点值和一个空的子节点数组
tree[index] = { value: index, next: [] };
});
// 第二次遍历,建立树的连接关系
unionArray.forEach((item, index) => {
if (index == 0) return; // 忽略索引为 0 的元素
if (item !== index) {
// 如果元素值与索引不相等,表示有父子关系,将子节点添加到父节点的子节点数组中
tree[item].next.push(tree[index]);
}
});
// 第三次遍历,删除不属于根节点的节点
unionArray.forEach((item, index) => {
if (index == 0) return; // 忽略索引为 0 的元素
if (index !== item) delete tree[index]; // 删除不属于根节点的节点
});
// 返回构建的树形结构数据
return tree;
};
// 用于打印树形结构的辅助函数
const printTree = (data, deeps = [1]) => {
// 如果输入数据为空,直接返回
if (!data) return res;
// 根据层级创建适当的缩进空格
let space = deeps
.slice(0, -1)
.map((item) => {
return item == 1 ? "|\t\t" : "\t\t";
})
.join("");
space += "|__";
// 添加当前节点的值到结果字符串
res = res + space + data.value + "\n";
// 递归处理子节点
if (Array.isArray(data.next)) {
for (let i = 0; i < data.next.length; i++) {
printTree(data.next[i], [...deeps, i == data.next.length - 1 ? 0 : 1]);
}
} else {
printTree(data.next, [...deeps, 0]);
}
// 返回结果字符串
return res;
};
// 用于打印多棵树的辅助函数
const printMultiTree = (treeData) => {
// 遍历所有树并调用打印树的函数
Object.values(treeData).forEach((item, index, array) => {
printTree(item);
});
// 打印结果字符串到控制台
console.log(res);
// 重置结果字符串,以备下次使用
res = "";
};
// 导出函数供其他模块使用
module.exports = { unionArray2TreeData, printMultiTree };
unionArray2TreeData
方法用来将 unionArray 转成对象结构,支持 quick find 和 quick union 两种数组结构。
Quick Find
javascript
// 创建并查集,初始化每个元素的集合标识为自身
const unionArray = Array(8) // 创建一个包含8个元素的数组,表示8个节点
.fill(-1) // 用-1填充数组,初始化每个元素的集合标识为-1
.map((item, index) => index); // 使用map函数将每个元素初始化为自身的索引
// 查找节点所属的集合的根节点
const findRoot = (unionArray, node) => {
return unionArray[node]; // 返回节点的集合标识,这里就是节点自身
};
// 检查两个节点是否属于同一个集合
const checkSameUnion = (unionArray, node1, node2) => {
return findRoot(unionArray, node1) == findRoot(unionArray, node2); // 检查两个节点的根节点是否相同
};
// 合并两个节点所在的集合
const combineUnion = (unionArray, node1, node2) => {
const union1 = findRoot(unionArray, node1); // 查找node1所属集合的根节点
const union2 = findRoot(unionArray, node2); // 查找node2所属集合的根节点
if (union1 == union2) return; // 如果两个节点已经属于同一个集合,无需合并
// 将node2所在的集合标识为node1所在的集合
unionArray[node2] = union1;
// 更新其他节点的集合标识,将属于node2集合的节点标识为node1集合
for (let i = 1; i < unionArray.length; i++) {
if (unionArray[i] == union2) {
unionArray[i] = union1;
}
}
};
// 执行一系列的合并操作
combineUnion(unionArray, 1, 2);
combineUnion(unionArray, 2, 3);
combineUnion(unionArray, 4, 5);
combineUnion(unionArray, 6, 7);
// 打印并查集的状态
console.log(unionArray);
// 检查两个节点是否属于同一个集合
console.log(checkSameUnion(unionArray, 3, 6)); // false
// 合并两个集合
combineUnion(unionArray, 6, 3);
// 打印并查集的状态
console.log(unionArray);
// 再次检查两个节点是否属于同一个集合
console.log(checkSameUnion(unionArray, 3, 6)); // true
Quick Union
javascript
// 创建并查集,初始化每个元素的集合标识为自身
const unionArray = Array(8)
.fill(-1)
.map((item, index) => index);
// 创建一个数组用于存储集合的秩(rank),用于优化合并操作
const unionRank = Array(8).fill(1);
// 查找节点所属的集合的根节点(快速 Union 版本)
const findRoot = (unionArray, node) => {
// 使用循环找到根节点,直到根节点的集合标识等于自身
while (unionArray[node] !== node) {
node = unionArray[node];
}
return node; // 返回根节点的集合标识
};
// 检查两个节点是否属于同一个集合
const checkSameUnion = (unionArray, node1, node2) => {
return findRoot(unionArray, node1) == findRoot(unionArray, node2);
};
const combineUnion = (unionArray, node1, node2) => {
const union1 = findRoot(unionArray, node1);
const union2 = findRoot(unionArray, node2);
if (union1 == union2) return;
unionArray[union2] = union1;
};
/**
* 进行一系列合并操作
* 1,2
* 2,3
* 4,5
* 6,7
*/
combineUnion(unionArray, 1, 2);
combineUnion(unionArray, 2, 3);
combineUnion(unionArray, 4, 5);
combineUnion(unionArray, 6, 7);
combineUnion(unionArray, 5, 6);
console.log(unionArray); // 打印并查集数组
console.log(checkSameUnion(unionArray, 3, 6)); // 检查节点3和节点6是否属于同一个集合
// 再次合并节点6和节点3所在的集合
combineUnion(unionArray, 6, 3);
console.log(unionArray); // 打印并查集数组
console.log(checkSameUnion(unionArray, 3, 6)); // 再次检查节点3和节点6是否属于同一个集合
代码都经过测试,能够直接 copy 下来直接在 vscode 运行
总结
这篇文章介绍了两种并查集方式,并且介绍了并查集的三种基本操作:
- 查找元素所属的集合
- 判断两个元素是否是同一集合
- 合并两个集合中的元素
同时给出了对应的 JS 实现代码,例子详实,还有对应的树形结构供参考,方便学习。下篇文章聊聊并查集的优化。不想了解优化也可以直接跳过,并不影响其他算法的学习