并查集问题

Published: by Creative Commons Licence

  • Tags:

并查集

提个问题

存在若干个元素,$e_1,e_2,\ldots,e_n$。要求我们支持两种操作:

1.对于给定$i$与$j$,将$e_i$,$e_j$所在的两个集合合并为一个。

2.对于给定$i$与$j$,判断$e_i$,$e_j$是否处于同一个集合中

如果你将这些元素视作顶点,那么操作1实际是建立边$(e_i, e_j)$,而操作2是询问顶点$e_i$与顶点$e_j$是否连通。问题就转换为图论问题。

并查集是专门用于解决这两个问题的数据结构。

按秩合并

并查集的最简单的实现方式是维护一个森林。为每个元素建立一个结点,每个结点维护两个属性:father和rank。father表示父结点,而rank是以自己为根的子树的大小。

class Node{
    Node father = this;
    int rank = 1;
    public Node getRoot(){
        return father == this ? father : father.getRoot();
    }
}

查询操作的结果可以通过判断两个元素所在树的根是否相同得到。

合并操作使用的是启发式合并,合并之前先找到两个结点所在树的根,并通过判断根是否相同判断两元素是否处于相同的集合中,如果是相同集合,就可以跳过合并。否则,则将较小集合的根的father字段设置为较大集合的根,并更新较大集合的根的rank字段。

Node union(Node a, Node b){
    a = a.getRoot();
    b = b.getRoot();
    if(a == b){
        return a;
    }
    if(a.rank >= b.rank){
        b.father = a;
        a.rank += b.rank;
        return a;
    }else{
        a.father = b;
        b.rank += a.rank;
        return b;
    }
}

由于这里使用的是启发式合并,每次只有较小的树会合并到较大的树中。而一个结点$u$与根的距离每扩大1,意味着它作为较小树中的成员合并到了较大的树中,而$u$所在的树的大小至少增大了一倍。由于$u$所在的树的大小最大为$n$,因此最多被合并$\log_2n$次,即$u$与根的距离最大为$\log_2n$。

因此查询和合并的时间复杂度均为$O(\log_2n)$。

路径压缩

可以看到按秩合并的主要时间费用都花在了getRoot()方法上,这个方法的时间复杂度为$O(\log_2n)$。那么我们能不能想个法子优化这个方法呢。

路径压缩就是一个好方法。首先father仅用来查询根结点,因此我们可以设置father为任何距离根结点更近的祖先结点,这样就可以减少每次查询花费的时间。其次需要重新定义秩,在按秩合并的情况下,秩代表的是子树的大小,但是在路径压缩技巧中,秩代表的是树的高度。

class Node{
    Node father;
	int rank;
    public Node getRoot(){
        return father.father == father ? father : (father = father.getRoot());
    }
}
Node union(Node a, Node b){
    a = a.getRoot();
    b = b.getRoot();
    if(a == b){
        return a;
    }
    if(a.rank == b.rank){
        a.rank++;
    }
    if(a.rank > b.rank){
        b.father = a;
        return a;
    }else{
        a.father = b;
        return b;
    }
}

这里getRoot()的时间复杂度会低于$O(\log_2n)$,事实上,你可以认为在你可以想象的任意大数据集中,它都是$O(1)$,这里并不给出证明,可以自行参考《算法导论》。

持久化

假如我们在原始的问题中再加上一个操作:

3.允许我们将并查集切换回到之前的某个版本。

这个问题就有点难度了。由于操作2是读操作,而操作1仅会修改两个结点的状态。因此假如我们用持久化结构保存每个结点的信息(father和rank),这样就可以同时实现版本的切换了。

但是注意如果我们使用路径压缩的技巧,可能一次操作会修改很多的结点(尽管摊还是$O(1)$),这就不适合使用持久化了。因此我们选择的优化技巧是按秩合并。

持久化结构可以使用持久化线段树或者持久化无旋Treap,它们的读写操作时间复杂度都为$O(\log_2n)$。因此在使用持久化结构作为底层的情况下,操作1、2的时间复杂度均为$O((\log_2n)^2)$,而操作3的时间复杂度为$O(1)$。