算法:并查集

241 阅读5分钟

有许多人在一个理想国中,他们每个人都有自己的圈子。这个理想国有一个规则:如果圈子A中有一个人是圈子B中的一个人的好朋友,那么两个圈子就会自动合并成一个大圈子。如果现在告诉你每个人的好友关系,你是否可以高效地计算出整个理想国有多少个不同的圈子呢?

在这个小问题中,我们可以将圈子看作集合。每个人都最多属于一个集合(否则多个集合就会合并),计算圈子的数量,就是要把所有能够找到好友连接的集合都高效地合并起来。在算法设计中,我们经常会遇到一些需要保存集合的情况(例如使用Kruskal算法求最小生成树)。我们只关心一些元素是否在一个集合里,并不需要关心它的顺序。更重要的是,我们需要高效地完成两个集合的合并操作。如果使用数组等方式存储每个元素对应的集合,合并集合的时候就面临修改大量元素的问题。在这种情况下,我们就可以我们可以使用一种新的数据结构:并查集。

并查集的基本操作,是将所有的元素(理想国中的人、Kruskal算法中的边等等)用树结构组织起来。每个节点都会有一个父节点,指向同一个集合中的另一个元素。为了区分不同的集合,我们选取一个元素作为集合的代表元素,它的父节点指向它自己,它的节点编号就作为这一个集合的编号(如封面图所示,1和4就是左右两个集合的代表元素)。

注意,一开始所有人都是“自成一派”,所有节点的父节点都是自己。

对于查询和合并两个操作:

  • 给定一个节点,如何才能知道这个节点的集合编号(代表元素的编号)呢?很简单,只需要沿着父节点一直递归,走到一个集合的代表元素(树的根节点)上,就可以知道集合编号了。

  • 给定两个节点,如何才能合并这两个节点所在的集合呢?例如,在封面图上,如果6号和7号节点是好朋友,如何才能合并这两个圈子呢?我们可以分别递归这两个节点,找到这两个节点的集合编号,也就是代表元素的编号(1和4)。此时,我们只需要把其中一个集合的代表元素的父节点指向另一个集合的代表元素就好了!在此处,我们将1的父节点改为指向4的虚线,此时左侧集合中的所有节点,递归查找集合编号就会全部改为4号,也就是完成了两个集合的合并操作。

路径优化

你以为这就结束了吗?现在,合并操作已经不会产生很大的性能开销了。但是,查询操作可不容易啊!每次查询都需要一路沿着集合树递归到根节点,在极端情况下,如果一棵树高度很高,递归的代价就会很大。因此,我们可以应用路径优化技术。

简而言之,就是在查询节点的集合编号中,递归返回时“顺手”将沿途的所有节点的父节点都改为根节点。这样,下次再查询沿途节点的时候,就不会有很深的递归了。以此办法,在查询节点的同时也会降低集合树的高度,为并查集的查询操作提供更好的性能。

例如,在下图左侧的集合树上查询5号节点,要依次递归经过5,4,3,1,最终得到集合编号1。但是我们可以在递归返回的过程中将沿途的节点的父节点都改为1(右图),这样下次再查询这些节点,就可以高效地返回结果。

optimize.png

C++代码实现

说了这么多,看看代码吧。最精彩的地方就是,这样听起来很复杂的递归查询+路径优化,其实只需要一行代码就可以完成!

int find(int x) { return p[x] == x ? x : p[x] = find(p[x]); }

稍微解释一下:

  • p[x]存放了编号为x的节点的父节点编号。初始化为p[x] = x
  • find(x)用来查询编号为x的节点的集合编号(代表元素的编号)

find函数的返回值是一个三目表达式:条件为p[x] == x

  • 当条件成立时,也就是x本身就是一个集合的代表元素,那么递归就可以返回了,返回值x就是集合的编号。
  • 当此条件不成立的时候,需要递归调用find(p[x])来获得父节点的集合编号(也就是自己的集合编号)。此时,利用赋值语句本身也是表达式的特性,将递归调用获得的集合编号赋值给p[x]完成路径优化。最后将赋值语句中的集合编号返回给上层函数。

Have fun!