并查集: 处理集合问题的优秀方案

488 阅读6分钟

不知为何,我最近看的数据结构相关的内容都很少提到并查集,可能是因为并查集并非面试热点。但是我个人觉得是一种很有用的数据结构,而且本身也不难,值得一学。因此整理成此文。

什么是并查集

网上有很多不同解释,我认为比较好理解的一种解释是:并查集就是一种树形的数据结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。

并查集作用

并查集可以把图中的两个连通分量合并成一个连通分量。

如果把并查集应用到具体的问题上。据我个人目前的使用经验,并查集擅长处理以下的问题(欢迎补充):

  1. 查找图中是否有环的存在
  2. 合并两个不相交的集合
  3. 反复多次查询某个元素是否属于某个集合
  4. 反复多次对比多个元素是否属于同一或者不同集合

并查集的初始化

为了便与理解,这里我采用图片和伪代码结合的方式,一边实现一边进行说明:

让我们把如下所示的数据结构转换成并查集:

该数据结构用代码表示为:

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]++;
  }
}

并查集相关习题

leetcode 547. 省份数量

建议点上面的链接进去看看原题。

这里简单分析下题目。该题提供了一个二维数组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--;
    }
}