不知为何,我最近看的数据结构相关的内容都很少提到并查集,可能是因为并查集并非面试热点。但是我个人觉得是一种很有用的数据结构,而且本身也不难,值得一学。因此整理成此文。
什么是并查集
网上有很多不同解释,我认为比较好理解的一种解释是:并查集就是一种树形的数据结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。
并查集作用
并查集可以把图中的两个连通分量合并成一个连通分量。
如果把并查集应用到具体的问题上。据我个人目前的使用经验,并查集擅长处理以下的问题(欢迎补充):
- 查找图中是否有环的存在
- 合并两个不相交的集合
- 反复多次查询某个元素是否属于某个集合
- 反复多次对比多个元素是否属于同一或者不同集合
并查集的初始化
为了便与理解,这里我采用图片和伪代码结合的方式,一边实现一边进行说明:
让我们把如下所示的数据结构转换成并查集:
该数据结构用代码表示为:
const edges = [
// 每一个数组表示一条边
[0,1],[1,2],[3,4],
[4,5],[1,5],[2,4]
]
我们先对并查集进行初始化。并查集可以用数组也可以用链表,不过为了将节省空间,一般都会选择数组表示。
初始化很简单,把数组中每一个元素设置为下标对应的值即可:
// 并查集
const fa = [0, 1, 2, 3, 4, 5];
合并单个节点
接下进行合并操作。我们按照顺序从0 - 6对节点进行合并,首先对{0,1}这条边进行合并,我们任意选择其中一个元素作为根节点。这里我选择的是0作为根节点,只要把数组下标为1的位置设为0就可以了:
fa[1] = 0;
这一步意义在于把1和0连接起来,这样我们访问fa[1]的时候,看到fa[1]的值为0,就知道下一步该访问fa[0]了。由此得知1所属集合的根节点是0。换句话说,1被分配到了根节点为0的这个集合里面(fa[0]的值为它本身,所以0也属于根节点为0的集合)。
接下来合并{1,2}这条边。这时候就不能随便选一个父节点了,因为1已经指向0了,我们不能再次把1指向2,看起来只能选择把2指向1。
但是我们还有一种更为巧妙的办法。由于并查集是一种侧重于解决集合问题的数据结构,所以并不需要去关心节点的层次关系。如果我们设置fa[2] = 1,要想知道2属于哪一个集合,就必须通过2找到1,再通过1找到0,才能知道2属于0这个集合,徒增了查找的复杂度。
为了减少树的层数,我们直接把2指向0,也就是:
fa[2] = 0; // 此时fa的值为[0,0,0]
此时fa的结构如下(箭头表示父子关系):
后面无论合并多少个节点,都无脑将其指向0即可。
合并两个不同的集合
接下来合并右半部分节点。因为在实际开发中,我们的数据都是一个个接收的,不可能事先就知道0-6都是一个集合里面。假设我们目前只知道345是相连的,先仿照之前的方法构造一棵以3为根节点的集合。完成后fa的值为[0, 0, 0, 3, 3, 3];
接下来得到一个新需求,把2和4相连。我们延续之前所说的,减少树的层数的思想,最理想的做法显然是先找到2和4的根节点,然后把两个父节点进行合并。代码如下:
//找到元素根节点
function findRoot(i,fa) {
if(fa[i] !== i) {
// 如果fa[i]不等于i,说明不是根节点,就继续递归向父节点查找
return findRoot(fa[i],fa);
} else {
// 如果fa[i]等于i,说明现在就是根节点
return fa[i];
}
}
// 合并4和2的根节点,也就是fa[3] = 0;
// 合并完成后fa的值为[0,0,0,0,3,3]
fa[findRoot(4)] = findRoot(2);
合并完成后的结构如下:
可以看到这样合并后的树有三层。虽然也可以先找到2的根节点0,然后把4所属集合的所有元素(即345)都放到0的下面,这样合并就只有两层。但是这样做的时间复杂度太高,所以只合并两个集合的根节点,是一种比较折衷的做法。
完整代码如下:
const edges = [
// 每一个数组表示一条边
[0,1],[1,2],[3,4],
[4,5],[1,5],[2,4]
]
// 初始化。
const fa = [0, 1, 2, 3, 4, 5];
//找到元素根节点
function findRoot(i,fa) {
if(fa[i] !== i) {
// 如果fa[i]不等于i,说明不是根节点,就继续递归向父节点查找
return findRoot(fa[i],fa);
} else {
// 如果fa[i]等于i,说明现在就是根节点
return fa[i];
}
}
// 遍历所有的边
for(let i = 0; i < edges.length; i++) {
const root1 = findRoot(edges[i][0]);
const root2 = findRoot(edges[i][0]);
// 连接两个根节点
// 单个节点的根节点是它自己
// 所以合并单个节点与合并不同集合,本质上都是一样的操作
fa[root1] = root2;
}
并查集的基本使用
并查集用法其实很简单,主要就是靠查找元素的根节点,来确定该元素属于哪个集合(查找根节点的操作可以通过上面的findRoot函数进行)。
比如比较两个节点是不是属于同一个集合,只要两个节点的根节点是同一个,那么就属于同一个集合。
虽然用图等数据结构也可以完成一样的操作,但显然并查集无论在使用难度上还是效率上都要优秀许多。
并查集的优化
1.路径压缩
路径压缩其实上面已经说过了。就是合并两个节点的时候,永远选择两个元素的根节点进行合并,这样可以保证树的查找路径最小。这里就不再赘述了
2.按秩合并
当两个集合树的高度不一样时,选择把较矮的树合并进较高的树里面,可以避免给树增加额外的高度。
例如有下面两个集合要进行合并:
如果把集合0合并到集合3里面(把0指向3),那么树的高度会变成4层;如果把3合并进0里面,那么树的高度还是只有三层。这种方式明显比我们随便选一个节点作为根节点更好。
具体操作时,我们需要额外维护一个数组记录不同集合的高度,然后在合并的时候比较二者的高度即可。具体代码如下
const rank = [];
for(let i = 0; i < edges.length; i++) {
const root1 = findRoot(edges[i][0]);
const root2 = findRoot(edges[i][0]);
if(rank[root1] > rank[root2) {
fa[root2] = root1;
} else {
fa[root1] = root2;
}
// 只有两棵树的高度相等的时候,合并后的高度才会增加
if(root1 !== root2 && rank[root1] === rank[root2]) {
// 在上面的代码中,两棵树相等的情况下,我们把root1合并进了root2中
// 所以这里记录root2的节点
rank[root2]++;
}
}
并查集相关习题
建议点上面的链接进去看看原题。
这里简单分析下题目。该题提供了一个二维数组isConnected,如果isConnected[i][j] ===1,说明i和j两个城市相连;否则两个城市不相连。同时相连的城市算作一组,独立的城市单独算作一组,这个问题就是求出总共有多少组城市。
很明显这是一个图论问题,比较暴力直接的办法是深度或广度遍历。但是如果采用并查集的方式解决,会发现这个问题非常简单。
我们用一个变量count来统计总共有多少集合,最开始每个城市都各自单独为一个集合,所以初始化的时候,让count = 城市数量。然后遍历isConnected,每当遇到两个城市需要合并的时候,从并查集的角度来看,就是两个集合合并成了一个,集合的总数会比原先减少一个,我们就把count的总数减一。这样遍历完isConnected,直接返回count的值,就是问题的答案了。
为了让代码更简洁,这里省略了按秩合并,有兴趣的同学可以自己动手加上去。完整代码如下:
var findCircleNum = function(isConnected) {
const unionFind = new UnionFind(isConnected.length);
for(let i = 0; i < isConnected.length; i++) {
for(let j = 0; j < isConnected[i].length; j++) {
// 如果两个城市相连,就用并查集将他们连接起来
if(isConnected[i][j] === 1) {
unionFind.union(i,j);
}
}
}
return unionFind.count;
};
class UnionFind {
constructor(n) {
this.parents = Array.from({length: n}).map((v,i)=>i);
// 让count等于集合总数
this.count = n;
}
find(x) {
return this.parents[x] === x ? x : this.find(this.parents[x]);
}
union(x,y) {
const root1 = this.find(x);
const root2 = this.find(y);
if(root1 === root2) return;
this.parents[root1] = root2;
// 两个集合发生合并,count总数减一
this.count--;
}
}