「笔记」不仅仅是并查集
Update on 2022.05.10:时隔多年,我又重新理解了一下种类并查集 其实是为了给学妹讲题,在食物链的代码中重新详细的解释了一下其具体含义,希望能够帮助理解。
写在前面
并查集是一种树形的数据结构。
并查集能维护连通性,传递性。通俗的说:亲戚的亲戚是亲戚。
并查集一般支持两个操作:查询两个元素是否在同一个联通块内,合并两个联通块。
普通并查集
来道例题
初始化
初始状态下每个元素只与自己联通,我们用 \(fa_i\) 表示 \(i\) 所处联通块内的根节点,显然初始化 \(fa_i = i\)。
寻找根节点
不断跳 \(fa\) 即可。
代码只有一行:
int Find(int x) { return fa[x] == x ? x : Find(fa[x]); }
复杂度 \(O(n)\).
合并
假设我们要合并 \(u,v\)。显然只需要让 \(u,v\) 所在联通块内的点都指向同一个根节点即可。
我们可以先找到 \(u,v\) 所在的根节点 \(uf, vf\),然后使 \(fa_{uf} = vf\),这样所有的点指向的跟结点就均为 \(vf\)。
Code:
void Hb(int x, int y) {
int xf = Find(x), yf = Find(y);
if(xf != yf) fa[xf] = yf;
}
复杂度 \(O(n)\)
查询
也比较简单,看看两个元素的根节点是否相同即可。
Code:
bool Check(int x, int y) { return Find(x) == Find(y); }
复杂度 \(O(n)\)。
路径压缩
按照上面的方法操作,如果出题人特意构造数据,会把我们卡成 \(O(n^2)\)。
我们考虑优化一下,让每个元素直接指向它的根节点,免去跳多步的麻烦。
优化后代码:
int Find(int x) { return fa[x] == x ? x : fa[x] = Find(fa[x]); }
稍微一改即可。
带权并查集
看道例题 P4079 [SDOI2016]齿轮
对每个结点另存一个值 \(k_u = 1\)。
当 \(u,v\) 之间出现传动比关系时,令 \(fa_u = v\),且 \(k_u = \frac{x}{y}\)。
同样带权并查集也可以进行路径压缩。
若使 \(fa_u = fa_{fa_u}\),则 \(k_u = k_u \times k_{fa}\)
联通块合并:
已知 \(k_u,k_v\),现在要连接 \(u,v\),即 \(k_u^, = \frac{x}{y}\),我们要让 \(fa_u \to fa_v\),设值为 \(k_{fa_u}\)。
因为要保证转动比合法且不变。所以 \(k_u \times k_{fa_u} = f_u^, \times f_v\),解得 \(k_{fa_u} = \frac{f_u^, \times f_v}{k_u}\)。
同一联通块内的两点判断是否有解:看看这个式子是否成立即可 \(k_u \times k_{fa_u} = f_u^, \times f_v\)。
/*
Work by: Suzt_ilymics
Problem: 不知名屑题
Knowledge: 垃圾算法
Time: O(能过)
*/
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
#include<queue>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl
using namespace std;
const int MAXN = 1e5+5;
const int INF = 1e9+7;
const int mod = 1e9+7;
const double lim = 1e-10;
int T, n, m;
int fa[1010];
double k[1010];
int read(){
int s = 0, f = 0;
char ch = getchar();
while(!isdigit(ch)) f |= (ch == '-'), ch = getchar();
while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
return f ? -s : s;
}
int Find(int x) {
if(fa[x] == x) return x;
int tmp = Find(fa[x]); // 带权并查集路径压缩
k[x] *= k[fa[x]];
return fa[x] = tmp;
}
int main()
{
T = read(); int cnt = 0;
while(++cnt <= T) {
n = read(), m = read();
for(int i = 1; i <= n; ++i) fa[i] = i, k[i] = 1.00;
bool flag = false;
for(int i = 1, u, v, x, y; i <= m; ++i) {
u = read(), v = read(), x = read(), y = read();
int uf = Find(u), vf = Find(v);
if(uf != vf) {
fa[uf] = vf;
k[uf] *= (1.0 * x * k[v]) / (1.0 * y * k[u]);
} else {
double K = (k[u] / k[v]);
if(abs(K - (1.0 * x / y)) > lim) flag = true;
}
}
if(flag) printf("Case #%d: No\n", cnt);
else printf("Case #%d: Yes\n", cnt);
}
return 0;
}
种类并查集
两道例题:P1525 [NOIP2010 提高组] 关押罪犯, P2024 [NOI2001] 食物链。
在这种情况下,我们需要维护这样一种关系:
敌人的敌人是朋友。
常见的做法是将原并查集扩大一倍规模,并划分为两个种类。
在同个种类的并查集中合并,和原始的并查集没什么区别,仍然表达他们是朋友这个含义。
考虑在不同种类的并查集中合并的意义,其实就表达 他们是敌人 这个含义了。
按照并查集美妙的 传递性,我们就能具体知道某两个元素到底是 敌人 还是 朋友 了。
至于某个元素到底属于两个种类中的哪一个,由于我们不清楚,因此两个种类我们都试试。
如果是食物链这道题,我们应该把原并查集扩大三倍规模。只有敌人的敌人的敌人才是朋友。修改的时候也要同时修改三个种类之间的关系。我们也可以继续推广到更大的规模。
放一下两个例题的代码:
关押罪犯 Code:
#include<iostream>
#include<cstdio>
#include<algorithm>
using namespace std;
const int MAXN = 2e4+4;
const int MAXM = 1e5+5;
struct edge{
int from, to, w;
bool operator < (const edge &b) const {return w < b.w; }
}e[MAXM];
int read(){
int s = 0, w = 1;
char ch = getchar();
while(ch < '0' || ch > '9') { if(ch == '-') w = -1; ch = getchar(); }
while(ch >= '0' && ch <= '9') s = (s << 1) + (s << 3) + ch - '0', ch = getchar();
return s * w;
}
int n, m;
int fa[MAXN << 1];
int find(int x){return fa[x] == x ? x : fa[x] = find(fa[x]); }
int main()
{
n = read(), m = read();
for(int i = 1; i <= 2 * n; ++i) fa[i] = i;
for(int i = 1; i <= m; ++i){
e[i].from = read(), e[i].to = read(), e[i].w = read();
}
sort(e + 1, e + m + 1);
for(int i = m; i >= 0; --i){
int uf = find(e[i].from), vf = find(e[i].to);
if(uf == vf) {
printf("%d", e[i].w);
break;
}
else{
fa[find(e[i].from)] = find(e[i].to + n);
fa[find(e[i].to)] = find(e[i].from + n);
}
}
return 0;
}
食物链 Code:
/*
Work by: Suzt_ilymics
Problem: 不知名屑题
Knowledge: 垃圾算法
Time: O(能过)
这个是我的 AC 代码,大部分都有注释,在这里写一下思路
因为动物园有三类 A,B,C, 其中 A 吃 B,B 吃 C, C 吃 A
所以我们开 3n 个点,前 n 个点代表 A 类,中间 n 个点代表 B 类,后 C 个点代表 C 类
注意我们并不需要知道每个动物是哪一类,我们只需要知道他们之间的关系即可。
如果给的信息是 1 x y,
那么直接在对应 A,B,C 类中,将对应的这两个点用并查集合并起来即可。
如果给的信息是 2 x y,表示 x 吃 y
那么我们把 A 中的 x 和 B 中的 y 合并,表示 x 吃 y。同时把 B 中的 x 和 C 中的 y,C 中的 x 和 A 中的 y 合并
也就是说它这个互相吃的关系就是靠这三类点来决定的, A 类点和 B 类点联通,说明对应的 A 吃 B,这样就巧妙的维护了这么一个吃与被吃关系。
*/
#include<iostream>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl
using namespace std;
const int MAXN = 1e6+5;
int n, k, Ans = 0;
int fa[MAXN];
int read(){ // 正常的快读
int s = 0, f = 0;
char ch = getchar();
while(!isdigit(ch)) f |= (ch == '-'), ch = getchar();
while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
return f ? -s : s;
}
int Find(int x) { return fa[x] == x ? x : fa[x] = Find(fa[x]); } // 正常的并查集
int main()
{
n = read(), k = read();
for(int i = 1; i <= 3 * n; ++i) fa[i] = i; // 正常的并查集初始化
for(int i = 1, opt, x, y; i <= k; ++i) {
opt = read(), x = read(), y = read();
if(x > n || y > n) { Ans++; continue; } // 编号不合法的情况
if(opt == 1) {
if(Find(x + n) == Find(y) || Find(x) == Find(y + n)) Ans++; // 判断一下 x 和 y 有没有 吃 或者 被吃 的关系
else {
fa[Find(x)] = Find(y); // 对应类的点合并
fa[Find(x + n)] = Find(y + n);
fa[Find(x + 2 * n)] = Find(y + 2 * n);
}
} else {
if(Find(x) == Find(y) || Find(x + n) == Find(y)) Ans++; // 判断一下 x 和 y 有没有是同族的关系或者 y 吃 x 的关系
else {
fa[Find(x)] = Find(y + n); // A 中的 x 和 B 中的 y 合并
fa[Find(x + n)] = Find(y + 2 * n); // B 中的 x 和 C 中的 y 合并
fa[Find(x + 2 * n)] = Find(y); // C 中的 x 和 A 中的 y 合并
}
}
}
printf("%d", Ans);
return 0;
}