Studying-代码随想录训练营day55| 并查集理论基础、107.寻找存在的路径

第55天,图论05,并查集!!💪(ง •_•)ง,编程语言:C++

目录

并查集理论基础

原理

路径压缩

代码模板 

常见误区

拓展

复杂度分析

107.寻找存在的路径 


并查集理论基础

文档讲解:代码随想录并查集理论基础

并查集主要解决的是连通性的问题,也即判断两个元素是否在同一个集合里的时候,我们需要想到使用并查集。

并查集主要有两个功能:1.将两个元素添加到一个集合中;2.判断两个元素在不在一个集合。

原理

上述的两个功能的核心就在于集合的建立。如何建立或者说定义一个集合。 

如果我们采用数组、set或者map来创建集合,则会导致集合的数量太多,有多少个集合就有多少个数组,在判断两个元素是否是同一个集合的时候,也会很麻烦,需要遍历多个数组。采用二维数组同理,每次插入新的元素,或者查找两个元素是否在同一个集合里面的时候,都需要遍历二维数组一遍。

因此我们需要换一个思路!集合最重要的是什么,是需要有一个标志!采用数组和set,我们的标志就是不同的数组,但是我们还可以把元素作为标志!

例如我们将三个元素ABC放在同一个集合,即:使得father[A] = B; father[B] = C。这个的意思表示A的父节点是B,B的父节点是C,他们同属于C这个集合当中。因此也可以说A的根是C,B的根也是C。用代码表示这个组合过程就是:

void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) {
        return;  // 如果发现根相同,则说明已经在一个集合当中了
    }
    else {
        father[v] = u; //不在一个集合,则将其中一个节点的父节点改为另一个节点
    }
}

在这个过程中,我们判断两个元素是否在一个集合,首先我们可以通过 father[A] = B知道A 连通 B,那B是否连通A呢,答案是肯定的,因为我们只需要知道它们在一个集合中就可以了,集合内的元素都是互相连通的。

接着我们给出寻根思路,一般来说,只要两个元素的根是相同的,则说明它们在同一个集合。例如A的根,可以通过father[A] = B,father[B] = C找到根C。而B的根通过father[B] = C也可以找到根C。这实际上就是一个不断递归的过程。

//寻根
int find(int u) {
    if (u == father[u]) return u; // 如果根就是自己,直接返回
    else return find(father[u]); // 如果根不是自己,就根据数组下标一层一层向下找
}

显然C的根就是其本身,因此father[C] = C。因此father数组初始化的时候要 father[i] = i,默认自己指向自己。之后再根据题目条件,更改father数组里面每个元素的值,即可构造出不同的并查集。

//并查集初始化
void init() {
    for (int i = 0; i < n; ++i) {
        father[i] = i;
    }
}

最后我们判断两个元素是否再同一个集合,只需要判断它们两个的根是不是同一个就可以了。 

// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {
    u = find(u);
    v = find(v);
    return u == v;
}

路径压缩

我们还可以进一步的将father数组的路径压缩!!!

首先一句find函数的实现逻辑,我们知道我们需要通过递归的方式,不断获取father数组下标的数值,最终找到这个集合的根。这个过程就类似于树从叶子节点不断向上找到根节点的过程:

显然如果这个树的高度很深的话,那么我们要递归多次,时间复杂度即为O(h)

但对于并查集来说,实际上我们只需要知道节点是不是在同一个根下即可,因此这个树的构造只需要按如下方式即可:

也就是除了根节点以外,其他同一个集合的点都挂在在根节点下面,这样我们寻根的时候只需要一步就可以了。

要实现这个过程我们需要进行路径压缩,把非根节点的所有节点直接指向根节点。这个过程可以在我们find递归的过程中实现,也就是在返回根节点的同时,让每一层的father[u]接住返回的结果,这样就让节点u的父节点,变成了根节点。

// 并查集里寻根的过程
int find(int u) {
    if (u == father[u]) return u;
    else return father[u] = find(father[u]); // 路径压缩
}

代码模板 

最后可以得到并查集的模板如下:

int n = 2024; //节点的个数由题目给出,一般比节点数量大一即可
vector<int> father = vector<int> (n, 0);

//并查集初始化
void init() {
    for(int i = 0; i < n; i++) {
        father[i] = i; //初始化为其本身
    }
}

//并查集寻根过程
int find(int u) {
    if(u == father[u]) return u; //如果根就是其本身,直接返回
    return father[u] = find(father[u]); //否则进入递归,这个地方在进入递归的同时加入了路径压缩
}

// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {
    u = find(u);
    v = find(v);
    return u == v;
}

// 将v->u 这条边加入并查集
void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) return ; // 如果发现根相同,则说明已经在一个集合了,无需操作
    father[v] = u; //实际上这是一个将两个集合合并为一个集合的过程
}

通过代码我们可以确定,并查集主要有三个功能:

  1. 寻找根节点,函数:find(int u),也就是判断这个节点的祖先节点是哪个。
  2. 判断两个节点是否在同一个集合,函数:isSame(int u, int v),就是判断两个节点是不是同一个根节点
  3. 将两个节点接入到同一个集合,函数:join(int u, int v),将两个节点连在同一个根节点上。

常见误区

对于join代码和isSame代码存在一个误区。看起来它们两个代码有一部分重合的地方。但是并不能组合在一起。也即如下这样写:

// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {
    u = find(u);
    v = find(v);
    return u == v;
}

// 将v->u 这条边加入并查集
void join(int u, int v) {
    if (isSame(u, v)) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u;
}

原因在于,在join中,我们是需要找到u和v的根,然后把根连接在一起(相当于把两个集合合并)而不是直接用u和v连线在一起。

举个例子,如果是按上述代码进行join(1,2) 和 join(3,2),最后得到的图是:

1和3并没有在一个集合中,因为我们并没有把根连接在一起。

而使用最初的代码,进行join(1,2) 和 join(3,2),得到的就会是:

void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根
    if (u == v) return ; // 如果发现根相同,则说明在一个集合,不用两个节点相连直接返回
    father[v] = u;
}

这样三个元素才会到一个集合当中。

拓展

我们知道,我可以通过路径压缩的方式,来降低查询根节点的时间。

事实上还有另一种适合二叉树的方法:按秩(rank) 合并。rank表示树的高度,即树中结点层次的最大值。例如两个集合(多叉树)需要合并,如图所示:

树1 rank 为2,树2 rank 为 3。那么合并两个集合,是 树1 合入 树2,还是 树2 合入 树1呢。可以看看两个不同方式合入的效果。

树2 合入 树1 会导致整棵树的高度变的更高,而 树1 合入 树2 整棵树的高度 和 树2 保持一致。我们又知道树高是和find的时间复杂度直接关联的,因此我们在join的时候一定是 rank 小的树合入 到 rank大 的树,这样可以保证最后合成的树rank 最小,降低在树上查询的路径长度。

int n = 2024; // n根据题目中节点数量而定,一般比节点数量大一点就好
vector<int> father = vector<int> (n, 0); // C++里的一种数组结构
vector<int> rank = vector<int> (n, 1); // 初始每棵树的高度都为1

// 并查集初始化
void init() {
    for (int i = 0; i < n; ++i) {
        father[i] = i;
        rank[i] = 1; // 也可以不写
    }
}
// 并查集里寻根的过程
int find(int u) {
    return u == father[u] ? u : find(father[u]);// 注意这里不做路径压缩
}

// 判断 u 和 v是否找到同一个根
bool isSame(int u, int v) {
    u = find(u);
    v = find(v);
    return u == v;
}

// 将v->u 这条边加入并查集
void join(int u, int v) {
    u = find(u); // 寻找u的根
    v = find(v); // 寻找v的根

    if (rank[u] <= rank[v]) father[u] = v; // rank小的树合入到rank大的树
    else father[v] = u;

    if (rank[u] == rank[v] && u != v) rank[v]++; // 如果两棵树高度相同,则v的高度+1,因为上面 if (rank[u] <= rank[v]) father[u] = v; 注意是 <=
}

注意在find里面是没有路径压缩的,因为如果加入路径压缩,rank记录的高度就不准确了,根据rank来判断如何合并就没有意义。虽然也可以在路径压缩的时候,再去实时修生rank的数值,但这样在代码实现上麻烦了不少,关键是收益很小。其实我们在优化并查集查询效率的时候,只用路径压缩的思路就够了,不仅代码实现精简,而且效率足够高。

按秩合并的思路并没有将树形结构尽可能的扁平化,所以在整理效率上是没有路径压缩高的。所以推荐直接使用路径压缩的并查集模板。

复杂度分析

以路径压缩的并查集模板为例。

空间复杂度:O(n),申请一个father数组。

时间复杂度:精确的时间复杂度需要进行数学证明,但是可以确定路径压缩后的并查集时间复杂度在O(logn)与O(1)之间,且随着查询或者合并操作的增加,时间复杂度会越来越趋于O(1)。在第一次查询的时候,相当于是n叉树上从叶子节点到根节点的查询过程,时间复杂度是logn,但路径压缩后,后面的查询操作都是O(1),而 join 函数 和 isSame函数 里涉及的查询操作也是一样的过程。 


107.寻找存在的路径 

文档讲解:手撕寻找存在的路径

题目:107. 寻找存在的路径 (kamacoder.com)

学习:本题看起来是一个无向图找路径的题目,但这样时间复杂度就为O(V*E),最坏的情况就是遍历所有的节点和边。但我们其实可以采用并查集的方式进行求解(并查集主要能够解决两个节点在不在一个集合,也可以将两个节点添加到一个集合中),因为本题是无向图,两个点之间有一条边就说明这两点是连通的,而集合里的点也是通过连通来加入的,也可以认为有边就是一个集合。因此本题就从找路径的方法转变到判断两个顶点是否在同一个集合中,如果在则说明能够达到,如果不在则说明不能够达到。

代码:先写模版,然后构建father数组,最后判断

#include <iostream>
#include <vector>
using namespace std;

//并查集模版
int n;
vector<int> father(n + 1, 0); //节点从1开始,因此我们定义一个n+1大小的数组

void init() { //初始化father数组
    for(int i = 0; i <= n; i++) {
        father[i] = i;
    }
}

int find(int u) { //寻根函数
    // return u == father ? u : father[u] = find(father[u]); //简化写法
    if(u == father[u]) return u; //自身就是根
    return father[u] = find(father[u]); //假如路径压缩
}

bool isSame(int u, int v) { //判断是否在一个集合当中
    u = find(u);
    v = find(v);
    return u == v;
}

void join(int u, int v) { //将两个点加入一个集合
    u = find(u); //找到根
    v = find(v); //找到根
    if(u == v) return; //本身就已经在一个集合中了
    father[v] = u;
}

int main() {
    cin >> n;
    init(); //初始化father数组
    
    int m;
    cin >> m;
    int s, t;
    while(m--) {
        cin >> s >> t;
        join(s, t);
    }
    int source, destination;
    cin >> source >> destination;
    if (isSame(source, destination)) cout << 1 << endl;
    else cout << 0 << endl;
    return 0;
}
  • 13
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值