- 并查集
- 1.基础知识
- 1.1 边带权并查集
- 1.2 扩展域并查集
- 1.3 并查集判断奇环
- 2. 例题
- 2.1 边带权并查集
- 2.2 拓展域并查集
- 2.3 路径压缩思想运用--从并查集思想出发
- 1.基础知识
并查集
1.基础知识
1.1 边带权并查集
适用条件: 这样的方法适用于维护存在距离关系的情况,比如说统计相互间的大小关系,距离关系
具体方法: 设置一个d数组,d[u]表示u到fa[u]的距离(或者表示u和fa[u]的属性是否相同)
在两个地方需要维护:
- 在做get操作时维护a到pa之间的所有点的d值,这样求得的d值是这些点到pa的距离
// 查询+路径压缩+更新边权
int get(int x)
{
if (x == fa[x]) return x;
int root = get(fa[x]); // 找到根,并不断更新d数组
d[x] += d[fa[x]];//这是维护距离的情况,如果维护属性那就d[x] ^= d[fa[x]]
return fa[x] = root;
}
- 在合并的时候需要维护d[px] ( 或d[dy] ),这样求得的是px到root的距离
int px = get(x), py = get(y);
fa[px] = py;
d[px] = sz[y] // 这里的公式需要推导,一般都是d'[x] - d[y] = t => (d[x] + d[px]) - d[y] = t, 然后推出d[px]的式子(x的父节点是px, px的父节点是root)
1.2 扩展域并查集
适用条件: 这样的方法适用于维护一个点只有少数几个属性的情况。同时使用扩展域并查集而不是2-sat的情况是,当前的一个条件可以推出它的4个命题(原命题、逆否命题、逆命题、否命题)。如果只能推出它的2个命题(原命题、逆否命题),那么使用2-sat。异或能够导出4个命题,与/或能导出2个命题。
具体方法: 扩展域就是把原来的点u替换为所有他的属性点,比如说u有3个属性a、b、c,那么就把u点替换为3个点ua,ub,uc。然后现在有i和j两个点,如果说i和j是相同的,那么说明 ia == ja, ib == jb ,ic == jc,即把ia和ja连一条边,ib和jb连一条边,ic和jc连一条边;如果说i吃j,那么把ia和jb连一条边,ib和jc连一条边,ic和ja连一条边。这里的连边表示等价关系。每次操作时,需要先检查所连的边是否是正确的,如果连边出现错误(比如是i和j是相同的,然而ia和jb相连,而相同隐含ia和ja相同,那么ja和jb相同,很明显,j的两个属性点不能相同;同时,检查的时候只需要检查u的一个属性点即可,因为u的所有属性点地位都是等价的),说明本次操作是错误的。强调:这里的连边都为merge操作,即把fa[py]=py,每次检查的本质是判断同一个物体的两个不同属性点是否相同,相同就是错误操作。
1.3 并查集判断奇环
这个方法是建立在:边带权并查集+并查集判断环的基础之上的,具体方法是维护一个d数组(和边带权并查集一样),然后按照并查集判断环的方法,如果px == py, 那么判断d[x] ^ d[y] == 1, 如果成立,那么出现奇环。
2. 例题
2.1 边带权并查集
acwing238. 银河英雄传说
两个操作:
1、M i j,表示让第i号战舰所在列的全部战舰保持原有顺序,接在第j号战舰所在列的尾部。
2、C i j,表示询问第i号战舰与第j号战舰当前是否处于同一列中,如果在同一列中,它们之间间隔了多少艘战舰。
战舰数目N≤30000,指令数目T≤500000
/*
本题要求去距离,因此可以考虑并查集的边带权处理
设d[x]为x到根fa[x]的距离
则每次把集合x和集合y合并时,需要知道y集合的大小,因此需要维护一个size[y]来表示y集合的大小
每次合并时,更新d[x]为size[y]
然后压缩路径
*/
#include
using namespace std;
int t, n;
char str[3];
int const N = 3e4 + 10;
int fa[N], size[N];
int d[N]; // d[i]表示i到fa[i]的距离
// 查询+路径压缩+更新边权
int get(int x)
{
if (x == fa[x]) return x;
int root = get(fa[x]); // 找到根,并不断更新d数组
d[x] += d[fa[x]];
return fa[x] = root;
}
int main()
{
cin >> t;
// 初始化
for (int i = 1; i <= 30000; ++i) fa[i] = i, size[i] = 1;
while (t--)
{
int a, b;
scanf("%s%d%d", str, &a, &b);
// 合并操作
if (str[0] == 'M')
{
a = get(a), b = get(b);
d[a] = size[b]; // 计算a到b的距离
size[b] += size[a]; // 更新b的大小
fa[a] = b; // 更新a的父节点
}
else // 询问操作
{
if (get(a) != get(b)) printf("-1\n");
else printf("%d\n", max(0, abs(d[a] - d[b]) - 1));
}
}
return 0;
}
acwing239奇偶游戏
小A和小B在玩一个游戏。
首先,小A写了一个由0和1组成的序列S,长度为N。
然后,小B向小A提出了M个问题。
在每个问题中,小B指定两个数 l 和 r,小A回答 S[l~r] 中有奇数个1还是偶数个1。
机智的小B发现小A有可能在撒谎。
例如,小A曾经回答过 S[1~3] 中有奇数个1, S[4~6] 中有偶数个1,现在又回答 S[1~6] 中有偶数个1,显然这是自相矛盾的。
请你帮助小B检查这M个答案,并指出在至少多少个回答之后可以确定小A一定在撒谎。
即求出一个最小的k,使得01序列S满足第 1~k个回答,但不满足第 1~k+1个回答。
//边带权
/*
本题告诉l~r之间的奇数个数,其实是告诉了S[l-1]与S[r]的奇偶关系(S[i]为1~i之间的1的个数)
那么本题就只有2种状态,即0和1;
可以使用边带权的并查集,维护一个d[x]表示x与fa[x]的奇偶关系
如果d[x]为0,那么x与fa[x]的奇偶性相同;否则不同
*/
#include
using namespace std;
int const N = 2e4 + 10;
int cnt = 1;
unordered_map S;
int n, m;
int fa[N], d[N];
// 离散化
int mapping(int x)
{
if (!S.count(x)) S[x] = cnt++;
return S[x];
}
int get(int x)
{
if (x == fa[x]) return x;
int root = get(fa[x]);
d[x] ^= d[fa[x]];
return fa[x] = root;
}
int main()
{
cin >> n >> m;
for (int i = 1 ; i <= 2 * m; ++i) fa[i] = i;
int res = m;
for (int i = 1; i <= m; ++i)
{
int a, b;
string type;
cin >> a >> b >> type;
int t = 0;
if (type == "odd") t = 1;
a = mapping(a - 1), b = mapping(b); // 离散化
int pa = get(a), pb = get(b); // 找父节点
if (pa != pb) // 父节点不同,更新距离
{
fa[pa] = pb;
d[pa] = d[a] ^ d[b] ^ t;
}
else // 父节点相同,判断是否奇偶性相同
{
if (d[a] ^ d[b] != t)
{
res = i - 1;
break;
}
}
}
cout << res << endl;
return 0;
}
acwing240食物链
A吃B,B吃C,C吃A,一共有N个动物,给出K个关系,问其中有多少关系是假的。
若D=1,则表示X和Y是同类。
若D=2,则表示X吃Y。
1≤N≤50000,
0≤K≤100000
/* 设置一个共同的起点,d[x]表示x到起点的距离,距离只有%3后才有意义
那么X吃Y就是(d[x] - d[y]) % 3 == 1, X和Y同类就是(d[x] - d[y]) % 3 == 0,
d的更新关系为:
1.在做get操作时:d[x] += d[fa[x]];
2.在合并时:d[px]= ((t + d[y] - d[x]) % 3 + 3) % 3*/
#include
using namespace std;
int const N = 5e4 + 10;
int n, k, fa[N], d[N];
int get(int x) {
if (x == fa[x]) return x;
int root = get(fa[x]);
d[x] += d[fa[x]];
return fa[x] = root;
}
int main() {
cin >> n >> k;
int res = 0;
for (int i = 1; i <= n; ++i) fa[i] = i;
while (k--) {
int D, x, y, t = 0;
scanf("%d %d %d", &D, &x, &y);
if (x > n || y > n || (D == 2 && x == y)) {
res ++;
continue;
}
if (D == 2) t = 1;
int px = get(x), py = get(y);
if (px == py) {
if (((d[x] - d[y]) % 3 + 3) % 3 != t) res++;
}
else {
fa[px] = py;
d[px]= ((t + d[y] - d[x]) % 3 + 3) % 3;
}
}
cout << res << endl;
return 0;
}
acwing257关押罪犯
现在有n个囚犯,有m对关系,每对囚犯间有c的仇恨值。如果把两个有k仇恨值的囚犯关押在一起,那么就会付出k的代价。现在希望最大的代价最小,问这个代价是多少。
// 二分答案+并查集判断奇环
// 说下并查集判断奇环的思路:如果两个罪犯的仇恨值大于limit,那么两个罪犯之间连一条边
// 然后d[x]维护x到fa[x]的距离的奇偶性,奇数为1,偶数为0
#include
using namespace std;
int const N = 2e4 + 10, M = 1e5 + 10;
int n, m, fa[N], d[N];
struct Man {
int u, v, c;
}man[M];
int get(int x) {
if (x == fa[x]) return x;
int root = get(fa[x]);
d[x] ^= d[fa[x]];
return fa[x] = root;
}
bool merge(int u, int v) {
int pu = get(u), pv = get(v);
if (pu == pv && !(d[u] ^ d[v])) return 0; // 出现奇环
// 否则,连边
fa[pu] = pv;
d[pu] = 1 ^ d[v] ^ d[u];
return 1;
}
// 判断是否出现奇环
bool check(int limit) {
for (int i = 1; i <= m; ++i)
if (man[i].c > limit)
if (!merge(man[i].v, man[i].u)) return 0;
return 1;
}
int main () {
cin >> n >> m;
int l = 0, r = 1e9;
for (int i = 1; i <= m; ++i) cin >> man[i].u >> man[i].v >> man[i].c;
while (l < r) {
int mid = (l + r) >> 1;
for (int i = 1; i <= n; ++i) fa[i] = i, d[i] = 0;
if (check(mid)) r = mid; // 是二分图,说明设置太大
else l = mid + 1;
}
cout << l;
return 0;
}
2.2 拓展域并查集
acwing240食物链
// 一个点分为几个属性点,每次检查x的不同属性点是否连边,每次合并就把i和j的对应属性点连边
#include
using namespace std;
int const N = 5e4 + 10;
int fa[N * 3], n, k;
int get(int x) {
if (fa[x] == x) return x;
return fa[x] = get(fa[x]);
}
// 检查
bool check(int x, int y) {
int px = get(x), py = get(y);
return px == py;
}
// 合并
void merge(int x, int y) {
int px = get(x), py = get(y);
fa[px] = py;
}
int main() {
cin >> n >> k;
for (int i = 1; i <= n * 3; ++i) fa[i] = i; // 初始化要3个属性点,因此为3*n
int res = 0;
while (k--) {
int D, x, y, t;
scanf("%d %d %d", &D, &x, &y);
if (x > n || y > n || (D == 2 && x == y)) { // 第一种情况特判
res++;
continue;
}
if (D == 1) { // 同类
if (check(x, y + n) || check(x, y + 2 * n)) res++; // 检查是否x1和y2,y3连边
else merge(x, y), merge(x + n, y + n) , merge(x + 2 * n, y + 2 * n); // 连边
}
else { // x吃y
if (check(x, y) || check(x, y + 2 * n)) res++; // 检查x1是否和y1,y3连边
else merge(x, y + n), merge(x + n, y + 2 * n), merge(x + 2 * n, y); // 连边
}
}
printf("%d", res);
return 0;
}
acwing239奇偶游戏
// 这种属性点较少的问题,使用扩展域就会特别简单
#include
using namespace std;
int const N = 4e4 + 10;
int fa[N], n, k, cnt;
unordered_map mp;
int mapping(int x) {
if (!mp.count(x)) return mp[x] = ++cnt;
else return mp[x];
}
int get(int x) {
if (x == fa[x]) return x;
return fa[x] = get(fa[x]);
}
bool check(int x, int y) {
int px = get(x), py = get(y);
return px == py;
}
void merge(int x, int y) {
int px = get(x), py = get(y);
fa[px] = py;
}
int main () {
cin >> n >> k;
for (int i = 1; i <= k * 4; ++i) fa[i] = i;
int res = k;
for (int i = 1; i <= k; ++i) {
int a, b;
string op;
cin >> a >> b >> op;
a = mapping(a - 1), b = mapping(b);
if (op == "even") {
if (check(a, b + 2 * k)) {
res = i - 1;
break;
}
else merge(a, b), merge(a + 2 * k, b + 2 * k);
}
else {
if (check(a, b)) {
res = i - 1;
break;
}
else merge(a, b + 2 * k), merge(b, a + 2 * k);
}
}
cout << res;
return 0;
}
acwing257关押罪犯
// 每个囚犯有两个属性,属性1:住在1号房,属性2:住在2号房。因此,可以贪心的思想
// 把囚犯的关系按照仇恨值从大到小排序,先判断仇恨值大的。如果这两个囚犯可以分到2个牢房,那就分到两个牢房
// 即:即囚犯1分到1号房和囚犯2分到2号房连一条边,囚犯1分到2号房和囚犯2分到1号房。
// 判断不合理,那就看是否囚犯1和囚犯2分到同一间房
#include
using namespace std;
typedef pair PII;
int const N = 4e4 + 10, M = 1e5 + 10;
int n, m, fa[N];
struct Con {
int u, v, c;
}C[M];
bool cmp(struct Con x, struct Con y) {
return x.c > y.c;
}
int get(int x) {
if (x == fa[x]) return x;
return fa[x] = get(fa[x]);
}
bool check(int u, int v) {
int pu1 = get(u), pv2 = get(v + n); // 囚犯1分到1号房,囚犯2分到2号房
fa[pu1] = pv2;
int pu2 = get(u + n), pv1 = get(v); // 囚犯1分到2号房,囚犯2分到1号房
fa[pu2] = pv1;
if (pu1 == pv1 || pu2 == pv2) return 0; // 判断是否会分到同一间房
return 1;
}
int main() {
cin >> n >> m;
for (int i = 1; i <= 2 * n ; ++i) fa[i] = i;
for (int i = 1; i <= m; ++i) cin >> C[i].u >> C[i].v >> C[i].c; // 排序
sort(C + 1, C + 1 + m, cmp);
int res = 0;
for (int i = 1; i <= m; ++i) {
if (!check(C[i].u, C[i].v)) { // 检查是否合理
res = C[i].c;
break;
}
}
cout << res;
return 0;
}
acwing403平面
若能将无向图 G=(V,E)画在平面上使得任意两条无重合顶点的边不相交,则称 G 是平面图。现在假设图中存在一个包含所有顶点的环,即存在哈密顿回路。请你判定它们是否是平面图。
/*
本题要求在一个哈密尔顿回路上判断平面图
由于是哈密尔顿回路,一旦发现两条边交叉,那么必然一条边在内侧,一条边在外侧
那么记内侧为0,外侧为1,则i^j=1,则可以使用扩展域并查集处理
m>3n*-6保证了m只能是n的量级,因此去任意两条边,判断这两条边是否为交叉,一旦交叉使用并查集处理
时间复杂度为O(T*m*m)
*/
#include
using namespace std;
typedef pair PII;
int const N = 200 + 10, M = 1e4 + 10;
int fa[M * 2], n, m, t, id[M * 2];
PII e[M]; // first为a, second为b
int get(int x) {
if (x == fa[x]) return x;
return fa[x] = get(fa[x]);
}
// 判断是否交叉
bool isx(int x, int y) {
int a = id[e[x].first], b = id[e[x].second]; // 转换成顺序标识
int c = id[e[y].first], d = id[e[y].second];
if (a > b) swap(a, b);
if (c > d) swap(c, d);
if (a == c || a == d || b == c || b == d) return 0;
return ((a < c && c < b) ^ (a < d && d < b));
}
void merge(int x, int y) {
int px = get(x), py = get(y);
if (px != py) fa[px] = py;
}
// 判断是否成立
bool slove() {
for (int i = 1; i <= m * 2; ++i) fa[i] = i;
for (int i = 1; i <= m; ++i) {
for (int j = i + 1; j <= m; ++j) {
if (isx(i, j)) { // 判断i和j是否可能交叉
merge(i, j + m), merge(i + m, j); // 交叉的话一个在里,一个在外
if (get(i) == get(i + m)) return 0; // 一个点的两个属性不能一样
}
}
}
return 1;
}
int main() {
cin >> t;
while (t--) {
cin >> n >> m;
for (int i = 1; i <= m; ++i) cin >> e[i].first >> e[i].second; //记录一条边的两个点
for (int i = 1, t; i <= n; ++i) { // 记录每个点
cin >> t;
id[t] = i;
}
if (m > 3 * n - 6 || !slove()) cout << "NO\n"; // m > 3 * n - 6限制了m的量级为n
else cout << "YES\n";
}
return 0;
}
2.3 路径压缩思想运用--从并查集思想出发
acwing145 超市
超市里有N件商品,每件商品都有利润pi和过期时间di,每天只能卖一件商品,过期商品不能再卖。
求合理安排每天卖的商品的情况下,可以得到的最大收益是多少。
0≤N≤10000,1≤pi,di≤10000
// 把商品按利润大小排序,优先考虑利润大的。当对于第i大利润的商品,如果它的过期时间为d,
// 那么它可以出售的时间为前1~d天,即去1~d天中离d最近的、没有出售货物的一天来出售
// 那么需要动态维护每一天d'的前缀1~d'中没有出售过货物的、最近的一天,我们记这天为t
// 而t具有前向传递性,即如果d的t用来出售货物了,那么d的新t为t-1的t。同理,如果t-1的t被出售
// 那么还可以继续向前传递,那么所有可以传递的t就可以变成一条链,这些链可以加一条边来连起来,记fa[d] = t
// 这样当d的t被出售时,我们借鉴线段树懒标记的思想,不去直接把t+1~d-1这些点的t重新标记,而只在t处重新打标记
// 然后每次我求d的t时,不断先前找,找到真正的t = root, 然后修改fa[d] = root.为了加快时间,我们可以把沿途所有的点的父节点都记为root
// 那就变成路径压缩,转而变成并查集的思想
#include
using namespace std;
int const N = 1e4 + 10;
typedef pair PII;
PII a[N];
int n, fa[N];
int get(int x) {
if (fa[x] == x) return x;
return fa[x] = get(fa[x]);
}
int main() {
while (scanf("%d", &n) != EOF) {
for (int i = 1; i <= n; ++i) scanf("%d %d", &a[i].first, &a[i].second);
// 按照利润排序,大的在前
sort(a + 1, a + n + 1);
reverse(a + 1, a + n + 1);
for (int i = 0; i <= N; ++i) fa[i] = i; // 这里要初始化0,为了让不合法的都压缩到0处
int res = 0;
for (int i = 1; i <= n; ++i) {
int x = a[i].second; // 每次看能不能插入1~x天范围内
int px = get(x); // 找到root
if (px >= 1) res += a[i].first, fa[px] = fa[px - 1]; // 如果root>=1,就路径压缩
}
cout << res << endl;
}
return 0;
}