并查集

并查集是一种动态维护集合关系的集合,一般包括查询集合和合并集合两个操作。

是一种维护集合关系的数据结构。在适当优化下可以达到常数级别的时间复杂度。

中心思想

中心思想为:换老大(即祖宗、父亲)。

我们设第 \(i\) 个集合的老大为 \(p_i\)

在开始时,每个集合的老大就是它本身,而合并集合就是把一个集合的老大换成另一个集合的老大,即 \(p_i = p_j\),而判断两个集合是否联通,就是判断他们的老大是不是一个。而整个联通块老大的老大就是它本身。这就是并查集的中心思想。

可写出查询代码

int find(int x)
{
	if (x != p[x]) return find(p[x]); // 如果x集合不是老大,则继续查找
	return x; // 如果 x 是老大,则直接输出老大
}

这是基本的代码,但并不常用,没有优化的并查集是 \(\operatorname O(n)\) 的时间复杂度。

具体如下图

![Pasted image 20250730193024.png]]

对于合并集合,设两个集合 \(a,b\) 的老大(树根)为 \(pa,pb\),如果 \(pa = pb\) 说明两个集合互相联通,不需要合并;如果 \(pa \not = pb\),则说明两个集合不联通,可以进行合并,合并两个集合就是使 \(p_{pb} = pa\)

代码为:

int pa = find(a), pb = find(b);
if (pa != pb)
{
	 // if (sizes[pa] < sizes[pb]) swap(pb, pa);
	p[pb] = pa;
	 // sizes[pa] += sizes[pb];
}

需要注意一下,我们一般不会每次使用 \(pa/pb\) 时都调用 \(find()\),因为在维护值时,可能会错误。为了保证正确,一般存下 \(pa,pb\)

优化

分为两种优化:路径压缩和按秩合并

路径压缩

路径压缩是最常用的,也是优化最明显的方法。原代码 \(\operatorname O(n)\) 时间复杂度的原因在于每次查询都必须一级一级地向上查询。如果每一级都知道自己的最高级,那么就可以优化查询次数。

而路径压缩可以在查询结束后(即找到老大后),把原本的小弟的上级老大全部换为最大的老大。从而降低之后向上查询的次数。优化后均摊时间复杂度为 \(\operatorname O(\log n)\)(实际上远小于此)

最常见的代码为:

int find(int x)
{
	if (x != p[x]) p[x] = find(p[x]); // 在向上查询之后更改老大
	return p[x]; // 因为上面已经更新了(如果没更新,那么这就是最大的老大)老大为
				 // 最大的老大,所以返回值即为最大的老大
}

干净一点

int find(int x)
{
	if (x != p[x]) p[x] = find(p[x]);
	return p[x];
}

这是最常用的代码,也是最常用的优化方法。

值得一提的是,这种优化可以被人为卡成 \(\operatorname O(n)\)

如何卡掉路径压缩

按秩合并

即字面意思,按照秩序合并,这个秩序可以说集合的最大深度,也可以是集合点的数量。一般使用集合数量(较为方便统计)作为秩。合并方法为将秩小的连接在秩大的后面。

简单说就是把点数少的集合连接在点数多的集合后面。

按照思路写出算法即可:

int sizes[N];
int p[N];

int pa = find(a), pb = find(b); 
if (pa != pb)
{
	if (sizes[pa] < sizes[pb]) swap(pa, pb); // 秩序
	p[pb] = p[pa];
	sizes[pa] += sizes[pb];
	sizes[pb] = 0;  // 通常需要清零
}

这种方法优化较少,但与路径压缩相结合可以达到常数级别的效率。

代码

常用代码为

//  
int p[x];

int find(int x)
{
	if (x != p[x]) p[x] = find(p[x]);
	return p[x];
}

int main()
{
	for (int i = 1; i <= n; i ++ ) p[i] = i;
	// 使用find查询
}

扩展

  1. 常用语维护联通块关系等,在特殊情况下可以维护数值(带权并查集),和矛盾关系(扩展域并查集)。

  2. 敌人的敌人就是朋友,很多情况可以按照这个模式来合并集合。

进阶应用

带权并查集(边带权并查集)

带权一般是带一个东西,如每个点到根节点的距离之类的。一般代码为:

int d[N]; // 到根节点的距离,可以存别的
int find(int x) // 返回根节点
{
    if (p[x] != x) 
    {
        int root = find(p[x]); 
        d[x] += d[p[x]];  // 因为路径压缩操作完后,p[x]指向root根节点,根据递归
        p[x] = root; //find(p[x])结束后d[p[x]]含的就是p[x]到根的距离,p[x]指向
    }  //root,d[x] 存的应该是x到root的距离,即d[x]+d[p[x]]. x -> p[x] -> root
    return p[x];
}

可能要维护并查集内的点(存下来),这时候把少点连到多点上。可以保证此类操作总和最多 \(\operatorname O(n\log n)\)。可以防卡。如果不加,可能会卡成 \(\operatorname O(n^2)\)。可以叫一个 Trick。(按秩合并)

可证,最坏情况为每次都是 \(pa_{size} = pb_{size}\), 此时可以把序列分为两两一组,合并,再两两一组,合并,重复直到全部合并。从上到下每一层最多加 \(n\) 次,每层连通块数量减半,所以一共 \(\log n\) 层。时间复杂度为 \(\operatorname O(n\log n)\)

扩展域并查集

可以保存关系,用好了或许能用于DP。

详细可以见 [[#扩展域并查集ha|后面]]

为了保证覆盖全面,关系必须紧凑,不能少不能多,即使看起来无用,但实际上他会影响传递。而关于判断,不用枚举各种情况,只需要表达出意思即可。详细可见 食物链

后面

AcWing 239. 奇偶游戏 - AcWing

连续^有可能传递大数
比如这个

#include <iostream>

using namespace std;

int main()
{
    int a = 1, b = 1;
    while (a > 0)
    {
        a *= 2;
        a ^= 1;
        cout << a << endl;
    }
    
    return 0;
}

int溢出奇偶性不变

带权边并查集写法

#include <iostream>
#include <cstring>
#include <algorithm>
#include <unordered_map>

using namespace std;

const int N = 10010;

int p[N], d[N];
int n, m, cnt;
unordered_map<int, int> S;

int get(int x)
{
    if (!S.count(x)) S[x] = ++ cnt;
    return S[x];
}

int find(int x)
{
    if (p[x] != x)
    {
        int root = find(p[x]);
        d[x] ^= d[p[x]]; // 换成加法可能会溢出int变成负数(溢出后奇偶性不变)
        p[x] = root;
    }
    
    return p[x];
} 

int main()
{
    cin >> n >> m;
    
    for (int i = 1; i < N; i ++ ) p[i] = i;
    
    int res = m;
    for (int i = 1; i <= m; i ++ )
    {
        int a, b;
        string op;
        cin >> a >> b >> op;
        a = get(a - 1), b = get(b);
        
        int t = 0;
        if (op == "odd") t = 1;
        
        int pa = find(a), pb = find(b);
        if (pa == pb)
        {
            if (d[a] ^ d[b] != t) // 使用加法需要把它变成正值即 ((d[a] + d[b]) % 2 + 2) % 2  != t ,这是为了防止int溢出成负数
            {
                res = i - 1;
                break;
            }
        }
        else
        {
            p[pa] = pb;
            d[pa] = d[a] ^ d[b] ^ t; // 这里d[]的值全部由t提供(d[]初值为0), t最大是1,
        }                            // 所以在异或下最大是1,所以偶数全是0, 奇数全是1,没有溢出空间的风险
    }
    
    cout << res << endl;
    
    return 0;
}

扩展域并查集ha

总体思想用每个变量作为_条件_, 如a 为 a 是奇数, a + N 为 a 是偶数
或者 b 为 b在 A类, b + N 为 b在 B类 ,b + 2N 为 b在 C类;
然后把情况放入相应的集合, 如果不同集合冲突(相等)则说明是谎话(有矛盾)

同一个集合的含义:如果集合内一个条件成立,则整个集合都成立(即可以互相推出)

1445|625
题解
239. 奇偶游戏 - AcWing题库
食物链

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <unordered_map>

using namespace std;

const int N = 20010, M = N / 2;

int p[N];
int n, m, cnt, res;
unordered_map<int, int> S;

int get(int x)
{
    if (S.count(x) == 0)  S[x] = ++ cnt;
    return S[x];
}

int find(int x)
{
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

int main()
{
    cin >> n >> m;
    for (int i = 1; i < N; i ++ ) p[i] = i;
    
    res = m;
    for (int i = 1; i <= m; i ++ )
    {
        int a, b;
        string op;
        cin >> a >> b >> op;
        a = get(a - 1), b = get(b);
        
        if (op == "even") // 如果偶数 
        {
            if (find(a + M) == find(b)) // 设a + M 为 a 是奇数, a是a 是偶数, b 同理设,则 a + M 一定和b + M
            {                           // 在一个集合 a 与 b 一定在一个集合 相等则有谎言
                res = i - 1;
                break;
            }
            else
            {
                p[find(a + M)] = find(b + M);
                p[find(a)] = find(b);
            }
        }
        else
        {
            if (find(a) == find(b)) // 与上面同理
            {
                res = i - 1;
                break;
            }
            else 
            {
                p[find(a)] = find(b + M);
                p[find(a + M)] = find(b); 
            }
        }
    }
    
    cout << res ;
    
    return 0;
}
posted @ 2026-07-17 16:15  blind5883  阅读(5)  评论(0)    收藏  举报