并查集

并查集是一种可以动态维护若干个不重叠的集合,并支持合并与查询的数据结构。

例:P1551 亲戚

题目描述: 如果 \(x\)\(y\) 是亲戚,\(y\)\(z\) 是亲戚,那么 \(x\)\(z\) 也是亲戚。如果 \(x\)\(y\) 是亲戚,那么 \(x\) 的亲戚都是 \(y\) 的亲戚,\(y\) 的亲戚也都是 \(x\) 的亲戚。现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
输入格式: 输入 \(3\) 个整数 \(n,m,p \ (n,m,p \le 5000)\),分别表示有 \(n\) 个人,\(m\) 对亲戚关系,\(p\) 对亲戚关系的询问。接下来 \(m\) 行,每行两个数说明这两个人是亲戚。接下来 \(p\) 行,每行询问两个人是否是亲戚。
输出格式: 对于每次查询,需要输出 Yes 或者 No 表示这次查询是否是亲戚关系。

分析: 将所有有亲戚关系的人归为同一个集合中(同一个家族)。如果想查询两个人是否有亲属关系,只需要判断这两个人是否为同一个家族内。

image

那么,怎么判断两个人是否在同一个家族内呢?可以从每个家族中选出一位“族长”来代表整个家族,这样只需要知道两个人的族长是否为同一人,就能判断出是否属于同一个家族。

规定所有的成员都有一名“负责人”,最开始的时候,所有人都“自成一族”,每个成员都有一个指向自己的箭头,意思是自己的“负责人”就是自己,这时本人就是族长。

image

假如得知 \(1\)\(2\) 是亲戚关系,那么就需要将 \(1\)\(2\) 合并为同一个家族,将 \(1\) 的“负责人”改成 \(2\) 即可(反过来也可以)。于是,关系就变为:

image

由于 \(1\) 号的负责人变成了 \(2\),族长换成了 \(2\),所以家族 \(1\) 不复存在。这时,\(1\) 号和 \(2\) 号在同一个家族中,他们的族长都是 \(2\) 号。

假如得知 \(1\)\(5\) 也是亲戚关系。直接将 \(1\) 号的“负责人”改成 \(5\) 是不行的(不然和 \(2\) 号建立起的关系就丢失了),不过可以把 \(1\) 号的族长(也就是 \(2\) 号)的负责人变成 \(5\) 号。这样 \(1,2,5\) 三个人都成为亲戚关系了,他们的族长是 \(5\) 号。

image

接下来假如得知 \(3\)\(4\) 是亲戚关系,只需要将 \(3\) 的负责人变成 \(4\),都归为家族 \(4\)。假如 \(2\)\(5\) 是亲戚关系,由于发现 \(2\)\(5\) 的族长都是 \(5\),已经是同一个家族了,所以可以忽略掉。

假如 \(1\)\(3\) 是亲戚关系,将 \(1\) 的族长(\(5\) 号)的负责人变成 \(3\) 的族长(\(4\) 号),至此一共就只剩下两个家族了。需要注意的是,查询某位成员的族长要沿着负责人关系一层一层往上遍历,直到发现自己的负责人就是自己,这位成员就是族长。

image

如果需要查询两个人是否是同一个家族的,只需要查询这两个人的族长是否是同一个人。如果要把两个家族合并,就把其中一个家族的族长的负责人指向另外一个家族的族长即可。

这种处理不相交可合并集合关系的数据结构叫做并查集。并查集具有查询、合并这两种基本操作。使用并查集时要先初始化并查集,然后处理这 \(m\) 对亲戚关系,如果两个人是亲戚,那么就在并查集上所对应的集合合并起来,然后对于每个询问,只需要把这两个人的集合的代表元素找出来,判断是否相等即可——如果相等,那么这两个人在同一个集合中,即为亲戚,否则不是。

如果构造测试数据,在合并时将“族谱树”变成长长的一条链,这样每次查询就必须从底部开始一层一层地往上遍历所有的节点才能查询到族长是谁,效率很低。针对这个问题,可以进行路径压缩优化。可以发现一棵树上每个节点离根越近越好,因此可以在查询一个节点的族长的同时将所有访问到的节点的负责人都设为族长,这样下次从同一个节点找族长的时候经过的节点数就会大大减少。

仅使用路径压缩会使查询的时间复杂度降低至 \(O(\log n)\)。除此之外,还可以进行按秩合并优化(树的深度小的一方的族长指向深度大的一方),两个优化叠加下查询复杂度会降低至 \(O(\alpha (n))\),其中 \(\alpha(n)\) 是阿克曼函数的反函数,可以近似认为是常数级(但和常数级不同阶)。

参考代码
#include <cstdio>
const int N = 5005;
int fa[N];
int query(int x) { // 查询x所在的集合
    return fa[x] == x ? x : fa[x] = query(fa[x]); // 路径压缩
}
int main()
{
    int n, m, p; scanf("%d%d%d", &n, &m, &p);
    for (int i = 1; i <= n; i++) fa[i] = i; // 初始化
    while (m--) {
        int i, j; scanf("%d%d", &i, &j);
        int qi = query(i), qj = query(j);
        if (qi != qj) fa[qi] = qj; // 合并两个集合
    }
    while (p--) {
        int i, j; scanf("%d%d", &i, &j);
        printf("%s\n", query(i) == query(j) ? "Yes" : "No");
    }
    return 0;
}

例:P1536 村村通

现有村镇道路统计表,表中列出了每条道路直接连通的村镇(村镇从 \(1\)\(N\) 编号,\(N \le 1000\))。如果要求任何两个村镇间都可以实现交通(不一定是直接的道路相连,只要相互之间可达即可),最少还需要建设多少条道路?

分析:先处理每条存在的边,即把每条存在的边所连接的两个节点用并查集合并起来,在这个过程中可以维护当前还有多少个集合,即有多少个连通块。接下来每添加一条边可以把两个连通块合并成一个,即将连通块个数减一,要实现任何两个村镇间都可以实现交通,即连通块只有一个,答案就是初始的连通块个数减一,输出即可。

参考代码
#include <cstdio>
const int N = 1005;
int fa[N];
int query(int x) {
    return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
    int n, m;
    while (scanf("%d", &n) && n != 0) {
        scanf("%d", &m);
        for (int i = 1; i <= n; i++) fa[i] = i;
        int ans = n;
        while (m--) {
            int x, y; scanf("%d%d", &x, &y);
            int qx = query(x), qy = query(y);
            if (qx != qy) {
                fa[qx] = qy; ans--;
            }
        }
        printf("%d\n", ans - 1);
    }
    return 0;
}

习:P1197 [JSOI2008] 星球大战

解题思路

按照常规的思维,这里需要在并查集上实现删点操作,这是比较麻烦的。这里我们可以采用逆向思维,将逐渐摧毁的过程倒过来考虑,变成逐渐重建的过程。

先将所有要摧毁的点摧毁,并将并查集维护好,记录此时的连通块个数,这就是整个摧毁过程完成后的答案。

从最后一次被摧毁的点开始“时光倒流”,让被摧毁的点一个个加回并查集中,在这个过程中记录连通块的个数。

参考代码
#include <cstdio>
#include <vector>
using namespace std;
const int N = 400005;
vector<int> graph[N];
int ans[N], target[N], fa[N];
bool destroy[N]; // 用于标记某点是否被摧毁
int query(int x) {
    return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 0; i < n; i++) fa[i] = i; // 并查集初始化
    for (int i = 1; i <= m; i++) {
        int x, y; scanf("%d%d", &x, &y);
        graph[x].push_back(y); graph[y].push_back(x); // 建图
    }
    int k; scanf("%d", &k);
    for (int i = 1; i <= k; i++) {
        scanf("%d", &target[i]);
        destroy[target[i]] = true; // 标记这个点已经被摧毁
    }
    int cnt = n - k;
    for (int i = 0; i < n; i++)
        if (!destroy[i]) {
            for (int to : graph[i]) 
                if (!destroy[to]) { // i和to都没被摧毁
                    int qi = query(i), qt = query(to);
                    if (qi != qt) {
                        fa[qi] = qt; cnt--; // 合并后连通块的数量减1
                    }
                }
        }
    ans[k + 1] = cnt; // 整个摧毁过程完成后剩下的连通块个数
    for (int i = k; i >= 1; i--) { // “时光倒流”
        int cur = target[i];
        destroy[cur] = false; cnt++; // 修复该点
        for (int to : graph[cur]) 
            if (!destroy[to]) { // 修复i<->to这条边
                int qc = query(cur), qt = query(to);
                if (qc != qt) {
                    fa[qc] = qt; cnt--; // 合并
                }
            }
        ans[i] = cnt; // 修复当前点后,连通块的个数
    }
    for (int i = 1; i <= k + 1; i++) printf("%d\n", ans[i]);
    return 0;
}

扩展域并查集

在普通的并查集中,通常只有一个维度(元素本身)。而在扩展域并查集中,会对元素进行扩展,让它拥有多个维度(通常是某些性质)。将原本的一个点拆成多个点,分别表示与该点满足某种关系的点构成的集合。

例题:P2024 [NOI2001] 食物链

对于动物 \(x\)\(y\),可能有 \(x\)\(y\)\(x\)\(y\) 是同类,\(x\)\(y\) 吃三种关系。因此考虑扩展到三倍空间,分别表示三类不同物种的域。

设有 \(n\) 只动物,则并查集的空间为 \(3n\),其中 \([1,n]\) 部分为 \(A\) 类物种域,\([n+1,2n]\) 部分为 \(B\) 类物种域,\([2n+1, 3n]\) 部分为 \(C\) 类物种域。

\(A\) 中的 \(x\)\(B\) 中的 \(y\) 合并时,相当于关系 \(x\)\(y\);当 \(C\) 中的 \(x\)\(C\) 中的 \(y\) 合并时,相当于关系 \(x\)\(y\) 是同类······。

由于不知道某个动物具体属于 \(A,B\) 还是 \(C\),所以三种情况都要考虑。

也就是说,每当有一句真话时,需要做三次合并。

#include <cstdio>
const int N = 50005;
int fa[N * 3];
int query(int x) {
    return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
    int n, k; scanf("%d%d", &n, &k);
    for (int i = 1; i <= n * 3; i++) fa[i] = i;
    int ans = 0;
    while (k--) {
        int op, x, y; scanf("%d%d%d", &op, &x, &y);
        if (x > n || y > n || op == 2 && x == y) {
            ans++; continue;
        }
        if (op == 1) {
            if (query(x) == query(y + n) || query(y) == query(x + n)) {
                ans++; continue;
            } 
            fa[query(x)] = query(y);
            fa[query(x + n)] = query(y + n);
            fa[query(x + n * 2)] = query(y + 2 * n);
        } else {
            if (query(x) == query(y) || query(y) == query(x + n)) {
                ans++; continue;
            }
            fa[query(x)] = query(y + n);
            fa[query(x + n)] = query(y + n * 2);
            fa[query(x + n * 2)] = query(y);
        }
    }
    printf("%d\n", ans);
    return 0;
}

习题:P5937 [CEOI1999] Parity Game

题解

分析:首先可以想到前缀和,可以把 \(l\)\(r\) 的区间和看成前缀和之差 \(sum[r]-sum[l-1]\),而这个式子的结果要么是奇数要么是偶数,当其为偶数时,说明 \(sum[r]\)\(sum[l-1]\) 的奇偶性需要相同,当其为奇数时,说明 \(sum[r]\)\(sum[l-1]\) 的奇偶性需要不同。

注意到 \(2m\) 远小于 \(n\),需要对最多 \(2m\) 个位置做离散化处理。把每一个位置扩展成两个域:奇数域和偶数域。不妨令 \([1,2m]\) 表示奇数域,\([2m+1,4m]\) 表示偶数域,而 \(i\)\(i+2m\) 是同一个位置分别对应到奇数域和偶数域上。则两个位置的前缀和奇偶性相同可以被表达为奇数域和奇数域合并、偶数域和偶数域合并;奇偶性不同可以被表达为奇数域和对方的偶数域合并、偶数域和对方的奇数域合并。

#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 5005;
int a[N], b[N], num[N * 2], n, m, fa[N * 4];
bool eq[N];
char q[10];
int getid(int x) {
    return lower_bound(num + 1, num + 2 * m + 1, x) - num;
}
int query(int x) {
    return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        scanf("%d%d%s", &a[i], &b[i], q);
        a[i]--; num[i] = a[i]; num[i + m] = b[i];
        eq[i] = q[0] == 'e';
    }
    sort(num + 1, num + 2 * m + 1);
    for (int i = 1; i <= 4 * m; i++) fa[i] = i;
    int ans = 0;
    for (int i = 1; i <= m; i++) {
        int x = getid(a[i]), y = getid(b[i]); // 离散化后对应的值
        if (!eq[i]) { // 奇偶性不等
            // 如果之前出现过奇偶性相同的情况,说明矛盾
            if (query(x) == query(y)) break;
            fa[query(x)] = query(y + 2 * m);
            fa[query(y)] = query(x + 2 * m);
        } else { // 奇偶性相等
            // 如果之前出现过奇偶性相反的情况,说明矛盾
            if (query(x) == query(y + 2 * m) || query(y) == query(x + 2 * m)) break;
            fa[query(x)] = query(y);
            fa[query(x + 2 * m)] = query(y + 2 * m);
        }
        ans++;
    }
    printf("%d\n", ans);
    return 0;
}
posted @ 2024-03-16 11:20  RonChen  阅读(52)  评论(0编辑  收藏  举报