一、前言
“亲爱的读者,你三十岁了吗?
”
以前总是讨厌父母的平庸,长大了才发现他们曾经也是怀揣着梦想的少年,只是被生活磨平了棱角。现在的我们走着父母曾经走过的路,却发现也许我们还不如父母,当初他们拿着微薄的工资却养活了我,而我却还是靠着父母几十年的积蓄付了首付。
小时候总是盼着长大,如果长大后父母依旧辛苦,那我们长大还有什么意义。
二、并查集的原理
1、"并"和"查"
- 有了深度优先搜索的基础以后,相信你对递归已经有一定的认识,那么今天让我们来认识一下并查集。
- 并查集是一种处理不相交集合的数据结构。
“它支持两种操作:
”
1)合并操作 。即合并两个原本不相交的集合,此所谓 “并”。
2)查找操作 。即检索某一个元素属于哪个集合,此所谓 “查”。
- 讲个简单的故事来加深对并查集的理解,这个故事要追溯到北宋年间。话说北宋时期,朝纲败坏,奸臣当道,民不聊生。又有外侮辽军大举南下,于是众多能人异士群起而反,各大武林门派同仇敌忾,共抗辽贼,为首的自然是中原武林第一大帮-丐帮,其帮主乃万军丛中取上将首级犹如探囊取物、泰山崩于前而面不改色的北乔峰;与其齐名的空有一腔抱负、壮志未酬的南慕容带领的慕容世家;当然也少不了天下武功的鼻祖-少林,以及一些小帮派,如逍遥派、灵鹫宫、无量剑、神农帮等等。我们将每个门派(帮派)作为一个集合,从中选出一个代表作为这个集合的标识,姑且认为门派(帮派)的掌门(帮主)就是这个代表。
- 作者有幸成了“抗辽联盟”的统计员,统计员只有一个工作,就是接收一条条同门数据,然后统计共有多少个门派,好进行分派部署。同门数据的格式为 ,表示 和 属于同一个门派,接收到一条数据,需要对 所在的群体和 的群体进行合并,当统计完所有数据后有多少个集合就代表多少个门派。
- 这个问题其实隐含了两个操作:
- 1)查找 和 是否已经在同一个门派;
- 2)如果两个人的门派不一致,则合并这两个人所在集合的两堆人。分别对应了并查集的查找和合并操作。
- 如图二-1-1所示,分别表示丐帮、少林、逍遥、大理段氏四个集合。
图二-1-1
2、朴素算法
- 接下来来讨论下并查集的朴素实现,既然是朴素实现,当然是越朴素越好啦。
- 朴素的只需要一个数组就能表示集合,我们用 表示 所在的集合编号。
“朴素算法实现如下:
”
1)初始化每个元素一个集合:;
2)查找 属于哪个集合,直接通过下标索引;
3)合并 和 的操作,需要判断 和 是否相等:
3.a)如果相等,则不作任何操作;
3.b)如果不相等,遍历所有满足条件 等于 的 ,设置 ;
- 可以把 数组理解成哈希表,查找过程的时间复杂度为 ;
- 然后,合并的时候,由于需要遍历 ,所以时间复杂度为 。图二-2-1展示了朴素算法的一个例子,该数组一共记录了四个集合,并且用每个集合的最小数字作为该集合的标识。
- 初始化的 c++ 代码实现如下:
const int MAXN = 300010;
int fset[MAXN];
void init(int n) {
for (int i = 1; i <= n; ++i) {
fset[i] = i;
}
}
- 查找和合并的 c++ 代码实现如下:
int find(int x) {
return fset[x];
}
void merge(int x, int y) {
int rx = find(x), ry = find(y);
if(rx != ry) {
for(int i = 1; i <= n; i++) {
if( fset[i] == rx ) {
fset[i] = ry;
}
}
}
}
- 初始化 , 每次得到一组数据 , 就执行 ,统计完所有数据后,对 数组进行一次线扫,就能统计出总共有多少个门派。
- 朴素算法实现中,合并操作的时间复杂度太高,在人数很多的情况下,效率上非常吃亏,如果有 次合并操作,那么总的时间复杂度就是 。所以我们将集合的表示进行一定的优化,将一个个集合用树的形式来组织,多个集合就组成了一个森林。
3、森林算法
- 用 表示 在集合树上的父结点,当 等于 时,则表示 为这棵集合树的根结点。
“森林算法实现如下:
1)初始化每个元素一个集合:,即表示森林中有 棵一个结点的树;
2)查找 属于哪个集合,只要顺着父结点一直找到根结点,就找到了该结点所在的集合 ();
3)合并 和 的操作,只需要查找 和 在各自集合树的根结点 和 ,如果 和 不相等,则将 设为 的父结点,即令 ;
如图二-3-1为合并 所在集合的操作。图二-3-1 ”
- c++ 代码实现如下:
int find (int x) {
return x == pre[x] ? x : find(pre[x]);
}
void merge(int x, int y) {
int rx = find(x), ry = find(y);
pre[ry] = rx;
}
- 代码量较朴素算法减少了不少,那时间复杂度呢?仔细观察,不难发现两个操作 和 的时间复杂度其实是一样的,瓶颈都在查找操作上,来看一个简单的例子。
“【例题1】因为天下武功出少林,所以很多人都想加入少林习武,令少林寺的编号为1。然后给定 组数据 ,表示 和 结成朋友,当 或 等于 1 时,表示另一个不等于 1 的人带领他的朋友一起加入少林,已知总人数 ,求最后少林寺来了多少人。
”
- 一个最基础的并查集操作,对于每条数据执行 即可,最后一次线性扫描统计 1 所在集合的人数的个数,但是对于极限情况,还是会退化成 的查找,如图二-3-2所示,每次合并都是一条链合并到一个结点上,使得原本的树退化成了链,合并本身是 的,但是在合并前的查找根结点的过程中已经是 的了,为了避免集合成链的情况,需要进行启发式合并。
图二-3-2
4、启发式合并
- 启发式合并是为了解决合并过程中树退化成链的情况,用 表示根为 的树的最大深度,合并 和 时,采用最大深度小的向最大深度大的进行合并,如果两棵树的最大深度一样,则随便选择一个作为根,并且将根的最大深度 自增 1,这样做的好处是在 次操作后,任何一棵集合树的最大深度都不会超过 ,所以使得查找的复杂度降为 。
- c++代码实现如下:
int find (int x) {
return x == pre[x] ? x : find(pre[x]);
}
void merge(int x, int y) {
int rx = find(x), ry = find(y);
if(rx != ry) {
if( depth[rx] == depth[ry] ) {
pre[ry] = rx;
depth[rx]++;
}else if( depth[rx] pre[rx] = ry;
}else {
pre[ry] = rx;
}
}
}
- 启发式合并的查找操作不变,合并操作引入了 数组,并且在合并过程中即时更新。
5、路径压缩
“【例题2】 个门派编号为 ,经过 次江湖上的血雨腥风,不断产生门派吞并,每次吞并可以表示成 ,即 吞并了 ,最后问从前往后数还存在的编号第 大的那个门派的编号。
”
启发式合并通过改善合并操作提高了效率,但是这个问题的合并是有向的,即一定是 向 合并,所以无法采用按深度的启发式合并,那么是否有办法优化查找操作来改善效率呢?答案是一定的,我们可以在结点 找到树根 的时候,将 到 路径上的点的父结点都设置为 ,这样做并不会改变原有的集合关系,如图二-5-1所示:
图二-5-1 由于每次查找过程中都对路径进行了压缩,使得任何时候树的深度都是小于 4 的,从而查找操作可以认为是常数时间。
c++ 代码实现如下:
int find(int x) {
return x == pre[x] ? x : pre[x] = find(pre[x]);
}
bool merge(int x, int y) {
int fx = find(x), fy = find(y);
if (fx != fy) {
pre[fx] = fy;
return true;
}
return false;
}
- 仔细观察不难发现,路径压缩版本, 函数中的赋值操作很好地诠释了深度优先搜索在回溯时的完美表现, 函数的返回值一定是这棵集合树的根结点 ,回溯的时候会经过从 到 的路径,通过这一步赋值可以很轻松的将该路径上所有结点的父结点都设为根结点 。
- 如果刚接触递归的同学,可以好好画图仔细理解一下这个 函数,我刚开始接触的时候也想了好久。
三、并查集的应用及扩展
1、图的连通性
“【例题3】作者的家和上班的地方相隔了 15 公里,途中有 个地铁站,假设每天能够修通一站地铁(一站地铁的形式为给出两个地点,表示这两个地点可以通过地铁互相可达),问第几天作者可以坐着地铁上班(假设作者家编号为 1,上班的地方编号为 )。
图三-1-1 ”
- 赤裸裸的并查集问题,每次对给出的边进行并查集的合并,然后查询 1 和 是否在一个集合;
2、树的判定
“【例题4】给定 个结点,条无向边,问这不是一棵树。如图三-2-1所示,前两个都是树,第三个因为有环,所以不是树。
图三-2-1 ”
- 树需要满足的特点如下:
- 1)是一个连通图;
- 2)点数 - 边数 = 1;
- 3)0个点也是树(空树);
- 那么,只要利用并查集进行结点合并,如果合并过程中出现连接的两个结点已经在同一个集合中,则必然存在环,不是树了;然后记录点数和边数,判断等式是否成立;最后根据并查集的集合结果判断总集合个数是否是 1 来决定是否是连通图;
- 注意:对于有向树的判定,还需要加上入度判定,整个图入度为0的点只有根节点
3、交错树构造
“【例题5】动物王国中有三类动物A,B,C,这三类动物的食物链构成了有趣的环形。A吃B, B吃C,C吃A。现有 N 个动物,以 1-N 编号。每个动物都是A,B,C中的一种,但是我们并不知道它到底是哪一种。有人用两种说法对这N个动物所构成的食物链关系进行描述:
”
第一种说法是"1 X Y",表示X和Y是同类。
第二种说法是"2 X Y",表示X吃Y。
此人对N个动物,用上述两种说法,一句接一句地说出K句话,这K句话有的是真的,有的是假的。当一句话满足下列三条之一时,这句话就是假话,否则就是真话。
1) 当前的话与前面的某些真的话冲突,就是假话;
2) 当前的话中X或Y比N大,就是假话;
3) 当前的话表示X吃X,就是假话。
你的任务是根据给定的 N(1 <= N <= 50,000)和 K 句话(0 <= K <= 100,000),输出假话的总数。
构造一棵 "ABC树"(注:并不存在这种数据结构,为了阐述问题,我暂时取的名字)来代表并查集的集合树,如图三3-1所示:
图三-3-1 这棵树的特点是:树的结点类型按照层级循环,假如(之所以用假设的口吻,是因为第一层可以不是 A 类结点)第一层为 A 类结点,那么第二层就是 B 类结点,第三层为 C 类结点,如此往复(这里的 ABC 对应了题目中的三种类型的动物);定义 A 为 B 的父类结点,B 为 C 的父类结点,C 为 A 的父类结点;为了方便数学运算,我们给每类结点一个权值:A = 0,B = 1,C = 2;如图三-3-2所示:
图三-3-2 并查集进行合并操作的时候,采用启发式合并,即一定是最大深度小的往大的合并,所以查找操作比较简单,不需要路径压缩;
如图三-3-3 的两棵 "ABC树" ,我们并不知道哪些结点是A,哪些是B,哪些是C,根据输入信息,我们知道两种情况:
1) 和 是同类结点;
2) 是 的父类结点;
图三-3-3 令 结点 的深度为 ,结点 的权值为 , 为 的根结点;
同理,结点 的深度为 ;结点 的权值为 , 为 的根结点;
考虑两种情况: 向 合并的情况、 向 合并的情况;
这里先讨论 向 合并的情况,如图三-3-4所示:
图三-3-4 考虑合并完以后,根结点 为 A 类结点(实际上是 B 还是 C 都一样,这里固定一种是为了简化问题),那么 它的权值 ,从而可以推导出结点 的权值为:
的取值可以通过 得出:
- 同样的, 的权值 也要满足:
- (其中 为合并前 结点的深度, 为 合并后 结点的深度 )
- 为了保证 和 的相对关系, 代表中间插入几个辅助结点后增长的深度,插入 个节点以后,深度增加 ;则有:
- 用类似的方法,分析 向 合并的情况,得到:
- 然后根据 和 的情况实际构造结点即可;
4、奇环判定
“【例题6】给定一个 个结点, 条边的图,问是否存在奇数个点的环。
”
- 类似【例题5】构造奇偶交错的树,如图三-4-1;
图三-4-1 - 提供一个可以通过 的时间复杂度内找到每个结点的深度的接口(参考 函数),采用启发式合并把深度控制在 ;
- 对于输入的边上的两个结点 ,分别计算 根
- 1)已经在同一棵树上(rx == ry), 且深度同奇偶,则存在奇环;否则不用处理;
- 2)不在同一棵树上(rx != ry),如果深度同奇偶,则直接采用启发式合并;如果深度不同奇偶,则需要将 最大深度小的那棵树增加一个伪根结点,然后再进行合并;
5、并查集的元素删除
“【例题7】话说有人加入少林,当然也有人还俗,虚竹就是个很好的例子,还俗后加入了逍遥派,这就是所谓的集合元素的删除。
”
并查集的删除操作可以描述成:在某个集合中,将某个元素孤立出来成为一个只有它本身的新的集合。这步操作不能破坏原有的树结构,单纯“拆”树是不现实的,如图三-5-1所示,是我们的美好预期,但是事实上并做不到,因为在并查集的数据结构中只记录了 的父亲,而未记录 的子结点,没办法改变 子结点的父结点。
图三-5-1 而且就算是记录了子结点,每次删除操作最坏情况会更新 个子结点的父结点,使得删除操作的复杂度退化成 ,还有一个问题就是 本身如果就是根结点,那么一旦x删除,它的子结点就会妻离子散,家破人亡,需要重新建立关系,越想越复杂。
所以,及时制止记录子结点的想法,采用一种新的思维——二次哈希法,对于每个结点都有一个哈希值,在进行查找之前需要将 转化成它的哈希值 ,那么在进行删除的时候,只要将 的哈希值进行改变,变成一个从来没有出现过的值(可以采用一个计数器来实现这一步),然后对新的值建立集合,因为只有它一个元素,所以必定是一个新的集合。
这样做可以保证每次删除操作的时间复杂度都是 的,而且不会破坏原有树结构,唯一的一个缺点就是每删除一个结点其实是多申请了一块内存,如果删除操作无限制,那么内存会无限增长。
6、并查集的离线操作
“【例题8】给定 个点和 条边,求去掉前 条边时整个图的连通分量的数目。
”
- 正向删边 可以 转换成 逆向加边,这就是逆向思维;
- 将所有边保存,逆序进行并查集的操作,初始化连通分量数为 ,每次对边的两个顶点进行询问,如果本来在同一个连通分量,那么加入这条边后的分量数为前一次的值,否则为前一次的值减 1,最后将这些分量数逆序输出即可。
7、求解最小生成树
- 这里主要涉及到一个基于贪心的 算法,因为最小生成树的概念还没有讲,等讲到最小生成树的时候再展开吧;