本日主要内容是并查集和堆。
- 并查集
并查集是一种树型的数据结构,通常用来处理不同集合间的元素之间的合并与查找问题。一个并查集支持三个基本功能:合并、查找和判断。举一个通俗的例子,我和lhz认识,lhz和hzc认识,那么也就可以断定我和hzc认识。
依照并查集的思想,我们把所有要待处理的元素a1,a2,a3....an这n个元素都看作是一个单独的集合,初始状态每个集合都只有一个元素。我们就可以把并查集的合并操作理解为集合之间的取并集操作。
作为一个树形结构,在一个由许多这样的集合构成的森林中,每个集合都应该有它自己的「代表元素」,即能够代表一整个集合的所有元素的元素。可以这样理解,在一个地方存在着许多黑恶势力,而每个黑恶势力都有一个自己的头目,这个头目就是集合里的「代表元素」。对于并查集,每个集合选择哪个元素作为代表元素不是我们要关心的问题,但是我们要保证这个代表元素要在集合不发生改变的状态下是不变的,换句话说,不能随便换头目。
那么这应该怎么操作呢?
对于一棵树,我们通常使用一个father数组记录某点的父亲节点,即用father[i]表示第i号点的父亲节点是谁。但是对于并查集,这个father数组则就是用来记录第i个点所在的集合的「代表元素」。
则初始化一个并查集的代码就明确了。
for (int i=1;i<=n;i++) father[i] = i;
下面介绍查找代表元素的find操作。find的作用是查找一个节点x所在集合的代表元素。
int find(int x){ if (father[x] == x) return x; else return find(father[x]); }
它使用了递归。可以想象一个暴力爬树的过程,如果我现在在i号节点,我为了要找到我们这个集合的代表元素,我肯定要沿着我的father向上爬,直到我找到一个元素,它的father是它本身,那么这就一定是那个黑恶势力的头目了。
时间复杂度O(h),h代表这个点距离代表元素的高度。
*路径压缩
我们考虑极端情况。如果有一个集合的某个链非常非常长,而我们要找到这个集合最下边的代表元素的话,是需要O(h)时间的,这个h有可能会非常大,如果再这样使用暴力爬树的方法就会吃到一个TLE。
不怕,我们有路径压缩!
路径压缩的思想非常简单,我在find的时候不是要不断地找祖先吗,那如果所有节点都几乎直接插在代表元素节点不远处,查找的次数不就大大变少了?说的再通俗一点,就是把一条路上的节点的father全部更新成真正的代表元素,而不是合并之前的代表元素,这就相当于是直接把这个地方插到代表元素上,所以就大大减少的查找的递归次数。
还是听不懂?Shut up and take my code!
int find(int x){ if (father[x] == x) return father[x]; father[x] = find(father[x]);//如果当前节点的father并不是代表元素,那就递归地更新老祖宗 return father[x];//返回老祖宗 }
这个函数的时间复杂度是O(α(n)),α(n)代表反阿克曼函数,反阿克曼函数时一个增长非常缓慢的函数,通常来说,反阿克曼函数的最大值不会超过4,所以路径压缩的find完全可以看作是一个O(1)的常数操作。
合并集合:只需要把一个头目变成另一个头目的下属就可以了。
void merge(int x,int y){ x = find(x); y = find(y); father[y] = x; }
判断两个元素是不是在同一集合,只需要问问他们的头目是谁就知道了。
bool check(int x,int y){ x = find(x); y = find(y); if (x==y) return true; else return false; }
很简单吧,是不是?
并查集的应用:kruskal算法求图上的最小生成树。
这里我先卖个关子,等我写到Day3 图论时再去详细介绍这个算法,它并不难理解。
例题:
T1:程序自动分析
luoguP1955.
离散化+并查集可做。所谓离散化就是把输入时相同的内容都去掉,可以用map,hash或者unique函数等等操作……我应该会在后面的一篇随笔上详细的介绍离散化操作,这里只是给一个粗略的思想。对于这个题我们可以用并查集维护所有数之间的相等关系。先处理所有的等式,将等式两边的两个数所在的集合合并在一起。然后我们检查所有的不等式。如果某个不等式两边的数字在一个集合中(被要求相等),则输出NO。不存在这样的情况则输出YES。
用两个并查集分别记录相等和不等关系好像也是可以的。
此外,这道题在Day1上午的模拟赛上也出现了。
T2:Connect
(我在luogu上找不到这题)
给定一个点数为??、边数为??的无向图,请编写一个程序支持以下两种操作:
1、?? ?? ??,在原图中删除连接顶点??和顶点??的边。
2、?? ?? ??,询问??顶点和??顶点是否联通。
??, ?? ≤500,000
看到Q x y这样的操作很显然想到是使用并查集。但是路径压缩之后的就并查集并不支持删除边的操作了,这怎么办呢?
倒着处理就好了嘛! 我们可以建立一个存储“删除”的并查集,既然D x y是把xy这条边删除,那我们就可以把它在“删除”并查集里合并。询问也变成了询问两个点是不是在同一集合中,这样就可以了。
但这样处理需要先存起来再进行,这样的操作我们称之为「离线操作」,就好像是从网上把数据下载下来再从本地进行操作那样。反之我们有「在线操作」,即边读边处理,边读边处理的在线操作基本上不需要数组等开销空间较多的数据结构,所以很多时候在线处理会省一点空间。
具体到某个体是要在线处理还是离线处理,这个不一定。
T3:Cube Stack
待续
T4:食物链
luoguP2024
啊这题太难了QAQ我不会做
待续
2. 堆
堆也是一种特殊的树形数据结构。它的本质是一棵完全二叉树(有时也是一个满二叉树)。
堆有大根堆和小根堆两种形态。大根堆即是对于任意一点,它的值比任何一个下面的点都要大,小根堆则就是值要比任何一个下面的点都要小。
所以:大根堆的根节点一定权值最大,小根堆的根节点一定权值最小
实现方式:手写 or STL的priority_queue
(priority_queue其实叫优先队列,虽然挂着一个队列的名字,但它满足堆的所有性质,我们可以用它来代替手写堆,但在不开-O2优化下速度相对手写堆慢一些)
(具体代码实现请参照一本通 or 课件)
维护(即调整)一个堆的具体思想就是,我每次要删除一个最大/最小的元素,那就要把根节点删除,然后把最后一个点作为新的根节点,但这个点有可能不满足堆的性质,那么我们就把这个不合法的点与它的儿子交换,不断地交换直到成为叶节点或者比儿子小/大。
(如果比两个儿子都小/大,就与最大/小的儿子交换。
维护的必要性:只有所有子树是一个合法的堆,整棵树才是一个合法的堆。
加入新节点与维护操作类似。如果现在堆中共有tot个节点,我们把新的节点放在位置tot+1上。我们不停地比较当前点和父亲,如果当前点比父亲大/小,则交换,直到交换到根或者不再比父亲大/小为止。
应用:堆排序
手写堆的做法:先将整个序列维护成一个堆,然后提取出根节点,输出,并删除。将最后一个点放到根节点的位置,再次维护,要保证维护后,仍然满足堆的性质。重复这一步,直到所有的元素都被删除。
STL的做法:push push push push push 输出top pop 输出top pop……
例题:
T1: 合并果子
luoguP1090
模板题。考虑贪心思想,我们每次取果子合并时应该要取两堆最小的合并,这样不会再有其他合并操作比这个更优。证明从略。
这样我们把序列维护成一个小根堆,每次取前两个节点,将值相加后把结果加入堆,一直这样做直到堆只剩一个节点 ,这个节点的值就是答案。
这是一个叫蛤(哈)夫曼树的经典模型。它的定义是:给定n个权值作叶子结点,构造一棵二叉树,若带权路径长度达到最小,称这样的二叉树为最优二叉树,别名哈夫曼树。