数据结构与算法——并查集
目录
一、前言
前言:
参考视频:
左程云--算法讲解056【必备】并查集-上
左程云--算法讲解057【必备】并查集-下
题目列表:
牛客--并查集的实现
洛谷--P3367【模板】并查集
力扣--765.情侣牵手
力扣--839.相似字符串组
力扣--200.岛屿数量
力扣--947.移除最多的同行或同列石头
力扣--2092.找出知晓秘密的所有专家
力扣--2421.好路径的数目
力扣--928.尽量减少恶意软件的传播Ⅱ
二、关于并查集的理解与模板
并查集本质:
并查集的核心代码【模板】:
build()方法示例:
find()方法示例:
union()方法示例:
如何利用模板做题:
三、题目解答
注意:
⭐牛客--并查集的实现
题目:
解题思路:
示例代码:
⭐洛谷--P3367【模板】并查集
题目:
解题思路:
示例代码:
⭐力扣--765.情侣牵手
题目:
解题思路:
示例代码:
⭐力扣--839.相似字符串组
题目:
解题思路:
示例代码:
⭐力扣--200.岛屿数量
题目:
解题思路:
示例代码:
⭐力扣--947移除最多的同行或同列石头
题目:
解题思路:
示例代码:
⭐力扣--2092.找出知晓秘密的所有专家
题目:
解题思路:
示例代码:
⭐力扣--2421.好路径的数目
题目:
编辑解题思路:
示例代码:
⭐力扣--928.尽量减少恶意软件的传播Ⅱ
题目:
解题思路:
示例代码:
一、前言
前言:
记录自己对并查集的学习,本文是总结性记录,涉及一些对并查集学习的习题,关于并查集原理基础就不在赘述。有关原理和习题全面的讲解,请参考左老师的视频。
参考视频:
左程云--算法讲解056【必备】并查集-上
左程云--算法讲解057【必备】并查集-下
题目列表:
牛客--并查集的实现
洛谷--P3367【模板】并查集
力扣--765.情侣牵手
力扣--839.相似字符串组
力扣--200.岛屿数量
力扣--947.移除最多的同行或同列石头
力扣--2092.找出知晓秘密的所有专家
力扣--2421.好路径的数目
力扣--928.尽量减少恶意软件的传播Ⅱ
二、关于并查集的理解与模板
并查集本质:
并查集的本质其实是对树的利用,将每个树的根节点作为该树的代表。当树中两个结点关联时,即两个树需要合并为一个树,只需要将其中一个根节点连接到另一个树的根节点。而扁平化(路径压缩优化)和小挂大(按秩合并),无非是避免树的高度过高进行的优化。其查询的时间复杂度综合下来是O(1),具体是O(α(n)),其中α(n)是反阿克曼(Ackermann)函数。
并查集的核心代码【模板】:
并查集最核心三个方法:构建集合 build() , 查找集合代表元素 find() , 合并集合 union() 。
build()方法示例:
//以元素自身序号为索引,用于存放该元素所在集合的代表元素
vector<father>;void build (int n) {for(int i = 0; i < n; i++){//当然,这里也可以初始化有关集合标签的信息,之后例题有用到father[i] = i;}
}
find()方法示例:
//递归压缩路径版,常用
int find (int i) {if (father[i] != i) {father[i] = find(father[i]);}return father[i];
}//迭代压缩路径版,不常用,要用到辅助数组stack模拟栈
int find (int i) {//记录沿途共收集的元素个数int size = 0;while (i != father[i]) {//将父结点存入栈中,指针向上,直到找到末端结点,即集合代表元素stack[size++] = i;i = father[i];}//将收集到的结点元素的父结点设为末端结点while (size > 0) {father[stack[--size] = i}return i;
}
union()方法示例:
//非按秩合并
//注:union在C++中为关键字
void myUnion (int x, int y) {int fx = find(x);int fy = find(y);if (fx != fy) {father[fx] = fy;}
}//按秩合并
void myUnion (int x, int y) {int fx = find(x);int fy = find(y);if(fx != fy) {if (size[fx] > size[fy]) {//fy 挂在 fx 下father[fy] = fx;size[fx] += size[fy];} else {//fx 挂在 fy 下father[fx] = fy;size[fy] += size[fx];}}
}
如何利用模板做题:
根据题目需要,有时我们会为集合添加附属信息,这时我们只需要在 build() 方法中初始化信息,在 union() 方法中正确更新信息即可,具体问题具体分析。find() 方法一般不做修改。我们会在接下来的习题中去体会。
三、题目解答
注意:
由于左老师在视频中已经讲解的非常细致,所以我尽记录大体思路,代码中也标有需要的注释。
以下所有代码都经题目通过。
⭐牛客--并查集的实现
题目:
解题思路:
直接套用模板即可
示例代码:
#include <iostream>
using namespace std;static const int N = 1e6 + 1;
int father[N];
void build(int n);
bool isSameSet(int a, int b);
int find(int i);
void myUnion(int x, int y);int main() {int n, m;cin >> n >> m;build(n);for(int i = 0; i < m; i++){int opt, a, b;cin >> opt >> a >> b;if(opt == 1){if(isSameSet(a, b)){cout << "Yes" << endl;}else{cout << "No" << endl;}}else{myUnion(a, b);}}return 0;
}void build(int n){for(int i = 0; i < n; i++){father[i] = i;}
}bool isSameSet(int a, int b){return find(a) == find(b);
}void myUnion(int x, int y){int fx = find(x);int fy = find(y);if(fx != fy){father[fx] = fy;}
}int find(int i){if(i != father[i]){father[i] = find(father[i]);}return father[i];
}
⭐洛谷--P3367【模板】并查集
题目:
解题思路:
同上一题,直接套模板即可。
示例代码:
#include<iostream>
using namespace std;static const int N = 2e5 + 1;
int father[N];
void build(int n);
int find(int i);
bool isSameSet(int x, int y);
void myUnion(int x, int y);int main(){int n, m;cin >> n >> m;build(n);for(int i = 0; i < m; i++){int z, x, y;cin >> z >> x >> y;if(z == 1){myUnion(x, y);}else{if(isSameSet(x, y)){cout << "Y" << endl;}else{cout << "N" << endl;}}}
}void build(int n){for(int i = 0; i < n; i++){father[i] = i;}
}int find(int i){if(i != father[i]){father[i] = find(father[i]);}return father[i];
}bool isSameSet(int x, int y){return find(x) == find(y);
}void myUnion(int x, int y){int fx = find(x);int fy = find(y);if(fx != fy){father[fx] = fy;}
}
⭐力扣--765.情侣牵手
题目:
解题思路:
我们可以从简单示例出发,思考这题为什么用到并查集。
我们考虑以情侣对为单位。
假设有 2 对情侣相互坐错,我们需要交换 1 次,使情侣对独立。
假设有 3 对情侣相互坐错,我们需要交换 2 次,使情侣对独立。
假设有 4 对情侣相互坐错,我们需要交换 3 次,使情侣对独立。
所以如果有 k 对情侣相互坐错,我们需要交换 k - 1 次,使情侣对独立。
很显然这类似集合的撤销,那么我们只需要知道情侣对相互之间的坐错情况即可求解。
即使用并查集将相互坐错的情侣对编号放入同一集合(注意这里是情侣对,而不是情侣ID)。
示例代码:
class Solution {
private:static constexpr int N = 31;int father[N];int sets;
public:int minSwapsCouples(vector<int>& row) {int n = row.size();//以情侣对为元素创建集合build(n / 2);//以两个座位为一单位遍历数组//将两个座位上情侣ID对应的情侣对序号合并为同一集合for(int i = 0; i < n; i += 2){myUnion(row[i] / 2, row[i + 1] / 2);}//如果一个集合有 K 对,则需要交换 K - 1 次//由于一共有 n / 2 对情侣,所以只需要最后减集合数量即可return (n / 2) - sets;}void build(int n){for(int i = 0; i < n; i++){father[i] = i;}//初始化集合数量sets = n;}int find(int i){if(father[i] != i){father[i] = find(father[i]);}return father[i];}void myUnion(int x, int y){int fx = find(x);int fy = find(y);if(fx != fy){father[fx] = fy;//合并后集合数量-1sets--;}}
};
⭐力扣--839.相似字符串组
题目:
解题思路:
本题是显然易见的集合合并问题,只需要套用模板,两两比对字符串,只要相似就合并集合即可。
关于如何判断是否为相似字符串,只需要按位比对,统计不同的数量,只有2或0为相似字符串,才进行合并。
示例代码:
class Solution {
private:static constexpr int N = 301;int father[301];int sets;
public:int numSimilarGroups(vector<string>& strs) {int len = strs.size();int s_len = strs[0].size();//以字符串为元素构建集合build(len);for(int i = 0; i < len; i++){for(int j = i + 1; j < len; j++){//两两进行比对验证if(find(i) != find(j)){//如果非同一集合,我们则判断二者是否为相似字符串//diff 用于记录不同位的个数int diff = 0;//按位比对for(int k = 0; k < s_len && diff < 3; k++){if(strs[i][k] != strs[j][k]){diff++;}}//相似字符串则合并if(diff == 0 || diff == 2){myUnionFunc(i, j);}}}}//根据题意返回结果return sets;}void build(int len){for(int i = 0; i < len; i++){father[i] = i;}sets = len;}int find(int i){if(father[i] != i){father[i] = find(father[i]);}return father[i];}void myUnionFunc(int x, int y){int fx = find(x);int fy = find(y);if(fx != fy){father[fx] = fy;sets--;}}
};
⭐力扣--200.岛屿数量
题目:
解题思路:
很显然只需要将每个位置的小岛屿作为集合,进行并查集操作即可。
注意,一般来讲,我们需要上下左右四个位置都进行临近判断是否合并,但我们只需要横向遍历时,只判断左和上即可,因为右和下的临近岛屿在之后的遍历过程中,可以通过左和上找到当前岛屿。另外在构建并查集时,我们将所有岛屿进行横向编号,注意编号与 i 和 j 的关系。
示例代码:
class Solution {
private:static constexpr int N = 9e4 + 1;int father[N];int sets;public:int numIslands(vector<vector<char>>& grid) {int m = grid.size();int n = grid[0].size();//以每一个岛屿为集合构建build(m, n, grid);// index = i * n + jfor (int i = 0; i < m; i++) {for (int j = 0; j < n; j++) {if (grid[i][j] == '1') {//得到当前岛屿编号int now = i * n + j;//左临近点if (j > 0 && grid[i][j - 1] == '1') {int left = now - 1;myUnion(left, now);}//上临近点if (i > 0 && grid[i - 1][j] == '1') {int up = now - n;myUnion(up, now);}}}}//根据题意返回集合数量return sets;}void build(int m, int n, vector<vector<char>>& grid) {// m行 n列sets = 0;for (int i = 0; i < m; i++) {for (int j = 0; j < n; j++) {if (grid[i][j] == '1') {//注意编号关系int index = i * n + j;father[index] = index;sets++;}}}}int find(int i) {if (father[i] != i) {father[i] = find(father[i]);}return father[i];}void myUnion(int x, int y) {int fx = find(x);int fy = find(y);if (fx != fy) {father[fx] = fy;sets--;}}
};
⭐力扣--947移除最多的同行或同列石头
题目:
解题思路:
这题想让我们求可以移除的石头数量,那么我们可以按照200.岛屿数量这一题的思路,将同行同列的石头进行合并成同一个集合,最后只需要用总石头数量减去集合数量就是我们想要的答案。
那么问题来到如何找同行和同列的石头?这题与200.岛屿数量不同点在于岛屿数量提供的是二维数组的整个图,我们可以对每个位置进行编号处理。而本题只是给了每个石头的坐标而已。获取我们可以自己制作二维图表,但太大(1e8)。所以本题我们可以使用键值对,用来存放每行每列第一次出现的石头,我们只需要根据这一个石头进行合并即可。
示例代码:
class Solution {
private:int father[1001];int sets;unordered_map<int, int> rowFirst;unordered_map<int, int> colFirst; //column
public:int removeStones(vector<vector<int>>& stones) {int n = stones.size();//将每个石头作为初始集合元素build(n);for(int i = 0; i < n; i++){//遍历石头,得到其行和列int row = stones[i][0];int col = stones[i][1];//如果所在行之前有石头,则合并//没有则记录if(rowFirst.find(row) == rowFirst.end()){rowFirst[row] = i;}else{myUnion(i, rowFirst[row]);}//如果所在列之前有石头,则合并//没有则记录if(colFirst.find(col) == colFirst.end()){colFirst[col] = i;}else{myUnion(i, colFirst[col]);}}return n - sets;}void build(int n){rowFirst.clear();colFirst.clear();for(int i = 0; i < n; ++i){father[i] = i;}sets = n;}int find(int i){if(father[i] != i){father[i] = find(father[i]);}return father[i];}void myUnion(int x, int y){int fx = find(x);int fy = find(y);if(fx != fy){father[fx] = fy;sets--;}}
};
⭐力扣--2092.找出知晓秘密的所有专家
题目:
解题思路:
本题由于按照时间顺序进行的会议,所以我们考虑先将会议信息按时间顺序进行排列。之后我们按照时间顺序,对每一个时间内的所有会议进行处理,将有联系的专家进行集合的合并。我们使用额外的数组进行集合标签的记录,便于后续判断专家所在的集合是否知晓秘密。本题不难,只是在并查集的模板基础上为并查集加上标签信息而已。
示例代码:
class Solution {
private:static constexpr int N = 100001;int father[N];bool secret[N];
public:vector<int> findAllPeople(int n, vector<vector<int>>& meetings, int firstPerson) {build(n, firstPerson);//以每个meetings元素的第三个元素进行排序,即按会议时间排序sort(meetings.begin(), meetings.end(), [&](const auto& x, const auto& y){return x[2] < y[2];});int m = meetings.size();int l = 0, r = 0;while(l < m){//确保l...r范围内的会议时间相同while(r + 1 < m && meetings[r + 1][2] == meetings[l][2]){r++;}//对于同一时间的会议中专家进行合并集合for(int i = l; i <= r; i++){myUnion(meetings[i][0], meetings[i][1]);}//对非知晓秘密的专家进行撤销集合//由于刚才仅操作了l...r会议范围内的专家//所以仅再次循环这些专家即可for(int i = l; i <= r; i++){int a = meetings[i][0];int b = meetings[i][1];//如果专家a不是知晓秘密的集合,则重置if(!secret[find(a)]){father[a] = a;}if(!secret[find(b)]){father[b] = b;}}//进行 l r 的调整,开始下一组l = r + 1;r = l;}//处理完所有会议后,遍历所有专家,处于知晓秘密的专家添加到数组中vector<int> ans;for(int i = 0; i < n; i++){if(secret[find(i)]){ans.push_back(i);}}return ans;}void build(int n, int first){for(int i = 0; i < n; i++){father[i] = i;secret[i] = false;}//将firstPerson与0号专家合并father[first] = 0;secret[0] = true;}int find(int i){if(father[i] != i){father[i] = find(father[i]);}return father[i];}void myUnion(int x, int y){int fx = find(x);int fy = find(y);if(fx != fy){father[fx] = fy;//注意集合标签信息的更新secret[fy] |= secret[fx];}}
};
⭐力扣--2421.好路径的数目
题目:
解题思路:
题目既然要求好路径上的所有值都小于端点值,那么我们可以从小值结点出发构建连通图。
如何从小值出发?我们只需要将边按照俩端点的大值进行排序,保证小值先处理。
主要核心在于如何计算好路径的个数。
我们对集合标记最大值标签和最大值个数,当两个集合合并,即对应图进行连通时,如果两个集合最大值一致,则会产生好路径,个数即为两者最大值个数之积。
根据边排序和集合打标签,即可求解该题。
示例代码:
class Solution {
private:static constexpr int N = 3e4 + 1;vector<int> father; //集合代表元素vector<int> valTimes; //最大值出现次数
public:Solution() : father(N), valTimes(N){}int numberOfGoodPaths(vector<int>& vals, vector<vector<int>>& edges) {//点的个数int n = vals.size();build(n, vals);//排序路径auto compare = [&vals](const auto& x, const auto& y){return max(vals[x[0]], vals[x[1]]) < max(vals[y[0]], vals[y[1]]);};sort(edges.begin(), edges.end(), compare);int m = edges.size();int ans = 0;//遍历边for(int i = 0; i < m; ++i){ans += myUnion(edges[i][0], edges[i][1], vals);}//算上各点ans += n;return ans;}void build(int n, const vector<int>& vals){for(int i = 0; i < n; ++i){father[i] = i;valTimes[i] = 1;}}int find(int i){if(father[i] != i){father[i] = find(father[i]);}return father[i];}int myUnion(int x, int y, const vector<int>& vals){int fx = find(x);int fy = find(y);//用于计算好路径int path = 0;if(fx != fy){//根据两个集合的最大值判断谁挂在谁下面//省去了对最大值和最大次数的更改//只有两者相同时才计算path并更新集合特征if(vals[fx] > vals[fy]){father[fy] = fx;}else if(vals[fx] < vals[fy]){father[fx] = fy;}else{//只有集合最大值相等时才有好路径//默认 fx 挂在 fy 下面path = valTimes[fx] * valTimes[fy];valTimes[fy] = valTimes[fx] + valTimes[fy];father[fx] = fy;}}return path;}
};
⭐力扣--928.尽量减少恶意软件的传播Ⅱ
题目:
解题思路:
我们将非感染源结点进行集合合并,接着从感染源出发,找到与感染源之间相连的集合,为该集合设置感染源,根据题意,只有当感染源为一个时,该由非感染源集合组成的集合才能视为可拯救。所以最后只需要找感染源只有一个的集合即可。
本题看似复杂,实则只是将两种元素(非感染源结点、感染源结点)分开处理,然后通过为集合打标签信息来解题。
示例代码:
class Solution {
private:static constexpr int N = 301;//集合代表结点vector<int> father; //集合大小vector<int> size;//集合的源头结点//==-1 : 无源头结点//==-2 : 复数个源头结点//>= 0 : 源头结点vector<int> infect;//以源头结点为索引,对应可以拯救的结点数量vector<int> cnts;//该结点是否是源头结点vector<bool> virus;public:Solution() : father(N), size(N), infect(N), cnts(N), virus(N){}int minMalwareSpread(vector<vector<int>>& graph, vector<int>& initial) {//结点个数int n = graph.size();//初始化集合build(n, initial);//合并非源头结点for(int i = 0; i < n; i++){for(int j = i + 1; j < n; j++){//如果两者连通并都是非源头结点,合并集合if(graph[i][j] == 1 && !virus[i] && !virus[j]){myUnion(i, j);}}}//从源头点出发,设置集合的源头信息for(int sick : initial){//遍历graph对应行,找到源头相连的普通点for(int neighbor = 0; neighbor < n; neighbor++){if(sick != neighbor && !virus[neighbor] && graph[sick][neighbor] == 1){//找到该普通点对应集合的代表结点//对代表结点的源头信息进行更新int fs = find(neighbor);if(infect[fs] == -1){//没有源头结点infect[fs] = sick;}else if(infect[fs] != -2 && infect[fs] != sick){//如果源头结点不是 -2 , 且源头结点不是sick自身infect[fs] = -2;}}}}//遍历所有结点for(int i = 0; i < n; i++){//如果当前结点是集合的代表结点并且源头结点仅一个//注意到源头结点即使自己是代表结点,但其源头结点一直是 -1 if(i == find(i) && infect[i] >= 0){//只有当源头结点是一个的连通分量(集合)才能被拯救cnts[infect[i]] += size[i];}}//遍历所有源头结点, 找出能够拯救的点数最大值, 且下标最小//排序,确保下标小的结点先处理sort(initial.begin(), initial.end());int ans = -1;int save = -1;for(int x : initial){if(cnts[x] > save){ans = x;save = cnts[x];}}return ans;}void build(int n, const vector<int>& initial){for(int i = 0; i < n; i++){father[i] = i;size[i] = 1;infect[i] = -1;cnts[i] = 0;virus[i] = false;}for(auto x : initial){virus[x] = true;}}int find(int i){if(father[i] != i){father[i] = find(father[i]);}return father[i];}void myUnion(int x, int y){int fx = find(x);int fy = find(y);if(fx != fy){father[fx] = fy;size[fy] += size[fx];}}
};
最后,本文主要用于个人记录,如有错误还望指出和海涵,感谢阅读 ^_^