并查集模板

281 阅读4分钟

并查集是一种简洁优雅的数据结构,本文是笔者对并查集的学习和整理,包含原理、优化方法和代码示例等。发出来作为个人的并查集模板归档。

1. 概念与特性

并查集主要用于解决一些 元素分组 问题,它管理 一系列不相交的集合,并支持两种操作:

  • 查询(Find):查询两个元素是否在同一个集合中。
  • 合并(Union):把两个不相交的集合合并为一个集合。

并查集的核心思想在于,用集合中的一个元素代表集合。并查集是用数组描述的一种树结构,数组的索引表示子节点,数组的值表示父节点。要寻找集合的代表元素,只需要一层一层往上访问父节点,直达树的根节点即可。根节点的父节点是它自己。

class UnionFind {
    private int fa[] = new int[N];

    public void init() {
        for (int i = 1; i < N; i++) {
            fa[i] = i;
        }
    }

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

    public void union(in x, int y) {
        fa[find(x)] = find(y);
    }
}

2. 路径压缩

最简单并查集的效率比较低,可能会形成一条长长的链。既然我们只关心一个元素对应的 根节点,那我们希望每个元素到根节点的路径尽可能短,最好只需要一步,所以我们在查询的过程中,把沿途的每个节点的父节点都设为根节点即可

class UnionFind {
    // ...

    public int find(int x) {
        if (fa[x] == x) {
            return x;
        }
        fa[x] = find(fa[x]);    // 路径压缩的核心步骤
        return fa[x];
    }

    // ...
}

3. 按秩合并

由于路径压缩只对访问过的路径进行压缩,不是每个节点的父节点都是根节点。所以并查集的结构仍然可能是负载的。看下面的情形:

union_by_rank_1.png

1 和 8 合并,这时我们有两种方案,8 作为 1 的父节点,或 1 作为 8 的父节点。

union_by_rank_2.png

显然第一种方式效率更加高。

因为如果把 8 作为 1 的父节点,会使得树的深度(树中最长链的长度)加深,原来树的每个节点到根的距离都变长了,后面我们每次查询根节点复杂度也会变高。而把 1 作为 8 的父节点就不会有这个问题。因此我们每次合并的时候都应该把简单的树合并到复杂的树上面,保证每次合并到根节点距离变长的节点数量最少。

我们用一个数组 rank 记录每个根节点对应的树的深度(如果不是根节点,其 rank 相当于以它作为根节点的子树的深度)。一开始,把所有元素的 rank 设为一。合并时比较两个根节点,把深度较小者往较大者上合并,并更新 rank。在数学上,rank 的含义是并查集的

如果一起使用路径压缩和按秩合并,时间复杂度接近 O(n)O(n),但是很可能会破坏 rank 的准确性(相关证明可以在网上搜索关键字「并查集」、「反阿克曼函数」)。

class unionFind {
    private int fa[] = new int[N];
    private int rank[] = new int[N];

    public void init() {
        for (int i = 1; i < N; i++) {
            fa[i] = i;
            rank[i] = 1;
        }
    }

    public int find(int x) {
        if (fa[x] == x) {
            return x;
        }
        fa[x] = find(fa[x]);    // 路径压缩
        return fa[x];
    }

    public void union(in x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        if (rank[rootX] <= rank[rootY]) {
            fa[rootX] = rootY;
        } else {
            fa[rootY] = rootX;
        }
        // 特殊情况下新的根节点深度加一
        if (rank[rootX] == rank[rootY] && rootX != rootY) {
            rank[rootY]++;
        }
    }
}

上面代码中提到了一类特殊情况,需要将合并后的新的根节点深度加一,我们通过一个案例来分析下原因。

union_by_rank_3.png

有两棵深度为 2 的树需要合并,无论是将 4 的父节点设为 1,还是把 1 的父节点设为 4,结果都是一样的,新的根节点深度为 3。

union_by_rank_4.png

所以,在使用按秩合并时,如果两个不同集合的秩相同,合并后的集合的秩需要加一。