并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题(即所谓的并、查)。比如说,我们可以用并查集来判断一个森林中有几棵树、某个节点是否属于某棵树等。
主要构成:
并查集主要由一个整型数组pre[ ]和两个函数find( )、join( )构成。
数组 pre[ ] 记录了每个点的前驱节点是谁,函数 find(x) 用于查找指定节点 x 属于哪个集合,函数 join(x,y) 用于合并两个节点 x 和 y 。
普通并查集所维护的关系是:朋友的朋友是朋友。
重点是在关注两个人是否连通,因此他们具体是如何连通的,内部结构是怎样的,甚至根节点是哪个,即代表元是哪个(集合中的某个元素来代表这个集合,则该元素称为此集合的代表元)这些都不重要。所以并查集在初始化时,代表元可以随意选择,只要能分清敌友关系就行。
find( )函数的定义与实现
首先我们需要定义一个数组:int pre[1000]; (数组长度依题意而定)。这个数组记录了每个元素的父级是谁。这些元素从0或1开始编号(依题意而定)。比如说pre[16]=6就表示16号的父级是6号。如果一个人的父级就是他自己,那说明他就是代表元了,查找到此结束。也有单个元素自成一派的,那么他的父级就是他自己。
每个人都只认自己的父级。至于代表元是哪个可不认识。要想知道自己代表元的名称,只能一级级查上去。因此你可以把find(x)这个函数当作就是找代表元用的。
int find(int x) //查找x的代表元
{
while(pre[x] != x) //如果x的父级不是自己(则说明找到的不是代表元)
x = pre[x]; //x继续找他的父级,直到找到代表元为止
return x; //代表元找到了
}
join( )函数的定义与实现
join其实说白了就是把森林里两颗不同的树联系起来成为朋友。目的就是合并,至于合并后他们的内部结构怎么样不需要关心,因为在这里并不重要,反正谁加入谁效果是一样的。
join(x,y)的执行逻辑如下:
- 寻找 x 的代表元;
- 寻找 y 的代表元;
- 如果 x 和 y 不相等,则随便选一个人作为另一个人的父级,如此一来就完成了 x 和 y 的合并。
void join(int x,int y) //我想让x和y做朋友
{
int fx=find(x), fy=find(y); //找到x的老大,找到y的老大
if(fx != fy) //两个老大显然不是同一个
pre[fx]=fy; //随便哪个当新老大(诶~就是这么佛)
}
路径压缩算法
一、优化find( )函数
问题引入:
前面介绍的 join(x,y) 实际上为我们提供了一个将不同节点进行合并的方法。通常情况下,我们可以结合着循环来将给定的大量数据合并成为若干个更大的集合(即并查集)。但是问题也随之产生,我们来看这段代码:
if(fx != fy)
pre[fx]=fy;
这里并没有明确谁是谁的前驱(父级)的规则,而是我直接指定后面的数据作为前面数据的前驱(父级)。那么这样就导致了最终的树状结构无法预计,即有可能是良好的 n 叉树,也有可能是单支树结构(一字形)。试想,如果最后真的形成单支树结构,那么它的效率就会及其低下(树的深度过深,那么查询过程就必然耗时)。
而最理想的情况就是所有元素的直接父级都是代表元,这样一来整个树的结构就只有两级,此时查询代表元只需要一次。这就产生了路径压缩算法。
当从某个节点出发去寻找它的根节点时,我们会途径一系列的节点,在这些节点中,除了根节点外,其余所有节点都需要更改直接前驱为根节点。
因此,基于这样的思路,我们可以通过递归的方法来逐层修改返回时的某个节点的直接前驱(即pre[x]的值)。简单说来就是将x到根节点路径上的所有点的pre(上级)都设为根节点。
int find(int x) //查找结点 x的根结点
{
if(pre[x] == x) return x; //递归出口:x的父级为 x本身,即 x为根结点
return pre[x] = find(pre[x]); //此代码相当于先找到根结点 rootx,然后pre[x]=rootx
}
二、加权标记法(优化join()函数)
该方法需要将树中所有节点都增设一个权值,用以表示该节点所在树中的高度(比如用rank[x]=3表示 x 节点所在树的高度为3)。这样一来,在合并操作的时候就能通过这个权值的大小来决定谁当谁的父级。
在合并操作的时候,假设需要合并的两个集合的代表元分别为 x 和 y,则只需要令pre[x] = y 或者pre[y] = x 即可。但我们为了使合并后的树不产生退化(即:使树中左右子树的深度差尽可能小),那么对于每一个元素 x ,增设一个rank[x]数组,用以表达子树 x 的高度。在合并时,如果rank[x] < rank[y],则令pre[x] = y;否则令pre[y] = x。
此时合并后就是:
这样合并前两个树的最大高度为3,合并后依然是3。但如果令pre[A]= F,那么A那三层跑F下面就会使得合并后的树的总高度增加 。
加权标记法的核心在于对rank数组的逻辑控制,其主要的情况有:
- 如果rank[x] < rank[y],则令pre[x] = y;
- 如果rank[x] == rank[y],则可任意指定上级;
- 如果rank[x] > rank[y],则令pre[y] = x;
void join(int x,int y)
{
x=find(x); //寻找 x的代表元
y=find(y); //寻找 y的代表元
if(x==y) return ; //如果 x和 y的代表元一致,说明他们共属同一集合,则不需要合并,直接返回;否则,执行下面的逻辑
if(rank[x]>rank[y]) pre[y]=x; //如果 x的高度大于 y,则令 y的上级为 x
else{
if(rank[x]==rank[y]) rank[y]++; //如果 x的高度和 y的高度相同,则令 y的高度加1
pre[x]=y; //让 x的上级为 y
}
}
例题如下:
合根植物
w星球的一个种植园,被分成 m * n 个小格子(东西方向m行,南北方向n列)。每个格子里种了一株合根植物。这种植物有个特点,它的根可能会沿着南北或东西方向伸展,从而与另一个格子的植物合成为一体。如果我们告诉你哪些小格子间出现了连根现象,你能说出这个园中一共有多少株合根植物吗?
输入格式
第一行,两个整数m,n,用空格分开,表示格子的行数、列数(1<m,n<1000)。
接下来一行,一个整数k,表示下面还有k行数据(0<k<100000)
接下来k行,第行两个整数a,b,表示编号为a的小格子和编号为b的小格子合根了。 格子的编号一行一行,从上到下,从左到右编号。
比如:5 * 4 的小格子,编号:
1 2 3 4
5 6 7 8
9 10 11 12
13 14 15 16
17 18 19 20
样例输入
5 4
16
2 3
1 5
5 9
4 8
7 8
9 10
10 11
11 12
10 14
12 16
14 18
17 18
15 19
19 20
9 13
13 17
样例输出
5
样例说明
其合根情况参考下图
import java.util.Scanner;
public class Main {
static int n,m,k,a,b;
static int[] pre=new int[1000010];//存放代表元
static int[] rank=new int[1000010];//树的高度
static int find(int x) {//找父级
if (pre[x]==x) {
return x;
}else {
return pre[x]=find(pre[x]);
}
}
static void join(int x,int y) {//合并
int fx=find(x),fy=find(y);
if (fx==fy) {
return;
}
if (rank[fx]>rank[fy]) {
pre[fy]=fx;
}else {
if (rank[fx]==rank[fy]) {
rank[fy]++;
}
pre[fx]=fy;
}
}
public static void main(String[] args) {
Scanner in=new Scanner(System.in);
n=in.nextInt();
m=in.nextInt();
for (int i = 1; i <=m*n; i++) {
pre[i]=i;//开始每一个点都是一个集合
rank[i]=1;
}
k=in.nextInt();
for (int i = 0; i < k; i++) {
a=in.nextInt();
b=in.nextInt();
join(a, b);
}
int a[]=new int[1000010];
int cnt=0;
for (int i = 1; i <=m*n; i++) {//再遍历表格,把他们的代表元赋为1
a[pre[i]]=1;
if (a[i]==1) {
cnt++;
}
}
System.out.println(cnt);
}
}
(其实我不知道Java用并查集写会不会超时,但是自己调试出了答案。因为找不到这个题的测试入口了;但是本意是想学习一下并查集就那它试试水。)
国王的烦恼
C国由n个小岛组成,为了方便小岛之间联络,C国在小岛间建立了m座大桥,每座大桥连接两座小岛。两个小岛间可能存在多座桥连接。然而,由于海水冲刷,有一些大桥面临着不能使用的危险。
如果两个小岛间的所有大桥都不能使用,则这两座小岛就不能直接到达了。然而,只要这两座小岛的居民能通过其他的桥或者其他的小岛互相到达,他们就会安然无事。但是,如果前一天两个小岛之间还有方法可以到达,后一天却不能到达了,居民们就会一起抗议。
现在C国的国王已经知道了每座桥能使用的天数,超过这个天数就不能使用了。现在他想知道居民们会有多少天进行抗议。
输入格式
输入的第一行包含两个整数n, m,分别表示小岛的个数和桥的数量。
接下来m行,每行三个整数a, b, t,分别表示该座桥连接a号和b号两个小岛,能使用t天。小岛的编号从1开始递增。输出格式
输出一个整数,表示居民们会抗议的天数。样例输入
4 4
1 2 2
1 3 2
2 3 1
3 4 3样例输出
2样例说明
第一天后2和3之间的桥不能使用,不影响。
第二天后1和2之间,以及1和3之间的桥不能使用,居民们会抗议。
第三天后3和4之间的桥不能使用,居民们会抗议。数据规模和约定
对于30%的数据,1<=n<=20,1<=m<=100;
对于50%的数据,1<=n<=500,1<=m<=10000;
对于100%的数据,1<=n<=10000,1<=m<=100000,1<=a, b<=n, 1<=t<=100000。
如果按照题意顺势思考去看哪个桥先断,居民开始抗议。老老实实的去模拟一座桥一座桥的坏掉,那在每一次坏掉一座桥的时候都需要从头去联合这些桥以判断所有小岛的连通性,这样极有可能会超时。那不如反着来,不去模拟桥的坏掉,而是去模拟桥的修建怎么样。
将给出的测试数据由桥的使用时限进行降序排序(此时使用时间最长的就是最开始被枚举的,因为它最晚坏,所以在逆向看来它是最先修建的)
那么先来模拟一下情况:题意里使用时效最长的是3天,那我们把刚开始大家都不连通看作第四天。
时间来到第三天,3、4城市连通。原本按照题意它第四天断了居民要抗议的,那我们既然反着来了,就把它看作桥连通了,居民欢呼怎么样。这样就从求抗议次数变欢呼次数(所以此时ans++)
接下来是第2天,1、2通了,居民欢呼,ans++。但是这一天1、3也通了,但是因为是同一天,那欢呼一次就够了。需要注意的是:我们的程序是顺序执行的,因此其会将这个情况视为两天不同的情况。
==》为了规避这种情况可以建立一个lastDay变量,这个变量的作用是记录前一次某个桥的使用天数,如果在循环中,检测到当前桥的使用天数和lastDay不相等,并且将当前桥连接的两个小岛进行 join 操作后其确实使得这两个岛的代表元发生了改变,就说明此时需要执行ans++,否则一律不执行。
第一天时所有小岛都连通了,但是呢早在第二天的时候,4个岛的居民就可以互相走动了,而且题目中也说了 " 如果前一天两个小岛之间还有方法可以到达,后一天却不能到达了,居民们就会一起抗议。"那么反过来就是如果前一天就有方法岛到达,那后一天依旧能 ,后一天就不会再欢呼。这么推算下来就是居民一共欢呼2次。放在题目里就是一共抗议两次。(用并查集里的思想来说就是:如果前一天他们的根节点已经是相同的了,后期的操作不改变根节点的话ans就不需要自加了)
代码如下:
import java.util.Scanner;
public class Main {
static int N=10010;
static int M=100010;
static int[] pre=new int[N];
static int[] rank=new int[N];
static int n,m,a,b,t;
static int find(int x) {//查找父级
if (pre[x]==x) {
return x;
}else {
return pre[x]=find(pre[x]);
}
}
static boolean join(int x,int y) {//合并
int fx=find(x),fy=find(y);
if (fx==fy) {//在同一个根系里
return false;
}
if (rank[fx]>rank[fy]) {
pre[fy]=fx;
}else {
if (rank[fx]==rank[fy]) {
rank[fy]++;
}
pre[fx]=fy;
}
return true;
}
public static void main(String[] args) {
Scanner in=new Scanner(System.in);
n=in.nextInt();
m=in.nextInt();
//初始化,每一个城市都是一个根节点
for (int i = 1; i <=n; i++) {
pre[i]=i;
rank[i]=1;
}
int[][] bridge=new int[M][4];
for (int i = 1; i <=m; i++) {
a=in.nextInt();
b=in.nextInt();
t=in.nextInt();
bridge[i][1]= a;
bridge[i][2]= b;
bridge[i][3]= t;
}
for (int i = 2; i <= m; i++) {//实现降序排列
if (bridge[i][3]>bridge[i-1][3]) {//交换
int temp=bridge[i-1][3];
bridge[i-1][3]=bridge[i][3];
bridge[i][3]=temp;
}
}
int ans=0,lastDay=0;
for (int i = 1; i <=m; i++) {
boolean flag=join(bridge[i][1], bridge[i][2]);//如果为真表示当前这两个岛未联通
if (flag && bridge[i][3] != lastDay) {
//未连通,且此桥的天数是第一次出现,那么就增加了抗议的天数
ans++;
lastDay=bridge[i][3];
}
}
System.out.println(ans);
}
}
(其实我不知道Java用并查集写会不会超时,但是自己调试出了答案。因为找不到这个题的测试入口了;但是本意是想学习一下并查集就那它试试水。)