数据结构专题-学习笔记:可并堆 - 左偏树
1. 前言
谈可并堆之前,先谈谈堆。
本文所有堆都以小根堆为例。
众所周知堆是一个优秀的数据结构,能够做到 \(O(\log n)\) 查询最小值,插入一个数,弹出堆顶之类的,正常比赛中可以使用 priority_queue
实现。
但是如果现在有多个堆,要求支持合并两个堆,查询堆内最小值,弹出堆顶之类的,你会发现普通堆维护不了了,我们也没办法使用 STL 库了(pbds 除外)
于是我们需要一种数据结构维护一下,然后就有了可并堆的出现。
可并堆有很多种,其中比较著名的几种有左偏树,配对堆等等,这里只讲左偏树(主要是左偏树是这几种里面唯一支持可持久化的)。
进入正题,必要的前置知识:并查集,小根堆。
可以不需要的前置知识:FHQ Treap 的一些思想。
2. 详解
先放例题:P3377 【模板】左偏树(可并堆)
2.1 基本定义与相关性质
首先我们放上一棵左偏树,看一看左偏树具体是长什么样子的。
肯定有人会说:你这棵树根本不像棵左偏树好吗?这哪是左偏树啊?
实际上这确实是一棵左偏树,因为这棵树完全满足左偏树的三个性质。
或者说具有这三个性质的树是左偏树。
性质 1 :每个节点的权值满足堆性质,小根堆大根堆都可以。
显然上面这棵树是小根堆。
讲性质二前先做一个定义:定义一个点 \(x\) 的距离 \(dis\) 为这个节点到其子树内的所有叶子节点中距离最小的值。
这么说可能有点难懂,上图:
于是我们有个性质 2 :对于一个节点 \(x\) 而言,其左儿子的 \(dis\) 一定大于右儿子的 \(dis\),也就是左偏树左偏二字的来源。
注意这个性质是定义型的,也就是这个性质没得证明,就是这么规定的,不满足这个性质的树不是左偏树。
该性质保证了左偏树的左偏性质~
性质 3 :对于一个点 \(x\),记其右儿子为 \(rx\),则 \(dis_x=dis_{rx}+1\)。
这个性质可以用来快速计算 \(dis_x\)。
大致证明就是如果 \(dis_x \neq dis_{rx}+1\),那么就有 \(dis_x=dis_{lx}+1\)(\(lx\) 是 \(x\) 的左儿子),根据 \(dis_x\) 的定义就可以得到 \(dis_{lx}<dis_{rx}\)。
然而根据性质 2,\(dis_{lx}>dis_{rx}\),于是矛盾。
故性质 3 成立。
满足这三个性质的树就是左偏树,前两个性质分别保证了左偏树满足堆性质和左偏性质,性质 3 是性质 2 的推论,可以快速计算 \(dis_x\)。
实际上左偏树还有个性质 4 :\(\max\{dis_i \mid i \in [1,n]\} \leq \log(n+1)-1\)。
这个性质我不会证qwq,但是这个性质保证了左偏树操作的复杂度是 \(O(\log n)\) 级别的。
开头说了,左偏树是个可并堆,可并堆最关键的操作自然就是合并堆操作啦~
当然弹出堆顶操作也是很重要的,不过这个操作其实特别简单。
2.2 Merge 操作
首先放一下我们需要的结构体:
struct node
{
int Son[2], Root, val, dis;
}LeftTree[MAXN];
Son[0/1]
表示这个点的左/右儿子,Root
表示这个节点所在堆的根节点(注意一下这个实际上是个并查集数组),val
是权值,val = -1
代表这个节点已经被删除了,dis
就是 \(dis_x\)。
设当前我们需要合并两个左偏树,堆顶是 \(x,y\),保证 \(val_x<val_y\)(\(val_x\) 是 \(x\) 的权值)(不满足交换就好了)。
有以下步骤:
- 首先将 \(x\) 的右子树和 \(y\) 继续合并。
- 然后如果 \(dis_{lx}>dis_{rx}\),说明违反了性质 2,交换左右子树。
- 然后更新 \(dis_{x}=dis_{rx}+1\)。
- 最后不要忘记更新 \(Root_{lx}=Root_{rx}=Root_x=x\),由于这是个并查集数组所以维护是没问题的。
当然 Merge 操作需要知道两个数的堆顶,所以我们需要 \(Root\)(貌似有不用 \(Root\) 的写法?),但是这个 \(Root\) 实际上是个并查集,跟左偏树是区别开来的,因此我们可以采用路径压缩。
这一部分的代码如下:
int gf(int x) { return (LeftTree[x].Root == x) ? x : (LeftTree[x].Root = gf(LeftTree[x].Root)); }
int Merge(int x, int y)
{
if (!x || !y) return x + y;
if (LeftTree[x].val > LeftTree[y].val || (LeftTree[x].val == LeftTree[y].val && x > y)) std::swap(x, y);
LeftTree[x].Son[1] = Merge(LeftTree[x].Son[1], y);
if (LeftTree[LeftTree[x].Son[0]].dis < LeftTree[LeftTree[x].Son[1]].dis) std::swap(LeftTree[x].Son[0], LeftTree[x].Son[1]);
LeftTree[x].dis = LeftTree[LeftTree[x].Son[1]].dis + 1;
LeftTree[LeftTree[x].Son[0]].Root = LeftTree[LeftTree[x].Son[1]].Root = LeftTree[x].Root = x; return x;
}
2.3 Pop 操作
删除操作其实特别的简单,只需要找到这个数所在堆的堆顶,然后把这个点的 \(val\) 改成 -1,然后重新合并两棵子树就好了。
需要注意这个数的堆顶需要重置成合并之后新数的堆顶,因为有些点的根节点是连接到这个点的,如果不更新就会出现事故。
这一部分的代码如下:
void Delete(int x)
{
LeftTree[x].val = -1;
LeftTree[LeftTree[x].Son[0]].Root = LeftTree[x].Son[0];
LeftTree[LeftTree[x].Son[1]].Root = LeftTree[x].Son[1];
LeftTree[x].Root = Merge(LeftTree[x].Son[0], LeftTree[x].Son[1]);
}
2.4 代码
合并两个数就是将这两个数看成两个只有一个点的堆合并即可,弹出就直接弹出就好了。
例题代码不贴了,要的人参考 GitHub CodeBase-of-Plozia P3377 【模板】左偏树(可并堆).cpp。
3. 总结
左偏树的四个性质:
- 性质 1 :\(\forall x \in [1,n],val_{x}<val_{lx},val_x<val_{rx}\)(堆性质)。
- 性质 2 :\(\forall x \in[1,n],dis_{lx}>dis_{rx}\)(左偏性质)。
- 性质 3 :\(\forall x \in[1,n],dis_x=dis_{rx}+1\)(推论)。
- 性质 4 :\(\max\{dis_i \mid i \in [1,n]\} \leq \log(n+1)-1\)(复杂度证明)。
两个关键操作:Merge 操作,Pop 操作。
实际上写题时你会发现学会这两个操作基本上就能实现左偏树的所有操作了,跟 FHQ Treap 的 Split,Merge 操作的关系差不多qwq