题意
【大家出门还是要戴好口罩噢😷】
新型冠状病毒肺炎(Corona Virus Disease 2019,COVID-19),简称“新冠肺炎”,是指2019新型冠状病毒感染导致的肺炎。
如果一个感染者走入一个群体,那么这个群体需要被隔离!
小A同学被确诊为新冠感染,并且没有戴口罩!!!!!!
危!!!
时间紧迫!!!!
需要尽快找到所有和小A同学直接或者间接接触过的同学,将他们隔离,防止更大范围的扩散。
众所周知,学生的交际可能是分小团体的,一位学生可能同时参与多个小团体内。
请你编写程序解决!戴口罩!!
Input
多组数据,对于每组测试数据:
第一行为两个整数n和m(n = m = 0表示输入结束,不需要处理),n是学生的数量,m是学生群体的数量。0 < n <= 3e4 , 0 <= m <= 5e2
学生编号为0~n-1
小A编号为0
随后,m行,每行有一个整数num即小团体人员数量。随后有num个整数代表这个小团体的学生。
Output
输出要隔离的人数,每组数据的答案输出占一行
输入样例
100 4
2 1 2
5 10 13 11 12 14
2 0 1
2 99 2
200 2
1 5
5 1 2 3 4 5
1 0
0 0
输出样例
4
1
1
提示
分析
这道题就是一道经典的利用并查集的基本应用解决的题目。
- 并查集
并查集实际上就是通过数组实现的集合结构。将属于同一集合的放在一起,每个集合中有一个代表元素,可以称之为祖先,来代表和区分每个不同集合。
通过查找每个元素的祖先和将一个祖先的祖先设置为另一个集合的祖先来实现集合的查找和不同集合的合并。
基本功能:
- 查找一个元素所在集合的祖先
int finds(int n) //查找祖先
{
if( student[n] == n )
return n;
else
return finds(student[n]);
}
- 合并两个元素所在的不同集合
void unite(int a,int b) //合并集合
{
int a1 = finds(a); //查找a和b的祖先
int b1 = finds(b);
if( a1 == b1 ) //若为同一个集合,则不需要合并
return;
student[b1] = a1; //合并集合
}
并查集在使用前需要初始化,即每个元素其自身为一个集合:
int initialnize()
{
for( int i = 0 ; i < n ; i++ )
//初始化,每个成员单独为一个集合
student[i] = i;
}
- 并查集的实现和优化
并查集中,将一个元素指向它的上一个父亲,直到一个元素的父亲为其本身,说明这个元素为该集合的祖先。并查集利用数组来模拟这个集合表示。每个下标对应的数组空间中存储的元素是该下标对应编号的节点或元素的父亲的编号。当该下标对应编号的节点的父亲的编号为其自身时,说明该节点为祖先。
sets[kid] = parent;
set[ancestor] = ancestor;
并查集并不复杂,并且在许多算法设计中经常用于优化搜索算法的性能。但是并查集本身仍然可以进一步优化。
💡优化
- 路径压缩(存储结构优化)
容易想到,并查集可以化作一个树形结构,叶子表示孩子、父节点表示父亲、根节点表示祖先,边即代表两个节点相关联。一棵树即为一个集合,当一个节点与一棵树中的任意节点相连时,该节点就属于这棵树的根节点所代表的集合。
正因此,当从树中任意一个节点查找该树的祖先,也就是从任意一个元素查找其所在集合的祖先时,最大的时间复杂度为O(logn),即为树的高度。
但实际上,在并查集中,父节点除了连接祖先与孩子之外,没有其他实际含义。因此,若除祖先之外的节点都直接与祖先连接,那么查找的复杂度就可以优化为Θ(1)。
- 在查询一个元素所在集合时,若当前被查询元素不为祖先,则将其父节点等于递归调用的结果,即将其直接与祖先节点相连。
int finds(int n) //查找祖先
{
if( student[n] == n )
return n;
else
return student[n] = finds(student[n]);
}
- 合并(结构优化)
同上一点的分析,基于并查集的树结构可知,两个集合的合并,实际上就是两棵树的合并。也就是其中一棵树的根节点成为另一棵树根节点的叶子。
同样基于搜素性能的最优化原则,当一棵并查集树的层数越少时,其搜索性能越好。所以我们要使两棵树合并后的新树层数最少。
显然,将元素少的树挂在元素更多的树之下,比元素多的树挂在元素少的树之下得到的新树层数会更少。因此,将小树挂在大树上,能够达到合并后结构最优化的结果。
- 用一个数组记录以每个元素为祖先的集合之中的元素个数。该数组初始化时全为1,因为此时所有元素都是其只有自身一个元素的集合的祖先。
int initialnize()
{
for( int i = 0 ; i < n ; i++ )
//初始化,每个成员单独为一个集合
{
student[i] = i;
numbers[i] = 1;
}
}
- 每次合并两个集合时,比较两个集合的元素个数,将小集合的祖先的祖先更新为大集合的祖先,实现合并。
void unite(int a,int b) //合并集合
{
int a1 = finds(a); //查找a和b的祖先
int b1 = finds(b);
if( a1 == b1 ) //若为同一个集合,则不需要合并
return;
if( numbers[b1] > numbers[a1] ) //始终保持a集合中元素更多
swap(b1, a1);
student[b1] = a1; //小集合挂在大集合上
numbers[a1] += numbers[b1]; //a集合的元素树为合并之后的和
}
- 题目分析
题目给出的每个小团体,实际上就是一个集合,代表了曾经互相接触过的同学。
当分别来自两个集合中的任意两个同学接触过后,就代表它们相互所在的集合与对方的集合间接接触过。因此只要两个元素出现在了同一团体,就应该将两个元素所在的集合合并。
因此,只需要依次把每个小团体中的每个成员与其前一个成员所在集合合并,就能将所有互相直接或间接接触过的同学分到各自对应的大集合中。最后检索0号同学所在集合的元素数量即可得到答案。
- 遇到的问题
1.合并集合时的比较
合并集合时比较的不是两个元素的父节点,而是两个元素所在集合的祖先。
因为只有祖先才能唯一区分集合,而两个元素的父节点不相同并不代表它们不来自同一个集合。
而合并集合必须是针对两个元素所在集合的祖先进行,否则就只是合并了这两个元素,或是这两个元素以及它们分别的父节点,这实际上只是最大集合的一个子集合。
2. 为什么答案不是存储集合个数的数组中下标对应0号同学的位置存储的元素
存储集合个数的的数组中实际上是存储的以其下标对应节点为祖先的集合之中元素个数。因为合并操作都是针对每个元素所在最大集合的祖先,所以集合中的其余元素以其自身为祖先的集合实际上都没有发生变化。
而在这个题目中,0号并不一定是答案集合的祖先,因此直接输出以0号为祖先的集合元素个数肯定是错误的。而应该找到0号所在最大集合的祖先其对应的集合元素个数。
总结
- 并查集虽简单,但是总是写了又忘,忘了又写🤪
代码
//
// main.cpp
// lab2
//
//
#include <iostream>
#include <vector>
using namespace std;
vector<int> student(1000000,-1);
vector<int> numbers(1000000,0);
int finds(int n) //查找祖先
{
if( student[n] == n )
return n;
else
return student[n] = finds(student[n]);
}
void unite(int a,int b) //合并集合
{
int a1 = finds(a); //查找a和b的祖先
int b1 = finds(b);
if( a1 == b1 ) //若为同一个集合,则不需要合并
return;
if( numbers[b1] > numbers[a1] ) //始终保持a集合中元素更多
swap(b1, a1);
student[b1] = a1; //小集合挂在大集合上
numbers[a1] += numbers[b1]; //a集合的元素树为合并之后的和
}
int main()
{
ios::sync_with_stdio(false);
int n = 0,m = 0,num = 0,a = 0,b = 0;
cin>>n>>m;
while( n != 0 || m != 0)
{
for( int i = 0 ; i < n ; i++ ) //初始化,每个成员单独为一个集合
{
student[i] = i;
numbers[i] = 1;
}
for( int i = 0 ; i < m ; i++ ) //把每个小团体的成员都放在第一个成员的集合下
{
cin>>num;
cin>>a; //输入第一个编号
num--;
while( num-- )
{
cin>>b;
unite(a, b); //合并当前编号与前一个编号的集合
a = b;
}
}
cout<<numbers[finds(0)]<<endl; //输出0所在集合的元素数量
student.clear();
cin>>n>>m;
}
return 0;
}