并查集
- TA Can Do What & why learning
- what
- why
- 原理和结构
- 路径压缩
- 例题讲解
- 题解
- solution 1(50分)
- solution 2(100分)
- 按秩(树高)合并
- 按大小合并
TA Can Do What & why learning
what
并查集主要是解决连通块的问题,例如对于上面的这个由5个村子若干条路构成的简易地图,如果问你1----->5是否是连通的,显然是的,那如果问你3——>5是否连通,显然是false,因为没有任何一条路能从3指向5,这不是很简单吗
why
那我们需要并查集干嘛?
好问题 如果这个图如果需要你自己构造而不是直接给你(动态构造集合关系),你很难或者说没法直接通过给的数据去画出每一个图的时候,并查集就能帮助我们迅速判断是否连通,而往往算法竞赛中的题目都是动态构造的(后面会附上例题),所以学习并查集是很有必要的,不然的话很大概率会超时
传统方式:
假设你有 100 万个村庄,每次新增一条路(动态添加边),如果每次都用 DFS/BFS 重新遍历整个图来判断两个村庄是否连通,时间复杂度会高达 O (N),效率极低。而并查集的find和union操作经过路径压缩和按秩合并优化后,时间复杂度几乎是O(1)
原理和结构
我们需要知道两个概念父节点和祖先,有一点像二叉树章节里的但又不太一样,尤其是对于祖先这个概念,
祖先代表的是:某一个节点不断去寻找他的父节点(递归),直到某一个节点的父节点是他本身(出口)
题外话:我在学习的时候感觉有点像 二叉树章节里面的 求最大深度问题,其实后来我想了一下是很正常的
毕竟树从某种意义上来说是特殊的图
言归正传:我们用一个pre数组来保存每个节点的父亲,我们的数组如下图所示,这里特意需要说的就是 1的父亲是他本身,所以1对应pre数组里头存储的父节点就是1
这句话怎么来理解呢:
其实可以把1看做是上面这个集合(1,2,4,5)的代表元素,也就是根节点,打个比方来说,这个1就是这个集合(家族)里面最年长的,就像家族的 “掌门人” 一样,没有比他更年长的父亲了,所以他的父亲就是他自己。
成员 1 作为这个家族的代表元素(根节点),就像是整个家族的源头,其他成员都是从他这里 “衍生” 出来的,就像一棵大树,成员 1 是树干最顶端的那个起始点,其他成员是从树干上长出来的树枝和树叶。
通过上面的例子大家应该会有一个比较基础的了解,但是在一开始每一个节点都是跟自己是一个连通关系(即指向自身)
就比如在添加1-2这条边的时候,我们就会让2的祖先指向1,在添加2-4这条边的时候,我们就需要注意,打个比方来说
由于2的“钱”已经交给1了,4想要找2要钱是找不到的,只能去找1了,体现在图中就是让4的祖先指向1,
总结来说就是:对于任何非根节点的相连都必须转换成它们各自的根节点相连
那现在我们已经可以用个构建的这个表来判断节点之间是否连通了,就比如1一直找,找到他的祖先是4,这个时间复杂度是O(N)的,但是这只是一次查询的情况如果有n次查询,那么时间复杂度就是O(n方),
那么有没有办法去优化它呢?
其实是有的——路径压缩(没学之前听着恐怖 其实是纸老虎)
路径压缩
我对于路径压缩的理解(可能不完全准确哈):
就是好比你是一个失忆的人,现在知道四个地方,A B C D,你通过不断探索知道了A是经过B C 能走到D的,也就是说你现在知道A到D是连通的,但是每过一段时间 如果一个人问你,你都需要重新走一遍,路径压缩好比就是,你用笔记本记下来了,A到D是通的并且D是终点,(这就引出了路径压缩的核心思想:通过直接记录最终结果(根节点),避免重复计算路径)
同理B,C到D也是通的,我们这里并不关心,A怎么到D,只关心从A能不能到D
压缩完成就是这个情况:
本质:
牺牲空间(存储父指针)换取时间(快速查询)
将树的高度 “压扁”,使得后续的find操作时间复杂度接近 O (1)
这里压扁的是啥?不就是我们寻找的路径path吗
例题讲解
题解
题目说的很直白就是让你用并查集的思路,其中有一个flag Z当它变化的时候,分别对应两种操作:1.合并(merge)2.判断(isCon)
solution 1(50分)
还有50%的测试点,数据非常大,即便关闭同步流,还是超时了吗,还是做不到吗,哈基霜你这家伙…
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e5 + 9;
int pre[maxn];//存储父节点int root(int x)
{return pre[x] == x ? x : pre[x] = root(pre[x]);
}
//一切操作都在根上
void Merge(int x, int y)
{pre[root(x)] = root(y);
}bool isCon(int x, int y)
{return root(x) == root(y);
}void init(int N)
{for (int i = 1; i <= N; ++i) pre[i] = i;
}signed main()
{ios::sync_with_stdio(0);int N, M;cin >> N >> M;init(N);while (M--){int Z, X, Y;cin >> Z >> X >> Y;if (Z == 1)Merge(X, Y);else if (Z == 2)printf("%c\n", isCon(X, Y) ? 'Y' : 'N');}return 0;
}
solution 2(100分)
之前代码仅实现路径压缩,未结合按树高或者大小合并,在数据量大 时,树可能因合并顺序不当变得高度很高,导致find操作时间复杂度退化为接近O(n),最终超时。而当前代码通过两种优化,将单次操作的时间复杂度优化至几乎常数级
#include <bits/stdc++.h>
using namespace std;const int maxn = 1e5 + 9;
int pre[maxn];// 存储父节点
int siz[maxn];// 存储集合大小(模拟秩)// 初始化并查集
void init(int N) {for (int i = 1; i <= N; ++i) {pre[i] = i;siz[i] = 1; // 初始时每个集合大小为 1}
}// 查找根节点并路径压缩
int root(int x) {return pre[x] == x ? x : pre[x] = root(pre[x]);
}// 合并两个集合,按集合大小(秩)优化
void Merge(int x, int y) {int rx = root(x);int ry = root(y);if (rx == ry) return; // 已在同一集合,无需合并// 保证将较小集合合并到较大集合if (siz[rx] > siz[ry]) swap(rx, ry); pre[rx] = ry;if (siz[rx] == siz[ry]) siz[ry]++; // 若大小相等,合并后新集合秩+1
}// 判断两个元素是否连通
bool isCon(int x, int y) {return root(x) == root(y);
}signed main() {ios::sync_with_stdio(0);int N, M;cin >> N >> M;init(N);while (M--) {int Z, X, Y;cin >> Z >> X >> Y;if (Z == 1) {Merge(X, Y);} else if (Z == 2) {printf("%c\n", isCon(X, Y) ? 'Y' : 'N');}}return 0;
}
这就引出了下面的优化方法按秩合并和启发式合并
按秩(树高)合并
在这之前啊,我们是不是通过解决 斜树查找退化成链表的问题 学习过平衡二叉树的概念,
这个其实跟这里的按秩合并非常像,解决的问题也很像
比如说上面这张图,如果新增的边是4->3,
那拿3这个节点举例,那他走到根只需要两步,那如果是3->4,那就需要走3步,根节点向右倾斜了
对于一棵树 我们更希望它变成 矮墩墩 而不是长竹竿,所以就需要我们添加边的时候就需要进行比较 矮的指向高的
按大小合并
跟上面跟类似用小的集合指向大的集合 也就是比较少的点会多走一步
void Merge(int x, int y) {int rx = root(x);int ry = root(y);if (rx == ry) return; // 已在同一集合,无需合并// 保证将较小集合合并到较大集合if (siz[rx] > siz[ry]) swap(rx, ry); pre[rx] = ry;if (siz[rx] == siz[ry]) siz[ry]++; // 若大小相等,合并后新集合秩+1
}