「算法学习」并查集以及它的一些扩展
2022/6/8 1:20:09
本文主要是介绍「算法学习」并查集以及它的一些扩展,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
并查集
简介
并查集是一种树形的数据结构,它支持两种操作:
- 查找(find):查询某个元素属于哪个集合;
- 合并(merge):将两个集合合并成同一个集合。
查找
我们令 find
函数表示寻找 \(x\) 的祖先。如果 \(x\) 已经是祖先,则返回;否则递归到 \(f[x]\) 的子问题。
int find(int x) { return f[x] == x ? x : find(f[x]); }
但是它在最坏情况下(如一条链)是单次 \(O(n)\) 的。考虑把在路径上的每个节点都直接连接到祖先上,我们称之为路径压缩。
int find(int x) { return f[x] == x ? x : f[x] = find(f[x]); }
合并
我们令 merge
函数表示合并 \(x,y\) 所在的集合。显然可以把 \(x\) 所在集合的祖先与 \(y\) 所在的集合的祖先相连,达到该目的。
void merge(int x, int y) { f[find(x)] = find(y); }
这样合并显得很无脑,我们考虑衡量一个集合的指标:大小和深度。以大小为例,我们将小的往大的合并,那么感性理解 find
函数递归的次数会相对少一些,我们称之为启发式合并。
void merge(int x, int y) { x = find(x), y = find(y); if (x == y) return; if (sz[x] > sz[y]) swap(x, y); f[x] = y, sz[y] += sz[x]; }
时间复杂度
我们定义 \(Ack(n,m)\) 表示阿克曼函数,\(\alpha(n)\) 表示反阿克曼函数。容易发现 \(\alpha(n)\) 的增长速度极其缓慢,在 OI 中一般默认它 \(\le 4\),可视为常数。
路径压缩 | 启发式合并 | 期望复杂度 | 最坏复杂度 |
---|---|---|---|
无 | 无 | 单次 \(O(\log n)\) | 单次 \(O(n)\) |
无 | 有 | 单次 \(O(\log n)\) | 单次 \(O(\log n)\) |
有 | 无 | 均摊 \(O(m\alpha(m,n))^{[1]}\) | 均摊 \(O(m\log n)^{[2]}\) |
有 | 有 | 单次 \(O(\log n)\),均摊 \(O(m\alpha(m,n))\) | 单次 \(O(\log n)\),均摊 \(O(m\alpha(m,n))\) |
例题
- 洛谷 P3367 【模板】并查集;
- UOJ 127 「NOI 2015」程序自动分析;
- UVA 11987 Almost Union-Find
传统的并查集不能进行操作 2 的原因是 \(p\) 有可能是某些点的父亲,如果直接将 \(p\) 接到 \(q\) 的祖先上,会导致把 \(p\) 的整棵子树给接过去。
这启发我们规避掉 \(p\) 成为某些点父亲的情形,因此考虑对于每个点 \(i\) 建立虚点 \(i'\),初始 \(i\) 指向 \(i'\)。那么在任意时刻,只有 \(i'\) 可能是父亲,这样直接把 \(i\) 接到 \(q\) 就没问题了。
带权并查集
我们可以在并查集的边上定义某种权值,每个点存的是它到它当前指向的祖先间的权值信息。在路径压缩时,将路径上的点的权值信息依次修改过去即可。
例题
- 洛谷 P2024 「NOI 2001」食物链;
- 洛谷 P1196 「NOI 2002」银河英雄传说;
可持久化并查集
要支持查询历史版本,用主席树维护即可。
例题
- 洛谷 P3402 可持久化并查集;
可撤销并查集(栈)
此时我们不能再路径压缩了,否则撤销会出大问题。因此只采用启发式合并,复杂度单次 \(O(\log n)\)。
如果我们把加边、删边看成一个进栈、弹栈的过程的话,那么我们每次撤销只能弹栈顶的边(即最近一次加入的边)。
可撤销并查集(双指针)
在做传统双指针问题的时候,我们遇到过“双栈结构”的 trick。
它的想法是开两个栈,一个倒的栈,一个正的栈。删头可以直接弹“倒栈”的头,加尾可以直接塞“正栈”的头。遇到“倒栈”为空时,把“正栈”全部弹进“倒栈”,自己变空。
容易发现一个元素只会依次在两个栈中出现,对复杂度的贡献是 \(O(1)\),所以总时间复杂度是 \(O(n)\) 的。
这引发我们的思考:为何传统问题能这样做,而可撤销并查集就不行呢?仔细思考,便会发现它有一大硬伤:它自身有一个加边的栈顺序。在它的眼里,它是将我们的两个栈归并在了一起看的,而每次只能撤销最新一次加入的边,那我们的两个栈就不方便撤销了。
因此,我们被迫顺着可撤销并查集来设计算法,我们用一个 stack
记录两个栈按照加边顺序归并在一起的结果,其中 stack
内每个元素都是三元组 (rev, u, v)
,rev
用来表示它是哪个栈的,\(u,v\) 则表示这条边的两端点。
考虑下面这样一个流程:
- 删元素:我们从
stack
的头开始一直删,直到取出的两个栈的元素个数相等。
参考文献
- \([1]\):Tarjan, R. E., & Van Leeuwen, J. (1984). Worst-case analysis of set union algorithms. Journal of the ACM (JACM), 31(2), 245-281;
- \([2]\):Yao, A. C. (1985). On the expected performance of path compression algorithms;
这篇关于「算法学习」并查集以及它的一些扩展的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-23Springboot应用的多环境打包入门
- 2024-11-23Springboot应用的生产发布入门教程
- 2024-11-23Python编程入门指南
- 2024-11-23Java创业入门:从零开始的编程之旅
- 2024-11-23Java创业入门:新手必读的Java编程与创业指南
- 2024-11-23Java对接阿里云智能语音服务入门详解
- 2024-11-23Java对接阿里云智能语音服务入门教程
- 2024-11-23JAVA对接阿里云智能语音服务入门教程
- 2024-11-23Java副业入门:初学者的简单教程
- 2024-11-23JAVA副业入门:初学者的实战指南