算法分析与设计之并查集
- 1.前言
- 2.并查集的基础
- 2.1.关于动态连通性
- 2.2.动态连通性的应用场景:
- 2.3.对问题建模:
- 2.4.建模思路:
- 2.5.API
- 2.7.Quick-Find算法:
- 2.8.Quick-Union算法:
- 3. 并查集的应用
1.前言
本文主要介绍解决动态连通性一类问题的一种算法,使用到了一种叫做并查集的数据结构,称为Union-Find。
更多的信息可以参考Algorithms一书的Section 1.5,实际上本文也就是基于它的一篇读后感吧。
原文中更多的是给出一些结论,我尝试给出一些思路上的过程,即为什么要使用这个方法,而不是别的什么方法。我觉得这个可能更加有意义一些,相比于记下一些结论。
2.并查集的基础
2.1.关于动态连通性
我们看一张图来了解一下什么是动态连通性:
假设我们输入了一组整数对,即上图中的(4, 3) (3, 8)等等,每对整数代表这两个points/sites是连通的。那么随着数据的不断输入,整个图的连通性也会发生变化,从上图中可以很清晰的发现这一点。同时,对于已经处于连通状态的points/sites,直接忽略,比如上图中的(8, 9)。
2.2.动态连通性的应用场景:
- 网络连接判断:
如果每个pair中的两个整数分别代表一个网络节点,那么该pair就是用来表示这两个节点是需要连通的。那么为所有的pairs建立了动态连通图后,就能够尽可能少的减少布线的需要,因为已经连通的两个节点会被直接忽略掉。
- 变量名等同性(类似于指针的概念):
在程序中,可以声明多个引用来指向同一对象,这个时候就可以通过为程序中声明的引用和实际对象建立动态连通图来判断哪些引用实际上是指向同一对象。
2.3.对问题建模:
在对问题进行建模的时候,我们应该尽量想清楚需要解决的问题是什么。因为模型中选择的数据结构和算法显然会根据问题的不同而不同,就动态连通性这个场景而言,我们需要解决的问题可能是:
-
给出两个节点,判断它们是否连通,如果连通,不需要给出具体的路径
-
给出两个节点,判断它们是否连通,如果连通,需要给出具体的路径
就上面两种问题而言,虽然只有是否能够给出具体路径的区别,但是这个区别导致了选择算法的不同,本文主要介绍的是第一种情况,即不需要给出具体路径的Union-Find算法。
而第二种情况可以使用基于DFS的算法。
2.4.建模思路:
最简单而直观的假设是,对于连通的所有节点,我们可以认为它们属于一个组,因此不连通的节点必然就属于不同的组。随着Pair的输入,我们需要首先判断输入的两个节点是否连通。如何判断呢?按照上面的假设,我们可以通过判断它们属于的组,然后看看这两个组是否相同,如果相同,那么这两个节点连通,反之不连通。为简单起见,我们将所有的节点以整数表示,即对N个节点使用0到N-1的整数表示。而在处理输入的Pair之前,每个节点必然都是孤立的,即他们分属于不同的组,可以使用数组来表示这一层关系,数组的index是节点的整数表示,而相应的值就是该节点的组号了。该数组可以初始化为:
for(int i = 0; i < size; i++)
id[i] = i;
即对于节点i,它的组号也是i。
初始化完毕之后,对该动态连通图有几种可能的操作:
- 查询节点属于的组
数组对应位置的值即为组号
- 判断两个节点是否属于同一个组
分别得到两个节点的组号,然后判断组号是否相等
- 连接两个节点,使之属于同一个组
分别得到两个节点的组号,组号相同时操作结束,不同时,将其中的一个节点的组号换成另一个节点的组号
- 获取组的数目
初始化为节点的数目,然后每次成功连接两个节点之后,递减1
2.5.API
我们可以设计相应的API:
注意其中使用整数来表示节点,如果需要使用其他的数据类型表示节点,比如使用字符串,那么可以用哈希表来进行映射,即将String映射成这里需要的Integer类型。
分析以上的API,方法connected和union都依赖于find,connected对两个参数调用两次find方法,而union在真正执行union之前也需要判断是否连通,这又是两次调用find方法。因此我们需要把find方法的实现设计的尽可能的高效。所以就有了下面的Quick-Find实现。
2.7.Quick-Find算法:
public class UF
{
private int[] id; // access to component id (site indexed) 即访问组件id(下标索引)
private int count; // number of components 组数
public UF(int N)
{
// Initialize component id array.初始化数组
count = N;
id = new int[N];
for (int i = 0; i < N; i++)
id[i] = i;
}
public int count()
{ return count; }
// 两点之间是否连通
public boolean connected(int p, int q)
{ return find(p) == find(q); }
public int find(int p)
{ return id[p]; }
public void union(int p, int q)
{
// 获得p和q的组号
int pID = find(p);
int qID = find(q);
// 如果两个组号相等,直接返回
if (pID == qID) return;
// 遍历一次,改变组号使他们属于一个组
for (int i = 0; i < id.length; i++)
if (id[i] == pID) id[i] = qID;
count--;
}
}
举个例子,比如输入的Pair是(5,9),那么首先通过find方法发现它们的组号并不相同,然后在union的时候通过一次遍历,将组号1都改成8。
当然,由8改成1也是可以的,保证操作时都使用一种规则就行。
上述代码的find方法十分高效,因为仅仅需要一次数组读取操作就能够找到该节点的组号。
但是问题随之而来,对于需要添加新路径的情况,就涉及到对于组号的修改,因为并不能确定哪些节点的组号需要被修改,因此就必须对整个数组进行遍历,找到需要修改的节点,逐一修改,这一下每次添加新路径带来的复杂度就是线性关系了,如果要添加的新路径的数量是M,节点数量是N,那么最后的时间复杂度就是MN,显然是一个平方阶的复杂度,对于大规模的数据而言,平方阶的算法是存在问题的,这种情况下,每次添加新路径就是“牵一发而动全身”,想要解决这个问题,关键就是要提高union方法的效率,让它不再需要遍历整个数组。
2.8.Quick-Union算法:
考虑一下,为什么以上的解法会造成“牵一发而动全身”?
因为每个节点所属的组号都是单独记录,各自为政的,没有将它们以更好的方式组织起来,当涉及到修改的时候,除了逐一通知、修改,别无他法。所以现在的问题就变成了,如何将节点以更好的方式组织起来,组织的方式有很多种,但是最直观的还是将组号相同的节点组织在一起,想想所学的数据结构,什么样子的数据结构能够将一些节点给组织起来?
常见的就是链表,图,树,什么的了。但是哪种结构对于查找和修改的效率最高?
毫无疑问是树,因此考虑如何将节点和组的关系以树的形式表现出来。
如果不改变底层数据结构,即不改变使用数组的表示方法的话。可以采用parent-link的方式将节点组织起来,举例而言,id[p]的值就是p节点的父节点的序号,如果p是树根的话,id[p]的值就是p,因此最后经过若干次查找,一个节点总是能够找到它的根节点,即满足id[root] = root的节点,也就是组的根节点了,然后就可以使用根节点的序号来表示组号。所以在处理一个pair的时候,将首先找到pair中每一个节点的组号(即它们所在树的根节点的序号),如果属于不同的组的话,就将其中一个根节点的父节点设置为另外一个根节点,相当于将一颗独立的树编程另一颗独立的树的子树。直观的过程如下图所示。但是这个时候又引入了问题。
在实现上,和之前的Quick-Find只有find和union两个方法有所不同:
private int find(int p)
{
// 寻找p节点所在组的根节点,根节点具有性质id[root] = root
while (p != id[p]) p = id[p];
return p;
}
public void union(int p, int q)
{
// Give p and q the same root.
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot)
return;
id[pRoot] = qRoot; // 将一颗树(即一个组)变成另外一课树(即一个组)的子树
count--;
}
树这种数据结构容易出现极端情况,因为在建树的过程中,树的最终形态严重依赖于输入数据本身的性质,比如数据是否排序,是否随机分布等等。比如在输入数据是有序的情况下,构造的BST会退化成一个链表。在我们这个问题中,也是会出现的极端情况的,如下图所示。
为了克服这个问题,BST可以演变成为红黑树或者AVL树等等。
然而,在我们考虑的这个应用场景中,每对节点之间是不具备可比性的。因此需要想其它的办法。在没有什么思路的时候,多看看相应的代码可能会有一些启发,考虑一下Quick-Union算法中的union方法实现:
public void union(int p, int q)
{
// Give p and q the same root.
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot)
return;
id[pRoot] = qRoot; // 将一颗树(即一个组)变成另外一课树(即一个组)的子树
count--;
}
所以我们应该考虑树的大小,然后再来决定到底是调用:
id[pRoot] = qRoot或者是id[qRoot] = pRoot
即总是size小的树作为子树和size大的树进行合并。这样就能够尽量的保持整棵树的平衡。
所以现在的问题就变成了:树的大小该如何确定?
我们回到最初的情形,即每个节点最一开始都是属于一个独立的组,通过下面的代码进行初始化:
for (int i = 0; i < N; i++)
id[i] = i; // 每个节点的组号就是该节点的序号
以此类推,在初始情况下,每个组的大小都是1,因为只含有一个节点,所以我们可以使用额外的一个数组来维护每个组的大小,对该数组的初始化也很直观:
for (int i = 0; i < N; i++)
sz[i] = 1; // 初始情况下,每个组的大小都是1
而在进行合并的时候,会首先判断待合并的两棵树的大小,然后按照上面图中的思想进行合并,实现代码:
public void union(int p, int q)
{
int i = find(p);
int j = find(q);
if (i == j) return;
// 将小树作为大树的子树
if (sz[i] < sz[j]) { id[i] = j; sz[j] += sz[i]; }
else { id[j] = i; sz[i] += sz[j]; }
count--;
}
Quick-Union和Weighted Quick-Union的比较:
可以发现,通过sz数组决定如何对两棵树进行合并之后,最后得到的树的高度大幅度减小了。这是十分有意义的,因为在Quick-Union算法中的任何操作,都不可避免的需要调用find方法,而该方法的执行效率依赖于树的高度。树的高度减小了,find方法的效率就增加了,从而也就增加了整个Quick-Union算法的效率。
上图其实还可以给我们一些启示,即对于Quick-Union算法而言,节点组织的理想情况应该是一颗十分扁平的树,所有的孩子节点应该都在height为1的地方,即所有的孩子都直接连接到根节点。这样的组织结构能够保证find操作的最高效率。
那么如何构造这种理想结构呢?
在find方法的执行过程中,不是需要进行一个while循环找到根节点嘛?如果保存所有路过的中间节点到一个数组中,然后在while循环结束之后,将这些中间节点的父节点指向根节点,不就行了么?但是这个方法也有问题,因为find操作的频繁性,会造成频繁生成中间节点数组,相应的分配销毁的时间自然就上升了。那么有没有更好的方法呢?还是有的,即将节点的父节点指向该节点的爷爷节点,这一点很巧妙,十分方便且有效,相当于在寻找根节点的同时,对路径进行了压缩,使整个树结构扁平化。相应的实现如下,实际上只需要添加一行代码:
private int find(int p)
{
while (p != id[p])
{
// 将p节点的父节点设置为它的爷爷节点
id[p] = id[id[p]];
p = id[p];
}
return p;
}
至此,动态连通性相关的Union-Find算法基本上就介绍完了,从容易想到的Quick-Find到相对复杂但是更加高效的Quick-Union,然后到对Quick-Union的几项改进,让我们的算法的效率不断的提高。
这几种算法的时间复杂度如下所示:
Algorithm | Constructor | Union | Find |
---|---|---|---|
Quick-Find | N | N | 1 |
Quick-Union | N | Tree height | Tree height |
Weighted Quick-Union | N | lgN | lgN |
Weighted Quick-Union With Path Compression | N | Very near to 1 (amortized) | Very near to 1 (amortized) |
对大规模数据进行处理,使用平方阶的算法是不合适的,比如简单直观的Quick-Find算法,通过发现问题的更多特点,找到合适的数据结构,然后有针对性的进行改进,得到了Quick-Union算法及其多种改进算法,最终使得算法的复杂度降低到了近乎线性复杂度。
如果需要的功能不仅仅是检测两个节点是否连通,还需要在连通时得到具体的路径,那么就需要用到别的算法了,比如DFS或者BFS。
3. 并查集的应用
后续就是在并查集的应用上。材料主要是取自POJ,HDOJ上的一些算法练习题。
首先还是回顾和总结一下关于并查集的几个关键点:
- 以树作为节点的组织结构,结构的形态很是否采取优化策略有很大关系,未进行优化的树结构可能会是“畸形”树(严重不平衡,头重脚轻,退化成链表等),按尺寸(正规说法叫做秩,后文全部用秩来表示)进行平衡,同时辅以路径压缩后,树结构会高度扁平化。
- 虽然组织结构比较复杂,数据表示方式却十分简洁,主要采用数组作为其底层数据结构。一般会使用两个数组(parent-link array and size array),分别用来保存当前节点的父亲节点以及当前节点所代表子树的秩。第一个数组(parent-link array)无论是否优化,都需要使用,而第二个数组(size array),在不需要按秩合并优化或者不需要保存子树的秩时,可以不使用。根据应用的不同,可能需要第三个数组来保存其它相关信息,比如HDU-3635中提到的“转移次数”。
- 主要操作包括两部分,union以及find。union负责对两颗树进行合并,合并的过程中可以根据具体应用的性质选择是否按秩优化。需要注意的是,执行合并操作之前,需要检查待合并的两个节点是否已经存在于同一颗树中,如果两个节点已经在一棵树中了,就没有合并的必要了。这是通过比较两个节点所在树的根节点来实现的,而寻找根节点的功能,自然是由find来完成了。find通过parent-link数组中的信息来找到指定节点的根节点,同样地,也可以根据应用的具体特征,选择是否采用路径压缩这一优化手段。然而在需要保存每个节点代表子树的秩的时候,则无法采用路径压缩,因为这样会破坏掉非根节点的尺寸信息(注意这里的“每个”,一般而言,在按秩合并的时候,需要的信息仅仅是根节点的秩,这时,路径压缩并无影响,路径压缩影响的只是非根节点的秩信息)。
以上就是我认为并查集中存在的几个关键点。
言归正传,来看几个利用并查集来解决问题的例子:
(说明:除了第一个问题贴了完整的代码,后面的问题都只会贴出关键部分的代码)
HDU-1213 How many tables
问题的描述是这样的:
Today is Ignatius’ birthday. He invites a lot of friends. Now it’s dinner time. Ignatius wants to know how many tables he needs at least. You have to notice that not all the friends know each other, and all the friends do not want to stay with strangers. One important rule for this problem is that if I tell you A knows B, and B knows C, that means A, B, C know each other, so they can stay in one table. For example: If I tell you A knows B, B knows C, and D knows E, so A, B, C can stay in one table, and D, E have to stay in the other one. So Ignatius needs 2 tables at least.
对这个问题抽象之后,就是要求进行若干次union操作之后,还会剩下多少颗树(或者说还剩下多少Connected Components)。反映到这个例子中,就是要求有多少“圈子”。
其实,这也是社交网络中的最基本的功能,每次系统向你推荐的那些好友一般而言,会跟你在一个“圈子”里面,换言之,也就是你可能认识的人,以并查集的视角来看这层关系,就是你们挂在同一颗树上。
给出实现代码如下:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
public class Main {
public static void main(String[] args) throws NumberFormatException,
IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
PrintWriter out = new PrintWriter(System.out);
int totalCases = Integer.parseInt(br.readLine());
WeightedQUWithPathCompression uf;
String[] parts;
while (totalCases > 0) {
parts = br.readLine().split(" ");
// based on 1, not 0
uf = new WeightedQUWithPathCompression(
Integer.parseInt(parts[0]) + 1);
// construct the uf
int tuples = Integer.parseInt(parts[1]);
while (tuples > 0) {
parts = br.readLine().split(" ");
uf.union(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
tuples--;
}
out.println(uf.count() - 1);
br.readLine();
totalCases--;
}
out.flush();
}
}
class WeightedQUWithPathCompression {
private int count;
private int[] id;
private int[] size;
public WeightedQUWithPathCompression(int N) {
this.count = N;
this.id = new int[N];
this.size = new int[N];
for (int i = 0; i < this.count; i++) {
id[i] = i;
size[i] = 1;
}
}
private int find(int p) {
while (p != id[p]) {
id[p] = id[id[p]]; // 路径压缩,会破坏掉当前节点的父节点的尺寸信息,因为压缩后,当前节点的父节点已经变了
p = id[p];
}
return p;
}
public void union(int p, int q) {
int pCom = this.find(p);
int qCom = this.find(q);
if (pCom == qCom) {
return;
}
// 按秩进行合并
if (size[pCom] > size[qCom]) {
id[qCom] = pCom;
size[pCom] += size[qCom];
} else {
id[pCom] = qCom;
size[qCom] += size[pCom];
}
// 每次合并之后,树的数量减1
count--;
}
public int count() {
return this.count;
}
}
最后,通过调用count方法获取的返回值就是树的数量,也就是“圈子”的数量。
根据问题的具体特性,上面同时采用了两种优化策略,即按秩合并以及路径压缩。因为问题本身对合并的先后关系以及子树的秩这类信息不敏感。然而,并不是所有的问题都这样,比如下面这一道题目,他对合并的先后顺序就有要求:
HDU-3635 Dragon Balls:
http://acm.hdu.edu.cn/showproblem.php?pid=3635
(上面题意的描述摘自:http://www.cnblogs.com/Shirlies/archive/2012/03/06/2382118.html)
在这道题中,对子树进行合并时,就不能按秩进行合并,因为合并是有先后关系的。
我们重点关注一下要回答的问题是什么,比如Q A代表的问题就是:
A球在哪里? — 这个问题好回答,A球所在的城市就是该子树的根节点,即find方法的返回值。
A球所在的城市有多少个球? — 同样地,这个问题的答案就是size数组中对应位置的信息,虽然本题不能按秩进行合并优化,但是秩还是需要被保存下来的。
A球被转移了多少次呢? — 这个问题画张图,就比较好理解了:
首先将球1所在城市的所有球转移到球2所在的城市中,即城市2,然后将球1所在城市的所有球转移到球3所在的城市中,即城市3。显然,在第二步中,1球已经不在城市1中,因为其在第一步中已经转移到城市2了。然后第二步实际就是将城市2中的所有球(包括球1和球2)都转移到城市3中。
紧接着,将1球所在城市的球全部转移(包括球1,2,3)到球4所在的城市中,即是将3和4进行合并。这个时候如果直接进行合并的话,会得到一个链表状的结构,这种结构使我们一直都力求避免的,所以可以采用前面使用的路径压缩进行优化。路径压缩的具体做法就不赘述了。现在需要考虑的是,经过这3轮合并,球1到底移动了多少次?如果从最后的结果图来看,球1最后到城市4,应该移动了2次,即1->3, 3->4。但是,仔细想想就会发现,这是不正确的。因为在T1 2中球1首先移动到了城市2,然后T 1 3,表示1球所在的城市中的所有球被移动到了城市3中,即城市2中的球移动到城市3中,这会对1球进行一次移动。以此类推,最后在T 1 4中,1球从城市3中移动到了城市4中,又发生了一次移动,因此,1球一共移动了3次,1->2, 2->3, 3->4。那么这就存在问题了,至少在最后的图中,这一点很不直观,因为从1到4的路径上,已经没有2的踪迹了。显然,这是路径压缩带来的副作用。因为采用了路径压缩,所以对树结构造成了一些破坏,具体而言,是能够推导出球的转移次数的信息被破坏了。试想一下,如果没有进行路径压缩,转移次数实际上是很直观的,从待求节点到根节点走过的路径数,就是转移次数。
所以为了解决引入路径压缩带来的问题,需要引入第三个数组来保存每个球的转移次数。结合题意,每次在进行转移的时候,是转移该球所在城市中所有的球到目标球所在的城市,把这句话抽象一下,就是只有根节点才能够进行合并。因此,现有的union方法还是适用的,因为它在进行真正的合并之前,还是需要首先找到两个待合并节点的根节点。然后合并的时候,将第一个球所在城市的的号码的转移次数加1。按照这种想法,实现代码为:
private static void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) {
return;
}
// 不能进行按秩合并,且在合并时,对第一个球的转移次数进行递增
id[pRoot] = qRoot;
trans[pRoot]++;
size[qRoot] += size[pRoot];
}
但是跟踪一下以上代码的调用过程不难发现,最后的球1,2,3,4的转移次数分别为1,1,1,0(唯一对trans数组进行影响的操作目前只存在于union方法中,见上)。显然,这是不正确的,正确的转移次数应该是3,2,1,0。那么是什么地方出了岔子呢,还是看看路径压缩就明白了,在路径压缩的时候,只顾着压缩,而没有对转移次数进行更新。
那么如何进行更新呢?看看上图,1本来是2的孩子,现在却成了3的孩子,跳过了2,因此可以看成,1->2->3的路径被压缩成了1->3,即2->3的这条路径被压缩了。被压缩在了1->3中,因此更新的操作也就有了基本的想法,我们可以讲被压缩的那条路径中的信息增加到压缩后的结果路径中,对应前面的例子,我们需要把2->3的信息给添加到1->3,用代码来表示的话,就是:
trans[1] += trans[2];
一般化后,实现代码如下所示:
private static int find(int q) {
while (id[q] != id[id[q]]) { //如果q不是其所在子树的根节点的直接孩子
trans[q] += trans[id[q]]; //更新trans数组,将q的父节点的转移数添加到q的转移数中
id[q] = id[id[q]]; //对其父节点到其爷爷节点之间的路径进行压缩
}
return id[q];
}
最后,如果需要获得球A的转移次数,直接获取trans[A]就OK了。
HDU-1856 More is better
这道题目的目的是想知道经过一系列的合并操作之后,查询在所有的子树中,秩的最大值是多少,简而言之,就是最大的那颗子树包含了多少个节点。
很显然,这个问题也能够同时使用两种优化策略,只不过因为要求最大秩的值,需要有一个变量来记录。那么在哪个地方来更新它是最好的呢?我们知道,在按秩进行合并的时候,需要比较两颗待合并子树的秩,因此可以顺带的将对秩的最大值的更新也放在这里进行,实现代码如下:
private static void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) {
return;
}
if (sz[pRoot] > sz[qRoot]) {
id[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
if (sz[pRoot] > max) { // 如果合并后的树的秩比当前最大秩还要大,替换之
max = sz[pRoot];
}
} else {
id[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];
if (sz[qRoot] > max) { // 如果合并后的树的秩比当前最大秩还要大,替换之
max = sz[qRoot];
}
}
}
这样,在完成了所有的合并操作之后,max中保存的即为所需要的信息。
HDU-1272 | HDU-1325 小希的迷宫 | Is it a tree ?
http://acm.hdu.edu.cn/showproblem.php?pid=1272
http://acm.hdu.edu.cn/showproblem.php?pid=1325
这两个问题都是判断是否合并后的结构是一棵树,即结构中应该没有环路,除此之外,还有边数和顶点数量的之间的关系,应该满足edges + 1 = nodes。
对于并查集,后者可以通过检查最后的connected components的数量是否为1来确定。
当然,两者在题目描述上还是有一定的区别,前者是无向图,后者是有向图。但是对于使用并查集来实现时,这一点的区别仅仅体现在合并过程无法按秩优化了。其实,如果能够采用路径压缩,按秩优化的效果就不那么明显了,因为每次进行查询操作的时候,会对被查询的节点进行路径压缩(参见find方法),可以说这是一种“懒优化”,或者叫做“按需优化”。而按秩合并则是一个主动优化的过程,每次进行合并的时候都会进行。而采用按秩合并优化,需要额外一个保存size信息的数组,在一些应用场景中,对size信息并不在意,因此为了实现可选的优化方法而增加空间复杂度,就有一些得不偿失了。并且,对于按秩合并以及路径压缩到底能够提高多少效率,我们目前也并不清楚,这里做个记号,以后有空了写一篇相关的文章。
扯远了,回到正题。前面提到了判断一张图是否是一颗树的两个关键点:
- 不存在环路(对于有向图,不存在环路也就意味着不存在强连通子图)
- 满足边数加一等于顶点数的规律(不考虑重边和指向自身的边)
------------------------------------------总结的分割线---------------------------------------
就目前看来,一般问题都是围绕着并查集的两个主要操作,union和find做文章,根据具体应用,增加一些信息,增加一些逻辑,例如上题中的转移次数,或者是根据问题特征选择使用合适的优化策略,按秩合并以及路径压缩。
并查集是我比较喜欢的一种数据结构和算法,很多实际问题都能够利用它给出高效而简洁的解决方案。后续还会陆续介绍一些有代表性的,同时难度也更大的题目,敬请关注。