lotus

贵有恒何必三更眠五更起 最无益只怕一日曝十日寒

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

并查集是一种用于处理不相交集合的合并和查询问题的数据结构。它可以把一些元素划分为若干个不相交的集合,并支持两种操作:

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

并查集的思想是用一个数组表示了整片森林,树的根节点唯一标识了一个集合,我们只要找到了某个元素的的树根,就能确定它在哪个集合里。

为了让你更好地理解并查集的原理和操作,我将按照以下的步骤来写这篇博客:

  1. 介绍一个具体的问题,让你感受到并查集的应用场景和价值。
  2. 用图形和代码来展示并查集最基本的数据结构和操作,让你掌握其核心思想和实现方法。
  3. 用图形和代码来展示并查集的一些优化技巧,如路径压缩和按秩合并,让你提高其效率和性能。
  4. 给出一些常见的并查集问题和解题思路,让你巩固所学,并能灵活运用。

希望你能跟着我一起学习,并查集这个简单而高效的数据结构。😊

1. 一个具体的问题

我们先来看一个具体的问题1:

若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。

规定:x和y是亲戚,y和z是亲戚,那么x和z也是亲戚。如果x,y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。

输入格式:

第一行:三个整数n,m,p,(n<=5000,m<=5000,p<=5000),分别表示有n个人,m个亲戚关系,询问p对亲戚关系。

以下m行:每行两个数Mi,Mj,1<=Mi,Mj<=N,表示Mi和Mj具有亲戚关系。

接下来p行:每行两个数Pi,Pj,询问Pi和Pj是否具有亲戚关系。

输出格式:

P行,每行一个’Yes’或’No’。表示第i个询问的答案为“具有”或“不具有”亲戚关系。

这其实是一个很有现实意义的问题。我们可以建立模型,把所有人划分到若干个不相交的集合中,每个集合里的人彼此是亲戚。为了判断两个人是否为亲戚,只需看它们是否属于同一个集合即可。因此,这里就可以考虑用并查集进行维护了。

2. 基本数据结构和操作

2.1 数据结构

2.2 初始化

假如有编号为1, 2, 3, …, n的n个元素,我们用一个数组fa []来存储每个元素的父节点(因为每个元素有且只有一个父节点,所以这是可行的)。一开始,我们先将它们的父节点设为自己,表示每个元素都是一个单独的集合。

// 初始化
int[] fa; // 存储每个元素的父节点
int n; // 元素的个数

void init(int n) {
    this.n = n;
    fa = new int[n + 1]; // 下标从1开始
    for (int i = 1; i <= n; i++) {
        fa[i] = i; // 每个元素的父节点设为自己
    }
}

2.3 查询

我们用递归的写法实现对代表元素的查询:一层一层访问父节点,直至根节点(根节点的标志就是父节点是本身)。要判断两个元素是否属于同一个集合,只需要看它们的根节点是否相同即可。

// 查询
int find(int x) {
    if (x == fa[x]) { // 如果x是根节点,直接返回
        return x;
    } else { // 否则,递归地找x的父节点
        return find(fa[x]);
    }
}

// 判断两个元素是否属于同一个集合
boolean isConnected(int x, int y) {
    return find(x) == find(y); // 只需比较它们的根节点是否相同
}

2.4 合并

合并操作也是很简单的,先找到两个集合的代表元素,然后将前者的父节点设为后者即可。当然也可以将后者的父节点设为前者,这里暂时不重要。本文末尾会给出一个更合理的比较方法。

// 合并
void merge(int x, int y) {
    fa[find(x)] = find(y); // 将x所在集合的根节点设为y所在集合的根节点
}

这就是并查集最基本的数据结构和操作。下面我们来看看如何用它来解决上面提到的亲戚问题。

3. 一个具体的例子

我们用Java语言来实现一个简单的并查集类,并用它来解决亲戚问题。我们假设输入数据已经存储在一个二维数组中,输出结果也用一个数组来表示。

public class UnionFind {

    private int[] fa; // 存储每个元素的父节点
    private int n; // 元素的个数

    public UnionFind(int n) {
        this.n = n;
        fa = new int[n + 1]; // 下标从1开始
        for (int i = 1; i <= n; i++) {
            fa[i] = i; // 每个元素的父节点设为自己
        }
    }

    public int find(int x) {
        if (x == fa[x]) { // 如果x是根节点,直接返回
            return x;
        } else { // 否则,递归地找x的父节点
            return find(fa[x]);
        }
    }

    public void merge(int x, int y) {
        fa[find(x)] = find(y); // 将x所在集合的根节点设为y所在集合的根节点
    }

    public boolean isConnected(int x, int y) {
        return find(x) == find(y); // 只需比较它

    // 用一个数组来存储输入数据,每一行表示一对亲戚关系或者一对询问
    // 例如,{1, 2}表示1和2是亲戚,{3, 4}表示询问3和4是否是亲戚
    int[][] data = {
            {1, 2},
            {3, 4},
            {5, 6},
            {1, 6},
            {3, 5},
            {2, 4}
    };

    // 用一个数组来存储输出结果,每一行表示一对询问的答案
    // 例如,{"Yes"}表示第一对询问的答案是“是”,{"No"}表示第二对询问的答案是“否”
    String[][] result = new String[3][1];

    // 创建一个并查集对象,假设有6个人
    UnionFind uf = new UnionFind(6);

    // 遍历输入数据,根据不同的情况进行合并或者查询操作
    int index = 0; // 记录输出结果的下标
    for (int[] pair : data) {
        int x = pair[0];
        int y = pair[1];
        if (uf.isConnected(x, y)) { // 如果x和y已经是亲戚,说明这是一对询问
            result[index][0] = "Yes"; // 查询它们是否属于同一个集合,即是否是亲戚
            index++; // 更新输出结果的下标
        } else { // 如果x和y不是亲戚,说明这是一对亲戚关系
            uf.merge(x, y); // 合并它们所在的集合,即建立亲戚关系
        }
    }

    // 打印输出结果
    for (String[] answer : result) {
        System.out.println(answer[0]);
    }
}

运行上面的代码,我们可以得到以下输出:

Yes
No
Yes

这就是我们想要的答案。我们可以看到,并查集可以很方便地处理这种不相交集合的问题,只需要简单地进行合并和查询操作就可以了。

4. 优化技巧

虽然上面的代码可以正确地解决问题,但是它还有很大的优化空间。我们可以通过一些技巧来提高并查集的效率和性能,使其能够处理更大规模和更复杂的问题。

4.1 路径压缩

最简单的并查集效率是比较低的。例如,来看下面这个场景:

 

现在我们要merge(2,3),于是从2找到1,fa[1]=3,于是变成了这样:

 

然后我们又找来一个元素4,并需要执行merge(2,4):

从2找到1,再找到3,然后fa[3]=4,于是变成了这样:

 

大家应该有感觉了,这样可能会形成一条长长的,随着链越来越长,我们想要从底部找

到根节点会变得越来越难。

怎么解决呢?

我们可以使用路径压缩的方法。既然我们只关心一个元素对应的根节点,那我们希望每个元素到根节点的路径尽可能短,最好只需要一步,像这样:

 

其实这说来也很好实现。只要我们在查询的过程中,把沿途的每个节点的父节点都设为根节点即可。下一次再查询时,我们就可以省很多事。

这用递归的写法很容易实现:

// 查询(路径压缩)
int find(int x) {
    if (x == fa[x]) { // 如果x是根节点,直接返回
        return x;
    } else {
        fa[x] = find(fa[x]); // 把x的父节点设为根节点
        return fa[x]; // 返回父节点
    }
}

以上代码常常简写为一行:

// 查询(路径压缩)
int find(int x) {
    return x == fa[x] ? x : (fa[x] = find(fa[x])); // 三元运算符和赋值运算符的简写
}

注意赋值运算符=的优先级没有三元运算符?:高,这里要加括号。

路径压缩优化后,并查集的时间复杂度已经比较低了,绝大多数不相交集合的合并查询问题都能够解决。

然而,对于某些时间卡得很紧的题目,我们还可以进一步优化。

4.2 按秩合并

有些人可能有一个误解,以为路径压缩优化后,并查集始终都是一个菊花图(只有两层的树的俗称)。

但其实,由于路径压缩只在查询时进行,也只压缩一条路径,所以并查集最终的结构仍然可能是比较复杂的。

例如,现在我们有一棵较复杂的树需要与一个单元素的集合合并:

 

假如这时我们要merge(7,8),如果我们可以选择的话,是把7的父节点设为8好,还是把8的父节点设为7好呢?

当然是后者。因为如果把7的父节点设为8,会使树的深度(树中最长链的长度)加深,原来的树中每个元素到根节点的距离都变长了,之后我们寻找根节点的路径也就会相应变长。

虽然我们有路径压缩,但路径压缩也是会消耗时间的。

而把8的父节点设为7,则不会有这个问题,因为它没有影响到不相关的节点。

这启发我们:我们应该把简单的树往复杂的树上合并,而不是相反。因为这样合并后,到根节点距离变长

 

的节点个数比较少。

我们用一个数组rank[]记录每个根节点对应的树的深度(如果不是根节点,其rank相当于以它作为根节点的子树的深度)。一开始,把所有元素的rank()设为1。

合并时比较两个根节点,把rank较小者往较大者上合并。

路径压缩和按秩合并如果一起使用,时间复杂度接近O(n),但是很可能会破坏rank的准确性。

// 初始化(按秩合并)
void init(int n) {
    this.n = n;
    fa = new int[n + 1]; // 下标从1开始
    rank = new int[n + 1]; // 记录每个根节点对应的树的深度
    for (int i = 1; i <= n; i++) {
        fa[i] = i; // 每个元素的父节点设为自己
        rank[i] = 1; // 每个元素的秩设为1
    }
}

// 合并(按秩合并)
void merge(int x, int y) {
    int fx = find(x); // 找到x所在集合的根节点
    int fy = find(y); // 找到y所在集合的根节点
    if (fx == fy) return; // 如果已经在同一个集合中,直接返回
    if (rank[fx] < rank[fy]) { // 如果x所在集合的深度小于y所在集合的深度,把x往y上合并
        fa[fx] = fy;
    } else { // 否则,把y往x上合并
        fa[fy] = fx;
        if (rank[fx] == rank[fy]) { // 如果两个集合的深度相等,合并后x所在集合的深度要加一
            rank[fx]++;
        }
    }
}

以上就是按秩合并的方法。它可以进一步减少寻找根节点的路径长度,提高并查集的效率。

 

 
posted on 2023-05-02 19:27  白露~  阅读(20)  评论(0编辑  收藏  举报