我正在参加「掘金·启航计划」
概念
基础知识
并查集被认为是最简洁而优雅的数据结构之一,主要用于解决一些元素分组的问题。它管理一系列不相交的集合,并支持两种操作:
- 合并(Union):把两个不相交的集合合并为一个集合。
- 查询(Find):查询两个元素是否在同一个集合中。
当然,这样的定义未免太过学术化,看完后恐怕不太能理解它具体有什么用。所以我们先来看看并查集最直接的一个应用场景:亲戚问题。
亲戚问题
现在有一组人之间的亲戚关系(远亲关系也是亲戚),例如:A->B, B-C, B->D, E->F ( X->Y 表示X和Y是亲戚关系),现在的问题是,B和F有亲戚关系吗?
总结
并查集是一种抽象度很高的数据结构,可以解决连通性问题。
注意:本文代码用Javascript来实现。
Quick-Find
集合染色,两个集合变成同一种颜色,以此来判断是否在一个集合中。
这里说的染色,是把其中一个集合的值赋给另一个集合中所有元素,至于具体谁把谁染色,并不重要,反正染色完成后,他们都是一个颜色,也就是同样的值,是什么值,我们并不关心。
class UnionSet {
constructor(n) {
this.color = new Array(n).fill(0).map((val, ind) => ind);
this.setCount = n;
}
find(i) {
return this.color[i];
}
merge(a, b) {
if(this.color[a] === this.color[b]) return;
let cb = this.color[b];
for(let i=0; i<this.color.length; i++) {
if(this.color[i] == cb) {
this.color[i] = this.color[a];
}
}
this.setCount--;
}
isConnected(a, b){
return this.color[a] === this.color[b];
}
getCount(){
return this.setCount;
}
}
// 0,1,2,3,4
let arr = [[1,2],[2,3]];
// 1.判断1和3是否在一个集合中;2.集合个数
let u = new UnionSet(5);
u.merge(1,2);
u.merge(2,3);
let isCon = u.isConnected(1,3);
let count = u.getCount();
由于上面的并查集实现中,find的时间复杂度是O(1),很快,所以称为quick-find。
Quick-Union
Quick-Find的实现查找速度很快,但是每次merge合并要遍历所有被合并集合中所有的节点,如果我们把连通关系转换为树形结构,通过迭代或递归的方式向上查找出根节点,这样就可以优化merge的效率。
每个树根代表了这个集合。
这是一个树状的结构,要寻找集合的代表元素,只需要一层一层往上访问父节点(图中箭头所指的圆),直达树的根节点(图中橙色的圆)即可。根节点的父节点是它自己。我们可以直接把它画成一棵树:
class UnionSet {
constructor(n) {
this.parent = new Array(n).fill(0).map((val, ind) => ind);
this.setCount = n;
}
find(i) {
// 根节点的值是它本身
if(this.parent[i] === i) {
return i;
}
return this.find(this.parent[i]);
}
merge(i, j) {
let rootI = this.find(i), rootJ = this.find(j);
// i和j已经在一个集合中,无需合并
if(rootI === rootJ) return;
// 合并,把i挂在了j下面
this.parent[rootI] = rootJ;
this.setCount--;
}
isConnected(i, j){
return this.find(i) === this.find(j);
}
getCount(){
return this.setCount;
}
}
// 0, 1, 2, 3, 4
let arr = [[1,2],[2,3]];
// 1.判断1和3是否在一个集合中;2.集合个数
let u = new UnionSet(5);
u.merge(1,2);
u.merge(2,3);
let isCon = u.isConnected(1,3);
let count = u.getCount();
上面的Quick-Merge改善了merge的效率,但是merge的时候应该考虑一下合并完的集合,应该平均查找次数最少,因为我们可以把A集合merge到B集合,也可以把B集合merge到A集合。
Weighted Quick Union
merge的时候应该减少平均查找次数。如果把节点少的节点merge到节点多的集合中,这样新集合的平均查找次数更少。平均查找次数等于,总的查找次数/节点总数。
按秩合并
class UnionSet {
constructor(n) {
this.parent = new Array(n).fill(0).map((val, ind) => ind);
this.counts = new Array(n).fill(1);
this.setCount = n;
}
find(i) {
// 根节点的值是它本身
if(this.parent[i] === i) {
return i;
}
return this.find(this.parent[i]);
}
merge(i, j) {
let rootI = this.find(i), rootJ = this.find(j);
// i和j已经在一个集合中,无需合并
if(rootI === rootJ) return;
// 合并,要看rootI和rootJ中节点的个数
if(this.counts[rootI] < this.counts[rootJ]) {
[rootI, rootJ] = [rootJ, rootI];
}
// 把节点数小的挂在节点数多的结合下
this.parent[rootJ] = rootI;
// 更新rootI中集合的个数
this.counts[rootI] += this.counts[rootJ];
this.setCount--;
}
isConnected(i, j){
return this.find(i) === this.find(j);
}
getCount(){
return this.setCount;
}
}
// 0, 1, 2, 3, 4
let arr = [[1,2],[2,3]];
// 1.判断1和3是否在一个集合中;2.集合个数
let u = new UnionSet(5);
u.merge(1,2);
u.merge(2,3);
let isCon = u.isConnected(1,3);
let count = u.getCount();
Path-Compress Quick Union
带路径压缩的并查集,有些节点查找根的时候,如果很长的话,查找效率是很低的。
怎么优化呢?我们可以使用路径压缩的方法。既然我们只关心一个元素对应的根节点,那我们希望每个元素到根节点的路径尽可能短,最好只需要一步,像这样:
其实这说来也很好实现。只要我们在查询的过程中,把沿途的每个节点的父节点都设为根节点即可。下一次再查询时,我们就可以省很多事。这用递归的写法很容易实现:
find(x) {
if(this.parent[x] !== x) {
this.parent[x] = this.find(this.parent[x]);
}
return this.parent[x];
}
Path-Compress & Weighted Quick Union
带路径压缩且按秩合并的并查集,就是同时优化了查找和合并。
并查集模板
class UnionSet {
constructor(n) {
this.parent = new Array(n).fill(0).map((val, ind) => ind);
this.counts = new Array(n).fill(1);
this.setCount = n;
}
find(i) {
// 根节点的值是它本身
if(this.parent[i] !== i) {
this.parent[i] = this.find(this.parent[i]);
}
return this.parent[i];
}
merge(i, j) {
let rootI = this.find(i), rootJ = this.find(j);
// i和j已经在一个集合中,无需合并
if(rootI === rootJ) return;
// 合并,要看rootI和rootJ中节点的个数
if(this.counts[rootI] < this.counts[rootJ]) {
[rootI, rootJ] = [rootJ, rootI];
}
// 把节点数小的挂在节点数多的结合下
this.parent[rootJ] = rootI;
this.counts[rootI] += this.counts[rootJ];
this.setCount--;
}
isConnected(i, j){
return this.find(i) === this.find(j);
}
getCount(){
return this.setCount;
}
}
// 0, 1, 2, 3, 4
let arr = [[1,2],[2,3]];
// 1.判断1和3是否在一个集合中;2.集合个数
let u = new UnionSet(5);
u.merge(1,2);
u.merge(2,3);
let isCon = u.isConnected(1,3);
let count = u.getCount();
应用
有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。
给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。
返回矩阵中 省份 的数量。
示例 1:
输入:isConnected = [[1,1,0],[1,1,0],[0,0,1]]
输出:2
var findCircleNum = function(isConnected) {
let cnt = isConnected.length;
let u = new UnionSet(cnt);
for(let i=0;i<cnt;i++){
for(let j=0;j<cnt;j++){
if(isConnected[i][j] == 1) {
u.merge(i, j);
}
}
}
return u.getCount();
};