知识储备
本篇博客是并查集问题的应用篇,如果大家对于并查集问题还不太理解可以参考 【并查集基础】这篇文章应该够用了! 先学习一下。如果对并查集基础知识很熟悉了,那就接着本篇文章往下看看并查集的应用吧。
应用
并查集的基础想必大家已经烂熟于心了,本篇博客主讲并查集的一些应用。通过三个考察并查集的题目来引出并查集这种数据结构的应用范围、谁和谁之间建立连接关系以及如何建立连接关系这三个问题;接下来在题解中对这三个问题进行分析;最后总结了以上三个问题的答案以及总结了并查集解题的步骤。
岛屿数量
给你一个由
'1'
(陆地)和'0'
(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。
【分析】
本题是一个典型的 连通性 问题,连通就是把相同元素连接在一起,本题中就是将所有的 1 连接在一起,这些连接在一起的 1 就是一个岛屿,题目要求解的就是岛屿的数量。
本题有多种解法:深搜、广搜以及并查集法。这里讲述的是并查集的方法,其他两种方法可以参考 LeetCode 给出的官方题解和众多网友的解答。
并查集的方法,首先需要统计出所有的 1 的数量,并将单元格中所有的元素(不论 0 还是 1)都进行并查集父节点的初始化操作即父节点指向自己;接着枚举单元格的 1 位置并从此出发,上下左右四方向搜索新的单元格,如果新的单元格包含的元素也是 1,那就将新的单元格和与之相邻的搜索起点合并,这时候 1 的数量也就减一了;最终剩下的 1 的数量就是岛屿的数量了。
【示例代码】
class Solution {
public:
const int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
int numIslands(vector<vector<char>>& grid) {
int m = grid.size(), n = grid[0].size();
int res = 0;
vector<int> pa(m*n);
vector<int> rank(m*n, 0);
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (grid[i][j] == '1') {
++res;
}
pa[i * n + j] = i * n + j;
}
}
function<int(int)> find = [&](int x) {
return pa[x] == x ? x : pa[x] = find(pa[x]);
};
function<void(int, int)> unite = [&](int x, int y) {
x = find(x);
y = find(y);
if (x == y) return;
if (rank[x] < rank[y]) {
swap(x, y);
}
pa[y] = x;
if (rank[x] == rank[y]) rank[x] += 1;
--res;
};
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (grid[i][j] == '1') {
grid[i][j] == '0';
for (int k = 0; k < 4; ++k) {
int x = i + dirs[k][0];
int y = j + dirs[k][1];
if (x >= 0 && x < m && y >= 0 && y < n && grid[x][y] == '1') {
unite(i * n + j, x * n + y);
}
}
}
}
}
return res;
}
};
【总结】
在这个岛屿数量问题中,两个 1 之间需要查找出是否有连接关系,我们通过搜索以及元素值可以判断是否连接,这里建立的连接是位置与位置之间的连接关系,是根据题目提供的单元网格建立关系的。
亲戚
若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
规定: x x x 和 y y y 是亲戚, y y y 和 z z z 是亲戚,那么 x x x 和 z z z 也是亲戚。如果 x x x, y y y 是亲戚,那么 x x x 的亲戚都是 y y y 的亲戚, y y y 的亲戚也都是 x x x 的亲戚。
【输入格式】第一行:三个整数 n , m , p n,m,p n,m,p,( n , m , p ≤ 5000 n,m,p \le 5000 n,m,p≤5000),分别表示有 n n n 个人, m m m 个亲戚关系,询问 p p p 对亲戚关系。
以下 m m m 行:每行两个数 M i M_i Mi, M j M_j Mj, 1 ≤ M i , M j ≤ N 1 \le M_i,~M_j\le N 1≤Mi, Mj≤N,表示 M i M_i Mi 和 M j M_j Mj 具有亲戚关系。
接下来 p p p 行:每行两个数 P i , P j P_i,P_j Pi,Pj,询问 P i P_i Pi 和 P j P_j Pj 是否具有亲戚关系。
【输出格式】
p p p 行,每行一个
Yes
或No
。表示第 i i i 个询问的答案为“具有”或“不具有”亲戚关系。【样例输入】
6 5 3 1 2 1 5 3 4 5 2 1 3 1 4 2 3 5 6
【样例输出】
Yes Yes No
【分析】
我们把输入提供的有亲戚关系的两个人通过并查集联系在一起,最后通过并查集的
f
i
n
d
(
)
find()
find() 来查询两个人是否有亲戚关系。这里的并查集建立的是 人与人 之间的联系。本题的 联系 是通过输入窗口直接定义的。
【示例代码】
#include <iostream>
#include <vector>
using namespace std;
int n, m, p;
vector<int> pa;
vector<int> heigh;
int find(int x) {
if (pa[x] != x) {
pa[x] = find(pa[x]);
}
return pa[x];
}
// 使用高度启发式合并
void unite(int x, int y) {
x = find(x);
y = find(y);
if (x != y) {
if (heigh[x] < heigh[y]) swap(x, y);
pa[y] = x;
if (heigh[x] == heigh[y]) heigh[x] += 1;
}
}
int main() {
cin >> n >> m >> p;
pa.resize(n);
heigh.resize(n);
for (int i = 0; i < n; ++i) {
pa[i] = i;
}
int x, y;
for (int i = 0; i < m; ++i) {
cin >> x >> y;
unite(x-1,y-1);
}
for (int i = 0; i < p; ++i) {
cin >> x >> y;
if (find(x-1) == find(y-1)) {
cout << "Yes" << endl;
}
else {
cout << "No" << endl;
}
}
return 0;
}
【总结】
本题中是关于 关系或者称为联系 的题目,可以使用并查集来解决。
谁与谁之间建立联系?是 人与人 之间建立联系,具体的本题中就是 编号与编号 之间建立联系。
如何判断连接呢?输入数据时就告知了连接关系。
奶酪
【题目描述】
现有一块大奶酪,它的高度为 h h h,它的长度和宽度我们可以认为是无限大的,奶酪中间有许多半径相同的球形空洞。我们可以在这块奶酪中建立空间坐标系,在坐标系中,奶酪的下表面为 z = 0 z = 0 z=0,奶酪的上表面为 z = h z = h z=h。
现在,奶酪的下表面有一只小老鼠 Jerry,它知道奶酪中所有空洞的球心所在的坐标。如果两个空洞相切或是相交,则 Jerry 可以从其中一个空洞跑到另一个空洞,特别地,如果一个空洞与下表面相切或是相交,Jerry 则可以从奶酪下表面跑进空洞;如果一个空洞与上表面相切或是相交,Jerry 则可以从空洞跑到奶酪上表面。
位于奶酪下表面的 Jerry 想知道,在不破坏奶酪的情况下,能否利用已有的空洞跑 到奶酪的上表面去?
空间内两点 P 1 ( x 1 , y 1 , z 1 ) P_1(x_1,y_1,z_1) P1(x1,y1,z1)、 P 2 ( x 2 , y 2 , z 2 ) P2(x_2,y_2,z_2) P2(x2,y2,z2) 的距离公式如下:
d i s t ( P 1 , P 2 ) = ( x 1 − x 2 ) 2 + ( y 1 − y 2 ) 2 + ( z 1 − z 2 ) 2 \mathrm{dist}(P_1,P_2)=\sqrt{(x_1-x_2)^2+(y_1-y_2)^2+(z_1-z_2)^2} dist(P1,P2)=(x1−x2)2+(y1−y2)2+(z1−z2)2
【输入格式】
每个输入文件包含多组数据。
第一行,包含一个正整数 T T T,代表该输入文件中所含的数据组数。
接下来是 T T T 组数据,每组数据的格式如下: 第一行包含三个正整数 n , h , r n,h,r n,h,r,两个数之间以一个空格分开,分别代表奶酪中空洞的数量,奶酪的高度和空洞的半径。
接下来的 n n n 行,每行包含三个整数 x , y , z x,y,z x,y,z,两个数之间以一个空格分开,表示空洞球心坐标为 ( x , y , z ) (x,y,z) (x,y,z)。
【输出格式】
T T T 行,分别对应 T T T 组数据的答案,如果在第 i i i 组数据中,Jerry 能从下表面跑到上表面,则输出
Yes
,如果不能,则输出No
。【样例输入】
3 // 此处是注释 T 2 4 1 // n h r 0 0 1 0 0 3 2 5 1 0 0 1 0 0 4 2 5 2 0 0 2 2 0 4
【样例输出】
Yes No Yes
【输入输出样例说明】
第一组数据,由奶酪的剖面图可见:
第一个空洞在 ( 0 , 0 , 0 ) (0,0,0) (0,0,0) 与下表面相切;
第二个空洞在 ( 0 , 0 , 4 ) (0,0,4) (0,0,4) 与上表面相切;
两个空洞在 ( 0 , 0 , 2 ) (0,0,2) (0,0,2) 相切。
输出
Yes
。第二组数据,由奶酪的剖面图可见:
两个空洞既不相交也不相切。
输出
No
。第三组数据,由奶酪的剖面图可见:
两个空洞相交,且与上下表面相切或相交。
输出
Yes
。【数据规模与约定】
对于 20 % 20\% 20% 的数据, n = 1 n = 1 n=1, 1 ≤ h 1 \le h 1≤h, r ≤ 1 0 4 r \le 10^4 r≤104,坐标的绝对值不超过 1 0 4 10^4 104。
对于 40 % 40\% 40% 的数据, 1 ≤ n ≤ 8 1 \le n \le 8 1≤n≤8, 1 ≤ h 1 \le h 1≤h, r ≤ 1 0 4 r \le 10^4 r≤104,坐标的绝对值不超过 1 0 4 10^4 104。
对于 80 % 80\% 80% 的数据, 1 ≤ n ≤ 1 0 3 1 \le n \le 10^3 1≤n≤103, 1 ≤ h , r ≤ 1 0 4 1 \le h , r \le 10^4 1≤h,r≤104,坐标的绝对值不超过 1 0 4 10^4 104。
对于 100 % 100\% 100% 的数据, 1 ≤ n ≤ 1 × 1 0 3 1 \le n \le 1\times 10^3 1≤n≤1×103, 1 ≤ h , r ≤ 1 0 9 1 \le h , r \le 10^9 1≤h,r≤109, T ≤ 20 T \le 20 T≤20,坐标的绝对值不超过 1 0 9 10^9 109。
【分析】
这个题目不像前面两个题目比较清晰,输入情况也稍微复杂一些。虽然复杂,但是还是按照前面两题的惯例来分析:如何判别是否可以使用并查集?谁和谁之间建立连接?如何判断是否需要连接?我们接下来一条一条的分析。
我们以组为单位来分析,每个组中的输入情况都一样。
在每个组中,我们需要做的是判断题目给定的几个洞能否将奶酪上下表面连接起来,我们可以将洞两两连接起来并且将洞和上下表面连接起来,利用并查集连接的传递性,最后再判断上下表面是否连通即可(使用并查集的查询功能来判断)。
谁和谁之间建立联系呢?我们就以输入数据的顺序(编号)作为连接点即可,第一个输入的洞的数据就用编号 1 表示,第 n 个就用编号 n 表示,下、上表面的标号分别记为 0 和 n+1 表示。
如何判断两个洞(球体)是否连接呢?利用两个球心距与半径之和来判断,类似于平面上两个圆的关系,只要两个球体相交或者相切,计算上是 球心距 < = <= <= 半径之和 即可判断为两个洞相连。那球体和奶酪上、下表面的连接关系呢?关系示意图如下所示,如果 z < = r z <= r z<=r 表明某个球体和奶酪下表面相连;如果 z + r > = h z + r >= h z+r>=h 表明某个球体和奶酪上表面相连。
【示例代码】
#include <iostream>
#include <vector>
#include <cmath>
#include <cstdio>
#include<cstring>
using namespace std;
typedef long long ll;
int pa[1005];
int x[1005], y[1005], z[1005];
int nums[1005];
int find(int x) {
return pa[x] == x ? x : pa[x] = find(pa[x]);
}
void unite(int x, int y) {
x = find(x);
y = find(y);
if (x == y) return;
if (nums[x] < nums[y]) swap(x, y);
pa[y] = x;
nums[x] += nums[y];
}
ll dis(ll x1, ll y1, ll z1, ll x2, ll y2, ll z2) {
return (x1 - x2)*(x1 - x2) + (y1 - y2)*(y1 - y2) + (z1 - z2)*(z1 - z2);
}
void init() {
memset(pa, 0, sizeof(pa));
memset(x, 0, sizeof(x));
memset(y, 0, sizeof(y));
memset(z, 0, sizeof(z));
memset(nums, 1, sizeof(nums));
}
int main() {
int T, n, h;
ll r;
cin >> T;
for (int i = 0; i < T; ++i) { // 遍历组
cin >> n >> h >> r;
init();
for (int j = 0; j <= n+1; ++j) { // 奶酪的下上表面分别用 0 和 n + 1 表示
// 初始化父节点为自己
pa[j] = j;
}
for (int j = 1; j <= n; ++j) {
cin >> x[j] >> y[j] >> z[j];
if (z[j] - r <= 0) unite(j, 0); // 底层合并
if (z[j] + r >= h) unite(j, n+1); // 顶层合并
for (int k = 1; k < j; ++k) {
if (dis(x[j], y[j], z[j], x[k], y[k], z[k]) <= 4*r*r) {
unite(j, k);
}
}
}
if (find(0) == find(n+1)) {
cout << "Yes" << endl;
}
else {
cout << "No" << endl;
}
}
return 0;
}
【总结】
- 使用并查集方法
- 序号与序号相连(本质是球体与球体相连)
- 根据相切和相交相连
总结
总结将对三个问题进行总结:并查集使用的时机,适用的情况?谁和谁之间相连?如何判断是否连接?
使用时机
题目中出现 “连接”、“传递关系” 等关键词,那么这个题目使用 并查集 来解题基本就没跑了。
谁和谁相连
你最终要判断谁和谁的关系,那就用这一类作为连接的点。判断人与人之间的关系就用人(编号)来作为连接点;判断球体与球体之间的关系,就用球体(用编号来代替,奶酪的上下表面也用球体来代替)作为连接点。
判断连接
充分利用题目中的信息,岛屿问题中,单元个元素相同且相邻就要连接;亲戚问题中,根据输入信息直接确定连接关系进行连接;奶酪问题中根据球台的关系(相交、相切等)确定连接关系。在 LeetCode 这种核心类编程的形式下,多会给定数组来根据数组建立连接。在 ACM 模式下,会根据自定义输入来建立连接。
其他
并查集代码基本都已经模板化了,变量上包括父节点数组 p a [ ] pa[] pa[]、启发式合并用的高度或者节点数数组 n u m s [ ] nums[] nums[],函数上包括查找 f i n d ( ) find() find()、合并 u n i t e ( ) unite() unite()。会有变化的地方是判断连接的地方。
并查集解题的基本步骤
- 初始化 p a [ ] pa[] pa[],如果明确知道连接点个数,使用 C++ \texttt{C++} C++ 中 v e c t o r < i n t > p a ( n ) ; vector<int> pa(n); vector<int>pa(n); 容器初始化,比如岛屿问题和亲戚问题中的初始化;如果对于连接点的数量不明确只有一个大概的数据范围,使用 C \texttt{C} C 中的数组初始化 i n t p a [ 1005 ] ; m e m s e t ( p a , 0 , s i z e o f ( p a ) ) ; int pa[1005];memset(pa, 0, sizeof(pa)); intpa[1005];memset(pa,0,sizeof(pa));。
- 合并、查找函数的实现,查找就是路径压缩那种实现,合并要看看是否有必要进行启发式合并,当然也可以一股脑用启发式或者直接不用启发式,一般不会卡时间。
- 根据题目信息建立 “关系”,前面已经详细说明了,这里不再啰嗦了。
- 查询就完事了。有的题目不是直接使用查询得到最后结果的,就像岛屿问题是通过合并来减少岛屿的数量,最后输出的是并查集的附带产品岛屿的数量作为答案输出。