【学习笔记】平衡树

bst 容易被卡,是因为很容易构造数据把它卡成一棵不平衡的树使得查询变慢,所以所有平衡树的主要目的都是为了尽量维护 bst 的平衡性。

替罪羊树

个人认为比较暴力的平衡树,但是跑出来的效果很好,板子题总共比 FHQ-Treap,Splay 都快了 4s 左右。

维护 \(ls,rs,va,cnt,sz,rsz\),即左右儿子,代表权值,出现次数,子树大小,子树真正的大小(删除操作是惰性删除,只打 tag,这个也会被记入子树大小,重构时略过就好了)。

很简单,如果有一棵子树不平衡了,就重构这棵子树。

如何重构?一棵树重构时按照中序遍历时中间的端点当根节点的建法是可以建出一棵平衡二叉树。所以重构时把子树按中序遍历拍平保证性质,然后从中间分开重构即可做到 \(O(sz_i)\) 的重构。

如何判断一棵子树是否平衡?替罪羊的精髓在于不需要严格不平衡,定义一个比例系数 \(a\),在一棵子树大小占到父亲子树大小的 \(a\) 倍或者删除的节点占子树大小的 \(a\) 被就可以判断为不平衡了,调小了比较松重构次数太多会超时,调大了比较严格重构次数较少仍然容易被卡,经过前人经验 \(a\) 的范围取 \([0.6,0.8]\) 左右比较合适,板子取的是 \(0.75\)

Splay

注意到 Splay 除了值域查询从始至终跟值域都没有关系,这让我们联想到它能够在序列和点集等处有期望较为优秀的表现。

基本思想是你访问哪个节点就把哪个节点当做根。这种看起来就抽象的算法算下来一次操作复杂度是 均摊 \(O(\log n)\) 的。时间复杂度证明,基于势能的分析,当前没什么用,反正也推不出来。

写起来的优点应该就是维护的东西比较少,只有 \(va,cnt,sz,fa,ch[0],ch[1]\),分别代表权值,出现次数,子树大小,父节点,左右儿子。左右儿子这么写是用来方便判断这个节点是父节点的左儿子还是右儿子用的。

最主要的操作就是把一个点搞到根上去。保证时间复杂度最重要的一步就是不管是什么函数只要有寻找一个节点的行为就要把那个节点旋到根上去,以及在修改结构的时候记得把要维护的东西先维护好。

先把最基本的单次旋转想好。

以左为例,右儿子是一样的。想象一下就是把右儿子断开连到父亲上面,然后父亲成为自己的右儿子,自己再取代父亲的位置。理解起来比较动态化,好玩! 但是代码实现起来比较难受。

然后还有一个函数叫 splay(p,to),也就是把 \(p\) 旋到 \(to\) 的下面,在 Splay 中认为根节点的上面还有一个 \(0\) 节点。这时不能一直单旋,据研究 Splay 的前人说单旋的势能是错误的,我们要在合适的时候使用双旋,不需要了解原因,只要知道是在保证势能维护时间复杂度就好了。双旋其实也很简单,就是如果自己,父亲,父亲的父亲构成一条往同一方向延伸的链,那就要先把父亲旋上去再把自己旋上去。只要会背就行了。

Splay 的很多函数都挺有意思的。比如多了个 pick(x),用处是把值最接近 \(x\) 的节点拉到根,所以查前驱后继的时候就可以把这个值 pick 上来然后就好找了。以及查排名的时候可以 pick 上来然后直接查左儿子以及特判自身就好了。这些东西实现起来挺方便的。但是删除写起来也很难评。这边推荐一个别人写的看起来比较好写的写法。你先把 \(x\) 的前驱拉到根,然后把 \(x\) 的后继拉到根下面,此时根的右儿子的左儿子 \(p\) 代表的值就是 \(x\),当然前提是有这个数,不过这个也好判断。结合 bst 的性质就好理解这句话。找到之后根据对应值的个数决定如何删除。注意到此时 \(p\) 一定是一个叶子节点,所以如果删掉节点直接断掉它与父亲的连接就好。

用 Splay 维护区间操作(区间翻转

让伟大的线段树望尘莫及。

处理 \([l,r]\) 区间时把 \(l-1\) 旋到根,\(r+1\) 旋到根的右儿子,那么根的左儿子的右儿子就是我们需要操作的区间。可以直接打一个 tag。

在序列操作上我们没有必要继续维护 bst 的性质,更加偏向于当做一种线段树使用。比如说在区间翻转里面直接把子树交换就好了。只要你的结构还是 bst 的结构 splay 的复杂度就不会出锅。

无旋 Treap(FHQ-Treap)

\(Treap=Tree+Heap\)。这就是 Treap 的定义。Treap 是一棵笛卡尔树,这里的堆是小根堆。它的复杂度基于第二键值的随机性,如果第二键值随机给出,那么复杂度是 \(O(\log n)\) 的,似乎这个是非均摊的,也就是可支持可持久化操作,但是被卡的概率很大。至于原理仍然未知。

码量相对最小,但是细节不少,容易敲错,记得背住。需要多维护一个 \(rv\) 代表随机权值。同时 FHQ-Treap 不需要将相同权值的点合并。有一个不知道有没有用的性质是如果仅支持板子的操作的话每次查询或修改进行完后 Treap 仅有一个根。

FHQ-Treap 的精髓在于分裂合并操作。

分裂操作将一棵 FHQ-Treap 按照 \(x\),分裂成两棵 FHQ-Treap \(a,b\),其中 \(a\) 中所有数小于 \(x\)\(b\) 中所有数大于等于 \(x\),返回值是两棵树的根。这个很简单,对于当前要分裂的根,如果它小于 \(x\) 就划到 \(a\) 中,然后划分它的右子树,更新它的右子树为返回的 \(a\)。因为所有原本就在它右边的根据性质仍然应该在它右边,然后被分在 \(a\) 这一组,就是它现在的右子树。大于等于 \(x\) 同理推出来。合并操作合并两个 Treap \(a,b\),其中保证 \(a\) 中最大值小于 \(b\) 中最小值。这个也很简单,因为已经满足好了 bst 的性质,就只需要维护好堆的性质。同样分类,如果 \(rv_a<rv_b\),那么按照堆的性质这里摆的应该是 \(a\),然后把 \(rs_a\)\(y\) 合并。同样的 \(rv_a\ge rv_b\) 的情况也能这样推出来。这两个东西的复杂度都是单次 \(O(\log n)\) 的,看起来挺优秀。

其他的很多操作有了分裂合并都变得简单。插入操作可以先新建一个节点,然后把 FHQ-Treap 按照值分裂,然后按照顺序合并回来。删除操作把 FHQ-Treap 分成小于等于大于三棵,把等于那棵的根的左右儿子合并就相当于抹掉了一个权值等于要删去的数的节点。然后按照顺序合并回来就好。查排名的的时候也可以分裂之后查小于那边的树的大小就直接是答案了。注意分裂完之后的合并。

挺好写的,如果不担心被卡一般是选择用 FHQ-Treap 的。

有旋 Treap

咕咕咕。

lct

咕咕咕。

posted @ 2024-07-17 16:22  Wind_Leaves_ShaDow  阅读(19)  评论(0编辑  收藏  举报