算法笔记-并查集

无人荒原(已经废弃)。

 

$\text{upd:2023/5/2\;完成算法优化。}$

如有错误,欢迎指正。


1.什么是并查集

并查集(union-find set),顾名思义就是可以维护两种操作的集合。

以下,设集合 $a$ 为要维护的集合。

1.1 并

并查集在最初时,集内所有的 $n$ 个元素 $a_1,a_2,\cdots,a_n$ 分别各自属于 $n$ 个不同的集合。“并”操作可以让任意两个元素 $a_i,a_j$ 所在的两个集合合并成一个集合如图: $n=3$ 时$$ 图\;1 $$ 此时 $n=3$,三个元素在各自的集合中。 若我们将 $1,2$ 进行合并,那么: 将 $1,2$ 合并$$ 图\;2 $$ (我们用颜色和连线表示两个点所在的集合) 如图2所示,这时 $1,2$ 在同一个集合,而 $3$ 在另一个集合中。

1.2 查

既然可以合并,我们就一定能查询吧? 是的,“查”操作可以查询任意两个元素 $a_i,a_j$是否在同一个集合中。 如图2,如果我们查 $1,2$,我们会的到肯定回答,即在同一集合中。 若查 $1,3$ 或 $2,3$,我们则会得知它们不在同一集合中


2.算法实现

2.1 思路

我们可以把这个算法具象化为家谱。我们假设,家族关系中只有父子关系。若 $a_i,a_j$ 有血缘关系,那么它们一定有一个共同的祖先。 复杂一点的情况$$ 图\;3 $$ 如图3,很明显 $3,6$ 在同一集合中,但是它们没有直接的关系。不过,它们有一个共同的祖先 $4$。所以可知它们在同一集合中。这就是“查”:如果 $a_i,a_j$ 有一个共同的祖先 $a_k$,则它们在同一集合中。 知道“查”了,那怎么并呢? 如果我们知道 $a_i,a_j$ 有血缘关系,那我们可以分别找到它们的祖先 $a_k,a_l$,使 $a_k$ 的父亲为 $a_l$(反过来也行)。这就是“并”。 这样做的原理是“五百年前是一家”,即是没有很“亲”的血缘,但至少有血缘关系了。

所以,并查集本质上是一棵树。

2.2 实现

设数组 $f_i$ 表示 $i$ 的父亲,初始化时,$f_i=i$。

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

查[^1]:

int Find(int k)
{
    if(f[k]==k)
    {
        return k;
    }
    return Find(f[k]);
}

并:

void Union(int a,int b)
{
    int fa=Find(a),fb=Find(b);
    if(fa==fb)
    {
        return;
    }
    f[fa]=fb;//or f[fb]=fa;
    return;
}

[^1]:代码中的查仅包含查找点 $a_i$ 的祖先,真正的查还要判断两元素的祖先是否相同。


3.算法优化

3.1 路径压缩

我们注意到,每一次查询都要爬一遍树,很麻烦,所以考虑优化。 每次找祖宗时,递归到的每一个点都是在同一个集合里的,所以干脆将经过的节点的父亲都设为祖宗,这样可以大大压缩查找的路径。

模板:

int Find(int k)
{
    if(f[k]==k)
    {
        return k;
    }
    return f[k]=Find(f[k]);
}

3.2 按秩合并

查询有优化,合并怎么会没有?

在合并的过程中,选择哪一棵树的根结点作为新的根结点很重要,因为会影响接下来操作的复杂度,按秩合并就是为了避免查询的复杂度发生退化。

在合并时,如果我们将小的树并到大的树上,这样合并后的树就会相对平衡。 有两种判别树的大小的方式。

3.3.1 树的深度

设节点 $i$ 的子树深度为 $dep_i$,初始化时 $dep_i=1$。因为我们都是把小树并到大树上,所以一般新树的深度就是大树的深度。但,如下图,当合并的两树大小相同时,树的深度就要加1。 两棵树深度都为2$$ 图\;4 $$ 合并后深度为3$$ 图\;5 $$ 图4中,两棵树深度都为2,但合并后如图5,深度为3。

模板:

int dep[n+1];
fill(dep,dep+n+1,1);
void Union(int a,int b)
{
    int fa=Find(a),fb=Find(b);
    if(fa==fb)
    {
        return;
    }
    if(dep[fa]<=dep[fb])
    {
        f[fa]=fb;
    }
    else
    {
        f[fb]=fa;
    }
    if(dep[fa]==dep[fb])
    {
        dep[fb]++;
    }
    return;
}

3.3.2 节点数量

同理,设节点 $i$ 的子树深度为 $siz_i$,初始化时 $siz_i=1$。

模板:

int siz[n+1];
fill(siz,siz+n+1,1);
void Union(int a,int b)
{
    int fa=Find(a),fb=Find(b);
    if(fa==fb)
    {
        return;
    }
    if(siz[fa]<=siz[fb])
    {
        siz[fb]+=siz[fa];
        f[fa]=fb;
    }
    else
    {
        siz[fa]+=siz[fb];
        f[fb]=fa;
    }
    return;
}

4.升级版1——带边权并查集(未完待续)

5.升级版2——扩展域并查集

有时,我们需要描述一些朋友与敌人的关系,这时候,我们就需要带边权并查集。

5.1 算法思路

我们可以用最简单的并查集处理朋友之间的关系,再利用扩展域并查集处理敌人之间的关系。 那么敌人之间的关系怎么维护呢? 我们假设每个节点都有两个面,正面和反面。 若 $i,j$ 两节点为敌人,那么用 $i$ 的反面与 $j$ 合并,再将 $j$ 的反面与 $i$ 合并。这样,就可以表示$i,j$ 两节点为敌人了。 当然,在表示朋友时也要记得分别用 $i,j$ 的的反面合并。

但现在又有新的问题了:怎么表示正反面呢? 很简单,对于节点 $i$,$i$ 就是它的正面,而 $i+n$($|a|=n$,$n$ 是集合大小)。 这样可以避免节点编号重合。

5.2 代码实现(未完待续)

posted @ 2023-05-10 20:13  Po7ed  阅读(2)  评论(0编辑  收藏  举报  来源