L3-图论-第06课 并查集
六度空间理论
一个数学领域的猜想,名为Six Degrees of Separation,中文翻译包括以下几种:六度分割理论或小世界理论等。理论指出:你和任何一个陌生人之间所间隔的人不会超过六个,也就是说,最多通过6个中间人你就能够认识任何一个陌生人,见图所示。这就是六度分割理论,也叫小世界理论。
20世纪60年代,美国心理学家米尔格兰姆设计了一个连锁信件实验。米尔格兰姆把信随机发送给住在美国各城市的一部分居民,信中写有一个波士顿股票经纪人的名字,并要求每名收信人把这封信寄给自己认为是比较接近这名股票经纪人的朋友。这位朋友收到信后,再把信寄给他认为更接近这名股票经纪人的朋友。最终,大部分信件都寄到了这名股票经纪人手中,每封信平均经手6.2次到达。
Facebook在2008年,测算出的人与人的平均分离度还是5.28,后来随着持续增长的用户基数和不断密集的关联网络,用户之间的联系将会进一步变得紧密,2011年已经降低至4.74。
我们可以用并查集进行这种集合的查找和聚合.
并查集
并查集,在一些有N个元素的集合应用问题中,我们通常是在开始时让每个元素构成一个单元素的集合,然后按一定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪个集合中。这一类问题近几年来反复出现在信息学的国际国内赛题中,其特点是看似并不复杂,但数据量极大,若用正常的数据结构来描述的话,往往在空间上过大,计算机无法承受;即使在空间上勉强通过,运行的时间复杂度也极高,根本就不可能在比赛规定的运行时间(1~3秒)内计算出试题需要的结果,只能用并查集来描述。
并查集是一种树型的数据结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。常常在使用中以森林来表示。
并查集被很多OIer认为是最简洁而优雅的数据结构之一,主要用于解决一些元素分组的问题。它管理一系列不相交的集合,并支持两种操作:
- 合并(Union): 把两个不相交的集合合并为一个集合。
- 查询(Find): 查询两个元素是否在同一个集合中。
P1551 亲戚
题目背景
若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
题目描述
规定:x和y是亲戚,y和z是亲戚,那么x和z也是亲戚。如果x,y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。
输入格式
第一行:三个整数 n, m, p, (n<=5000,m<=5000,p<=5000), 分别表示有n个人,m个亲戚关系,询问p对亲戚关系。
以下m行:每行两个数Mi,Mj,1<=Mi,Mj<=N,表示Mi和Mj具有亲戚关系。
接下来p行:每行两个数Pi,Pj,询问Pi和Pj是否具有亲戚关系。
输出格式
P行,每行一个’Yes’或’No’。表示第i个询问的答案为“具有”或“不具有”亲戚关系。
输入输出样例
- 输入 #1复制
6 5 31 21 53 45 21 31 42 35 6
- 输出 #1复制
YesYesNo
并查集的引入
看到集合想不想用 set ?
并查集的重要思想在于,用集合中的一个元素代表集合。
我曾看过一个有趣的比喻,把集合比喻成帮派,而代表元素则是帮主。接下来我们利用这个比喻,看看并查集是如何运作的。
最开始,所有大侠各自为战。他们各自的帮主自然就是自己。(对于只有一个元素的集合,代表元素自然是唯一的那个元素)
现在1号和3号比武,假设1号赢了(这里具体谁赢暂时不重要),那么3号就认1号作帮主(合并1号和3号所在的集合,1号为代表元素)。
现在2号想和3号比武(合并3号和2号所在的集合),但3号表示,别跟我打,让我帮主来收拾你(合并代表元素)。不妨设这次又是1号赢了,那么2号也认1号做帮主。
现在我们假设4、5、6号也进行了一番帮派合并,江湖局势变成下面这样:
现在假设2号想与6号比,跟刚刚说的一样,喊帮主1号和4号出来打一架(帮主真辛苦啊)。1号胜利后,4号认1号为帮主,当然他的手下也都是跟着投降了。
好了,比喻结束了。如果你有一点图论基础,相信你已经觉察到,这是一个树状的结构,要寻找集合的代表元素,只需要一层一层往上访问父节点(图中箭头所指的圆),直达树的根节点(图中橙色的圆)即可。根节点的父节点是它自己。我们可以直接把它画成一棵树:
用这种方法,我们可以写出最简单版本的并查集代码。
- 初始化
int fa[MAXN];void init(int n){ for (int i = 1; i <= n; i++) fa[i] = i;}
假如有编号为1, 2, 3, ..., n的n个元素,我们用一个数组fa[]来存储每个元素的双亲节点(因为每个元素有且只有一个双亲点,所以这是可行的)。一开始,我们先将它们的双亲点设为自己。
- 查询
int find(int x){ if(fa[x] == x) return x; else return find(fa[x]);}
我们用递归的写法实现对代表元素的查询:一层一层访问父节点,直至根节点(根节点的标志就是父节点是本身)。要判断两个元素是否属于同一个集合,只需要看它们的根节点是否相同即可。
- 合并
void merge(int x, int y){ fa[find(x)] = find(y);}
合并操作也是很简单的,先找到两个集合的代表元素,然后将前者的双亲节点设为后者即可。当然也可以将后者的双亲节点设为前者,这里暂时不重要。本文末尾会给出一个更合理的比较方法。
怎么解决呢?我们可以使用路径压缩的方法。既然我们只关心一个元素对应的根节点,那我们希望每个元素到根节点的路径尽可能短,最好只需要一步,像这样:
- 合并(路径压缩)
int find(int x){ if (fa[x] != x) fa[x] = find(fa[x]); return fa[x];}
参考代码
#include using namespace std;const int N = 5005;int n, m, p;int x, y;int fa[N];void init(int n){ for (int i = 1; i <= n; i++) fa[i] = i;}int find(int x){ if (fa[x] != x) fa[x] = find(fa[x]); return fa[x];}void merge(int x, int y){ fa[find(x)] = find(y);}int main(){ cin >> n >> m >> p; init(n); for (int i = 1; i <= m; i++) { cin >> x >> y; merge(x, y); } for (int i = 1; i <= p; i++) { cin >> x >> y; if (find(x) == find(y)) cout << "Yes" << endl; else cout << "No" << endl; } return 0;}
P2814 家谱
题目背景
现代的人对于本家族血统越来越感兴趣。
题目描述
给出充足的父子关系,请你编写程序找到某个人的最早的祖先。
输入格式
输入由多行组成,首先是一系列有关父子关系的描述,其中每一组父子关系中父亲只有一行,儿子可能有若干行,用 #name 的形式描写一组父子关系中的父亲的名字,用 +name 的形式描写一组父子关系中的儿子的名字;接下来用 ?name 的形式表示要求该人的最早的祖先;最后用单独的一个 $ 表示文件结束。
输出格式
按照输入文件的要求顺序,求出每一个要找祖先的人的祖先,格式为:本人的名字 + 一个空格 + 祖先的名字 + 回车。
输入输出样例
- 输入 #1复制
#George+Rodney#Arthur+Gareth+Walter#Gareth+Edward?Edward?Walter?Rodney?Arthur$
- 输出 #1复制
Edward ArthurWalter ArthurRodney GeorgeArthur Arthur
说明/提示
规定每个人的名字都有且只有 6 个字符,而且首字母大写,且没有任意两个人的名字相同。最多可能有 组父子关系,总人数最多可能达到 人,家谱中的记载不超过 30 代。
分析
很典型的并查集题目, 如果把人名改成数字该多好啊就和亲戚那道题差不多了. 把字符串转成整数? 不用的. 神奇的 「map」 可以支持字符串做下标的. 其他的操作套并查集模板就好了.
参考代码
略
种类并查集
种类并查集可以维护敌人的敌人是朋友这样的关系,这种说法不够准确,较为本质地说,种类并查集(包括普通并查集)维护的是一种循环对称的关系。
所以如果是三个及以上的集合,只要每个集合都是等价的,且集合间的每个关系都是等价的,就能够用种类并查集进行维护。
P2024 [NOI2001]食物链
题目描述
动物王国中有三类动物 A,B,C,这三类动物的食物链构成了有趣的环形。A 吃 B,B吃 C,C 吃 A。
现有 N 个动物,以 1 - N 编号。每个动物都是 A,B,C 中的一种,但是我们并不知道 它到底是哪一种。
有人用两种说法对这 N 个动物所构成的食物链关系进行描述:第一种说法是“1 X Y”,表示 X 和 Y 是同类。第二种说法是“2 X Y”,表示 X 吃 Y 。
此人对 N 个动物,用上述两种说法,一句接一句地说出 K 句话,这 K 句话有的是真 的,有的是假的。当一句话满足下列三条之一时,这句话就是假话,否则就是真话。
- 当前的话与前面的某些真的话冲突,就是假话
- 当前的话中 X 或 Y 比 N 大,就是假话
- 当前的话表示 X 吃 X,就是假话
你的任务是根据给定的 N 和 K 句话,输出假话的总数。
输入格式
第一行两个整数,N,K,表示有 N 个动物,K 句话。
第二行开始每行一句话(按照题目要求,见样例)
输出格式
一行,一个整数,表示假话的总数。
输入输出样例
- 输入 #1复制
100 71 101 12 1 22 2 32 3 31 1 32 3 11 5 5
- 输出 #1复制
3
说明/提示
分析
我们需要维护3个种类 a, b, c. 动物总数是n个. 可以用一个数组val[i] 表示 i 动物属于哪一类. 这种实际上属于带权并查集了. 这里呢, 我们用另外一种方法. 设 i 是 a类, 然后就是用 i+n 和 i+2n 表示b c的种类。这样呢, 其中 1∼n 的部分为 A 群系,n+1∼2n 的部分为 B 群系,2n+1∼3n 的部分为 C 群系.
怎么理解呢?
对于x, x 吃 n+x, 2n+x 吃 x. 我们可以把 x n+x, 2n+x 看成3个不同的动物.
如果x 和 y 是同类, 那么, n+x 和 n+y也是同类, 因为同类动物吃同样的食物. 2n+x 和 2n+y 也是同类, 被同类动物被同样的天敌吃掉. 所以用并查集合并同类.
merge(x, y); //x和y是一族merge(x + n, y + n); //x的猎物和y的猎物是一族merge(x + 2 * n, y + 2 * n); //x的天敌和y的天敌是一族
- 如果 x 吃 y, 那么容易得出 n+x 和 y 是同类, 因为都被 x 吃. z 的天敌 2n+z 和 x 是同类, 因为都吃 z.
merge(x, y + 2 * n); // 都吃 ymerge(x + n, y); // 都被 x 吃merge(x + 2 * n, n+y); // 都被 y 吃掉
参考代码
#include using namespace std;const int MAXN = 5e4 + 6;int fa[MAXN * 3];int n, k, opt , x, y;int ans;void init(int n){ for (int i = 1; i <= n; i++) fa[i] = i;}int find(int x){ if (fa[x] != x) fa[x] = find(fa[x]); return fa[x];}void merge(int x, int y){ fa[find(x)] = find(y);}int main(){ ios::sync_with_stdio(false); cin >> n >> k; init(n * 3); while(k--) { cin >> opt >> x >> y; if (x > n || y > n) { ans++; continue; } if (opt == 1) { if ((find(x) == find(y + n)) || (find(x) == find(y + 2 * n))) ans++; else { merge(x, y); //x和y是一族 merge(x + n, y + n); //x的猎物和y的猎物是一族 merge(x + 2 * n, y + 2 * n); //x的天敌和y的天敌是一族 } } else if (opt == 2) { if ((find(x) == find(y)) || (find(x) == find(y + n))) ans++; else { merge(x, y + 2 * n); // 都吃 y merge(x + n, y); // 都被 x 吃 merge(x + 2 * n, n+y); // 都被 y 吃掉 } } } cout << ans << endl; return 0;}
带权并查集
有的时候在边中添加一些额外的信息可以更好的处理需要解决的问题,在每条边中记录额外的信息的并查集就是带权并查集。
带权并查集中经常加个 value[] 表示权值. 带权并查集可以推算集合内点的关系,而一般并查集只能判断属于某个集合。
每个节点都记录的是与根节点之间的权值,那么在Find的路径压缩过程中,权值也应该做相应的更新,因为在路径压缩之前,每个节点都是与其父节点链接着,那个Value自然也是与其父节点之间的权值
在两个并查集做合并的时候,权值也要做相应的更新,因为两个并查集的根节点不同。
权值更新的一般方法:
int find(int x){ if (fa[x] != x) { int fx = fa[x]; fa[x] = find(fa[x]); value[x] += value[fx]; } return fa[x];}
void merge(int x, int y){ int px = find(x); int py = find(y); if (px != py) { fa[px] = py; value[px] = -value[x] + value[y] + s; }}
P2024 [NOI2001]食物链
题目内容见上
分析
食物链上的物种A, B, C 是种相对关系, 是同类, 吃, 和被吃的关系. 可以用权值记录这种关系.
- 0 表示和根节点是同类关系
- 1 表示和跟节点是捕食关系(吃根节点)
- 2 表示和根节点是被捕食关系(被根节点吃)
- 路径压缩时,如何更新Value
结点A与根关系 | 结点B与A关系 | B与根关系 |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
0 | 2 | 2 |
1 | 0 | 1 |
1 | 1 | 2 |
1 | 2 | 0 |
2 | 0 | 2 |
2 | 1 | 0 |
2 | 2 | 1 |
从表格中我们显然可以得到关系value[b] = (value[a] + value[b]) % 3
. 所以路径压缩代码:
int find(int x ){ if ( x != fa[x]) { int fx = fa[x]; fa[x] = find(fa[x]); value[x] = (value[x] + value[fx]) % 3; } return fa[x];}
那么反推, 如果 A 和 B 的根是相同的, 那么有relation[a->b] = (value[a] - value[b]) % 3
所以判断真假代码:
if(find(x) == find(y)){ // 如果两个根节点相同 relation = (value[x] - value[y] + 3) % 3; // 推出两个根节点之间的关系 return relation == r; // 判断给出关系是否与已经存在的关系矛盾 }
- 区间合并时,如何更新Value
结点A与根关系 | 结点B与根关系 | 结点B与A的关系 | B根节点和A根节点的关系 |
---|---|---|---|
0 | 0 | 0 | 0 |
0 | 0 | 1 | 1 |
0 | 0 | 2 | 2 |
0 | 1 | 0 | 2 |
0 | 1 | 1 | 0 |
0 | 1 | 2 | 1 |
0 | 2 | 0 | 1 |
0 | 2 | 1 | 2 |
0 | 2 | 2 | 0 |
上面这个表并没有列出所有情况,但是我们已经可以从表格中可以得到关系
value[fa[b]->fa[a]] = (value[a] - value[b] + value[b -> a]) % 3
合并关系的代码如下:
void merge(int x, int y, int r){ int fx = find(x); int fy = find(y); if(fx != fy){ fa[fx] = fy; value[fx] = (value[y] - value[x] + r + 3) % 3; }}
参考代码
#include #include using namespace std;const int MAXN = 5e4 + 6;int fa[MAXN], value[MAXN]; //0:同类 1:吃 2:被吃int n, k, opt , x, y;int ans;void init(int n){ for (int i = 1; i <= n; i++) { fa[i] = i; // value[i] = 0; }}int find(int x ){ if ( x != fa[x]) { int fx = fa[x]; fa[x] = find(fa[x]); value[x] = (value[x] + value[fx]) % 3; } return fa[x];}void merge(int x, int y, int r){ int fx = find(x); int fy = find(y); if(fx != fy){ fa[fx] = fy; value[fx] = (value[y] - value[x] + r + 3) % 3; }}bool solve(int x,int y,int r){ // 判断真话假话 int relation; if(x > n||y > n||(r == 1&&x == y)){ // 根据题意直接判断的假话 return false; } if(find(x) == find(y)){ // 如果两个根节点相同 relation = (value[x] - value[y] + 3) % 3; // 推出两个根节点之间的关系 return relation == r; // 判断给出关系是否与已经存在的关系矛盾 } else return true; //否则为真}int main(){ cin >> n >> k; init(n); while (k--) { cin >> opt >> x >> y; opt--; if(solve(x,y,opt)){ merge(x,y,opt); //真话合并两个节点关系 }else{ ans++; //假话答案自增 } } cout << ans; return 0;}
题单
- P1551 亲戚
- P1536 村村通
- P1525 关押罪犯
- P1621 集合
- P1892 [BOI2003]团伙
- P1955 [NOI2015]程序自动分析
- P3879 [TJOI2010]阅读理解
- P2814 家谱
云帆优培订阅号:
云帆优培服务号:
云帆优培老师联系方式:
云帆老师
微信:
云帆优培介绍