一、什么是并查集?
首先字面意思是把相互联系的元素通过特定查询组成一个集合。规范化解释:并查集,在一些有N个元素的集合应用问题中,我们通常是在开始时让每个元素构成一个单元素的集合,然后按一定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪个集合中。
二、并查集应用场景:
1. 图的连通性,可以用来判断哪些节点是连通的。也可以知道一个图一共能被分成几个相互独立的块。
2. 区间类问题
3. 连续子序列问题
4. 经典最小生成树算法:Kruskal 算法
5. 等等… 后期补充
总而言之,凡是涉及到元素的分组管理问题,都可以考虑使用并查集进行维护。
这一类问题近几年来反复出现在信息学的国际国内赛题中。其特点是看似并不复杂,但数据量极大,若用正常的数据结构来描述的话,往往在空间上过大,计算机无法承受;即使在空间上勉强通过,运行的时间复杂度也极高,根本就不可能在比赛规定的运行时间(1~3秒)内计算出试题需要的结果,只能用并查集来描述。
三、并查集详解:
先引入一道经典例题: 洛谷 P1551 亲戚
输入样例:
6 5 3
1 2
1 5
3 4
5 2
1 3
1 4
2 3
5 6
输出样例:
Yes
Yes
No
看到这道题,我的第一想法就是按每个人之间的联系建立邻接表,然后进行dfs遍历,每次dfs以后把结果存起来。当判断两个人是否是亲戚关系时,就在刚才存的dfs的所有集合中找两个人是否在同一集合,如果在则说明是亲戚,否则不是。
实现代码:
#include <bits/stdc++.h>
using namespace std;
int n,m,p;
int x,y;
vector<vector<int>> relationship(10000);
int vis[10000] = {0};
vector<vector<int>> v;
void dfs(int start, vector<int> &v) {
vis[start] = 1;
for (int i = 0; i < relationship[start].size(); ++i) {
if (vis[relationship[start][i]] == 0) {
v.push_back(relationship[start][i]);
dfs(relationship[start][i], v);
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie();cout.tie();
cin>>n>>m>>p;
// 建立邻接表
for (int i = 0; i < m; ++i) {
cin>>x>>y;
relationship[x].push_back(y);
relationship[y].push_back(x);
}
// dfs 深度搜索
for (int i = 1; i <= n; ++i) {
if (vis[i] == 1) continue;
vector<int> a; // 存储每次dfs的结果
a.push_back(i);
dfs(i, a);
v.push_back(a);
}
for (int i = 0; i < p; ++i) {
cin>>x>>y;
int flag = 0;
// 判断两个人是否在同一个集合,在则是亲戚,反之则不是
for (vector<int> &nums : v) {
if (find(nums.begin(), nums.end(), x) != nums.end() && find(nums.begin(), nums.end(), y) != nums.end()) {
cout<<"Yes"<<endl;
flag = 1;
break;
}
}
if (flag != 1) cout<<"No"<<endl;
}
}
耗时 654 ms, 内存:1.12 MB
好了,步入正题,我们是来讲如何使用并查集这种数据结构做题
记住:并查集的重要思想就在于用集合中的一个元素代表整个集合,好比一个班里边班长代表整个班。
先看个小故事😆 😆 :(自己乱编的)
一、假设现在有 6 个人,他们分别是这个城市不同地盘的黑老大。刚开始,所有黑老大井水不犯河水,各自管各自的地盘。他们各自的老大自然就是自己。(对于只有一个元素的集合,代表元素自然是唯一的那个元素)
二、后来由于国家扫黑除恶愈加愈烈,他们都想吞并别的势力来壮大自己。擒贼先擒王,要想吞并其他势力,势必需要两个黑帮老大打一架,哪队赢了,哪队就吞并另一队。现在一号老大想吞并二号的势力,那就得打一架,假设一号打赢了,那么二号就要带着他的小弟们认一号做老大。
三、有一天,二号去挑衅三号,我们老大迟早会把你吞并,三号气急败坏,立刻想把二号按在地上揍一顿,但是二号跑的快,把自己的大哥一号叫过来帮他,一号大哥也确实nb,直接把三号干趴了,顺便吞并了三号及他的小弟们。二号还在那里沾沾自喜。
四、一段时间过后,这个城市的黑恶势力经过各自约架吞并,当下局势如下:
五、一山如不了二虎,一号看不惯四号,得想办法来一波偷袭,终于在一次月黑风高夜顺利把四号拿下,他的手下全部跟着投降了。
六、好了,这下这个城市就只有一个黑恶势力,但好景不长,虽然他们各自吞并,势力逐渐壮大。但相比于国家还是过于渺小,最终还是被一锅端了。
通过上面的讲述,可以看出这是一个典型的树状结构,要寻找集合的代表(黑老大)元素,只需要一层一层往上访问父节点(图中箭头所指的圆),直达树的根节点即可
依照我上面讲的小故事,就可以写出最原始的并查集代码:
集合初始化:
int group[10000];
inline void init (int n) {
for (int i = 1; i <= n; ++i) {
group[i] = i;
}
}
假如有编号1-n个元素,用一个数组group[]来存储每个元素的父节点。一开始,我们先将它们的父节点设为自己。
查询父节点:
int find(int i) {
return group[i] == i ? i : find(group[i]);
}
一层一层递归访问父节点,直至根节点(根节点的标志就是父节点是本身)。要判断两个元素是否属于同一个集合,只需要看它们的根节点是否相同即可。
元素合并:
inline void merge(int i, int j) {
group[find(i)] = find(j);
}
先找到两个集合的代表元素,然后将前者的父节点设为后者即可。(或者反过来都行)
现在用这个最原始的并查集解上面那道经典例题:
#include <bits/stdc++.h>
using namespace std;
int n,m,p;
int arr[10000];
int x,y;
inline void init (int n) {
for (int i = 1; i <= n; ++i) {
arr[i] = i;
}
}
int find(int i) {
return arr[i] == i ? i : find(arr[i]);
}
inline void merge(int i, int j) {
arr[find(i)] = find(j);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m>>p;
init(n); // 初始化
for (int i = 0; i < m; ++i) {
cin>>x>>y;
merge(x, y);
}
for (int j = 0; j < p; ++j) {
cin>>x>>y;
find(x) == find(y) ? cout<<"Yes"<<endl : cout<<"No"<<endl;
}
return 0;
}
耗时 137ms, 内存:740 KB。 时间和空间得到了大幅度优化!
并查集路径压缩优化:
首先,让我们来回忆一下find执行的操作:从一个节点,一层一层递归访问父节点,直至根节点,在这个过程中,我们相当于把从这个节点到根节点的这条路径上的所有的节点都给遍历了一遍,那么,让我们想一想,在find的同时,是否可以顺便加上一些其它的操作使得树的层数尽量变得更少呢?答案是可以的。
我们可以使用路径压缩的方法。既然我们只关心一个元素对应的根节点,那我们希望每个元素到根节点的路径尽可能短,最好只需要一步,如下:
只要我们在查询的过程中,把途中的每个节点的父节点都设为根节点即可。下一次再查询时,直接就是根节点了。可以使用递归的写法实现:
int find (int i) {
/*
* 将父节点设为根节点
* arr[i] = find(arr[i])
* */
return i == group[i] ? i : (group[i] = find(group[i]));
}
现在用这个路径压缩后的并查集解上面那道经典例题:
#include <bits/stdc++.h>
using namespace std;
int n,m,p;
int arr[10000];
int x,y;
inline void init(int n) {
for (int i = 1; i <= n; ++i) {
arr[i] = i;
}
}
int find (int i) {
return i == arr[i] ? i : (arr[i] = find(arr[i]));
}
inline void merge (int i, int j) {
arr[find(i)] = find(j);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(); cout.tie();
cin>>n>>m>>p;
init(n); // 初始化
for (int i = 0; i < m; ++i) {
cin>>x>>y;
merge(x,y);
}
for (int j = 0; j < p; ++j) {
cin>>x>>y;
find(x) == find(y) ? cout<<"Yes"<<endl : cout<<"No"<<endl;
}
return 0;
}
耗时 37ms, 内存:644 KB。 时间再次得到了大幅度优化!感叹算法之美!
其实路径压缩优化后,并查集的时间复杂度已经比较低了,绝大多数不相交集合的合并查询问题都能完美AC。但是还是再说一种按秩合并,基本不常用到,除非很高规格竞赛,时间卡的很死的。
并查集按秩合并优化:
由于我们在找出一个元素所在集合的代表时需要递归地找出它所在的树的根结点,所以为了减短查找路径,在合并两棵树时要尽量使合并后的树的高度降低,所以要将高度低的树指向高度更高的那棵。这里我们引入一个秩的概念:为每一个结点维护一个秩,它表示以该节点为根的树的高度的上界。在做合并操作时,将秩小的根指向秩大的结点。
初始化
int rank[10000] // c++ 有个rank内置函数,如果先前定义了 using namespace std; 就不要用 rank
inline void init(int n) {
for (int i = 1; i <= n; ++i) {
group[i] = i;
rank[i] = 1;
}
}
合并
inline void merge(int i, int j) {
int x = find(i); int y = find(j); // 先找到两个元素的根节点
if (rank[x] < rank[y]) {
group[x] = y;
} else {
group[y] = x;
}
if (rank[x] == rank[y] && x != y) {
group[y]++; // 如果深度相同且根节点不同,则新的根节点的深度+1
}
}
现在用这个按秩合并+路径压缩的并查集解上面那道经典例题:
#include <bits/stdc++.h>
int n,m,p;
int arr[10000];
int rank[10000]; // 秩
int x,y;
inline void init(int n) {
for (int i = 1; i <= n; ++i) {
arr[i] = i;
rank[i] = 1;
}
}
int find(int i) {
return arr[i] == i ? i : (arr[i] = find(arr[i]));
}
inline void merge(int i, int j) {
int x = find(i); int y = find(j);
if (rank[x] < rank[y]) {
arr[x] = y;
} else {
arr[y] = x;
}
if (rank[x] == rank[y] && x != y) {
rank[y]++;
}
}
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie();std::cout.tie();
std::cin>>n>>m>>p;
init(n); // 初始化
for (int i = 0; i < m; ++i) {
std::cin>>x>>y;
merge(x,y);
}
for (int j = 0; j < p; ++j) {
std::cin>>x>>y;
find(x) == find(y) ? std::cout<<"Yes"<<std::endl : std::cout<<"No"<<std::endl;
}
return 0;
}
对于数据规模不大的题,基本已经优化不到哪里去了。
四、并查集相关题目:
547. 省份数量
684. 冗余连接 (并查集环路问题)
后续慢慢加上…