并查集[讲课留档]

并查集(DSU)

一些可以实现合并 查询 集合

简洁优雅的树型数据结构,主要用于解决一些元素分组的问题。可以管理一系列不相交的集合,并支持两种操作:

  • 合并(join):把两个不相交的集合合并为一个集合。
  • 查询(find):查询两个元素是否在同一个集合中。
前置芝士!
  • 连通图:由若干个顶点构成的子图,其中任意两个顶点之间都存在路径。

  • :有 n n n个结点, n − 1 n-1 n1条边的无向无环的连通图。

  • 森林:每个连通分量(连通块)都是树的图。按照定义,一棵树也是森林。

理论存在!
  • 集合的表示方式

用集合中的一个元素表示。(举个栗子……

  • 合并

A A A节点带着他的集合共同加入 B B B节点所在集合,直接让 B B B的祖宗成为 A A A的祖宗的父亲即可。

  • 查询

只需要一直查找父亲节点,直到出现父亲为自己时,说明找到了根节点。

当需要判断两个元素是否同在一个集合时,只需要找到每个元素所在集合的代表元素即可,一个元素不可身兼数职

实践开始!(基础版):
  • 初始化:一开始每个节点的父亲都是自己。
int f[N];
void init(int n){
    for (int i = 1; i <= n; ++i){
        fa[i] = i;
    }
}
  • 查询:找到根节点。
int find(int x){
	if(f[x] == x)
        return x;
    else
        return find(f[x]);
}
  • 合并:将A的集合的代表元素的父亲设为B的集合的代表元素
void join(int a,int b){
	f[find(a)] = find(b);
}
例题

并查集模板:https://www.luogu.com.cn/problem/P3367

AC代码:

#include<bits/stdc++.h>
using namespace std;
int f[20005];

void init(int n) {
    for (int i = 1; i <= n; ++i) {
        f[i] = i;
    }
}

int find(int x) {
    if (f[x] == x) {
        return x;
    } else {
        return f[x] = find(f[x]);
    }
}

void join(int x, int y) {
    int a = find(x), b = find(y);
    if (a != b) {
        f[a] = b;
    } 
}

int main() {
    int n, m;cin >> n >> m;
    init(n);
    while (m--) {
        int op, x, y;cin >> op >> x >> y;
        if (op == 1) {
            join(x, y);
        } else {
            if (find(x) == find(y)) {
                cout << "Y\n";
            } else {
                cout << "N\n";
            }
        }
    }
    return 0;
}
进阶之路!
  • 路径压缩:

    极端情况下,若每次将新元素合并到某集合的末尾,最终会构成一条链,随着链长的增加,从尾到头的查询效率会越来越低,查询的复杂度是由树的高度决定的,那么最理想的情况是所有人的父亲都是该集合的代表元素,此时树只有两层,因而出现了路径压缩优化。

    路径压缩的trick在于查询,在查询过程中不难看出,当从某个节点出发去寻找它的根节点时,会途径一系列的节点,在这些节点中,除了根节点外,我们可以顺手将其余所有节点都把直接父亲更改为根节点。基于这样的思路,我们可以通过递归来逐层修改返回时的某个节点的直接父亲(即f[x]的值)。简单说来就是将x到根节点路径上的所有点的父亲都设为根节点。

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

    但该优化的局限性也同样在于查询,由于压缩过程在查找时生效,于是只有查找了某个元素到根节点后,才能对该查找路径上的各节点进行路径压缩,即第一次执行查找操作的时候是没有实现压缩效果的,只有在之后的才有效,且每次压缩只能压缩一条路径,所以压缩后的树仍可能是较为复杂的,于是出现了另外一种优化方式。

  • 按秩合并|启发式合并:

    按秩合并的主要思想是合并的时候把小的树合并到大的树以减少工作量。

    我们先来定义一下并查集的“秩”,有两种定义的方法:

    1. 树的高度
    2. 树的节点数

    我们在路径压缩之后一般采用第二种,因为第一种在路径压缩之后就已经失去意义了,按照第二种合并可以减少一定的路径压缩的工作量。(但其实也不会太多,所以一般来说路径压缩就够用了)

    单独采用路径压缩的时查询时间复杂度为 O ( l o g N ) O(log N) O(logN)

    如果我们把路径压缩和按秩合并合起来一起使用的话可以把查询复杂度下降到 O ( α ( n ) ) O(α(n)) O(α(n)),其中 α ( n ) α(n) α(n)为反阿克曼函数。阿克曼函数是一个增长极其迅速的函数,而相对的反阿克曼函数是一个增长极其缓慢的函数,所以我们在算时间复杂度的时候可以把他视作一个常数

    void join(int a,int b){
       int fa=find(a),fb=find(b);
       if(fa==fb) return;
       if(size[fa]>size[fb])
           swap(fa,fb);
       f[fa]=fb;
       size[fb]+=size[fa];
    }
    
成为大师!
  • 种类并查集

    团伙:https://www.luogu.com.cn/problem/P1892

    如何实现敌人的敌人是朋友?

    反集:并查集的反集适用于元素只有两种性质的题目,也就是说,这个元素如果不属于并查集维护集合,则其必定属于另一个集合,加一个n即可将元素的一个虚拟敌人存入反集,并且和另一个元素合并。

    如果我们要将两个性质不同的元素 a a a b b b合并,我么们可以用并查集合并a和b+n、b和a+n ,如果 c c c a a a的性质不同,我们继续用并查集合并a和c+n、c和a+n ,此时 b b b a a a性质相同,他们成功合并在一起。

    食物链(感兴趣可做):https://www.luogu.com.cn/problem/P2024

团伙AC代码:

#include <bits/stdc++.h>
using namespace std;
#define io ios::sync_with_stdio(false),cin.tie(0),cout.tie(0)
typedef long long ll;
//#define int ll
#define pb push_back
#define eb emplace_back
#define m_p make_pair
const int mod = 998244353;
#define mem(a,b) memset(a,b,sizeof a)
#define pii pair<int,int>
#define fi first
#define se second
const int inf = 0x3f3f3f3f;
const int N = 3e5 + 50;
//__builtin_ctzll(x);后导0的个数
//__builtin_popcount计算二进制中1的个数
int f[N];

int find(int a) {
    if (f[a] == a)
        return a;
    else
        return f[a] = find(f[a]);
}

void join(int a, int b) {
    if (find(a) != find(b))
        f[find(b)] = find(a);
}

void work() {
    int n,m;cin>>n>>m;
    for(int i=1;i<=2*n;++i){
        f[i]=i;
    }
    for(int i=1;i<=m;++i){
        char opt;int p,q;
        cin>>opt>>p>>q;
        //cout<<p<<" "<<q<<'\n';
        if(opt=='F'){
            join(p,q);
        }else{
            join(p,q+n);
            join(q,p+n);
        }
    }

    int ans=0;
    for(int i=1;i<=n;++i){
        if(f[i]==i)ans++;
    }
    cout<<ans<<'\n';
}

signed main() {
    io;
    int t=1;
    //cin >> t;
    while (t--) {
        work();
    }
    return 0;
}
  • 带权并查集

    不难发现所谓的并查集本质上也是一种树,由于路径压缩的存在,使得一个并查集树中,其被压缩过的子节点总是直接指向根节点,于是我们可以给根节点与子节点之间的边进行赋权,这个权值可以表示该节点与根节点之间的距离。

大师我悟了!
  • 写并查集时很容易忘记初始化导致 d e b u g debug debug时找不到错,务必务必务必先初始化

  • 注意!并查集无法以较低复杂度实现集合的删除

  • 大多数考察并查集的题目在我们想到用并查集的时候已经被解决了大半,即想到要用是极为重要的。

  • 在日后的学习过程中,并查集多数时刻会作为工具拥有更灵活的用法,例如最小生成树 K r u s k a l Kruskal Kruskal算法、记录区间实现快速跳区间。

祝大家学习愉快!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值