【学习笔记】平衡树

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

替罪羊树

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

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

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

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

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

Splay

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

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

写起来的优点应该就是维护的东西比较少,只有 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] 区间时把 l1 旋到根,r+1 旋到根的右儿子,那么根的左儿子的右儿子就是我们需要操作的区间。可以直接打一个 tag。

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

好吧我还是肤浅了,FHQ 也可以做到维护序列,只要按子树大小分裂一样可以很简单地打上 tag,甚至因为可持久化在维护序列的过程中要优于 splay。

无旋 Treap(FHQ-Treap)

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

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

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

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

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

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

可持久化 Treap

注意到 splay 的复杂度是均摊的,我们没有办法在合理的复杂度内支持可持久化。

但是 FHQ 不一样。由于随机化的优越性每次修改都可以控制在 O(logn),进而支持可持久化,于是我们得到最基本的 可持久化平衡树。注意到每次 split 和 merge 都会导致新建 O(logn) 个节点,而询问修改时我们会调用不止一次 split 和 merge 函数。所以一般来说空间开销会很大。重点是这玩意因为 merge 的时候不会有多余节点导致根本没有办法垃圾回收,因为检测不到哪些点对答案没有影响。硬要加上回收节点看起来就会让代码变长所以只能顶着大空间写(恼。

硬要做其实也能支持 可持久化文艺平衡树,需要在可持久化的过程中注意翻转 tag,在 pushdown 的时候要新建节点,导致空间开销极大。不清楚其空间复杂度上限为多少。但是将平衡树用结构体存起来能够有效地节省空间。

题解区找到的一篇不需要打 tag 的看上去很牛的可持久化文艺平衡树做法,原理是维护正反两棵平衡树然后翻转的时候把两棵平衡树对应的位置交换。理论上看起来很简洁,因为没有那么多的打 tag 而且空间是比较确定的大概十倍常数的 O(qlogn),但是由于本身 FHQ 的常数原因以及两棵 FHQ 带来的更大常数和更多细节实际实现起来可能并不是那么优美。但是改变不了这个想法确实很牛的事实。有时间可以写写。

喵喵喵发现很多情况下题目不会让你合并两个平衡树所以你的 merge 只是过程中的操作根本没必要新建节点,所以空间常数大概小了一点。

哦哦哦你可以直接判断总共节点 id 的数量大于某个阈值或者数组大小的时候直接推掉重构,感性分析重构次数不会太多,因为你每次加的也就几倍 logn 个节点。

有趣的 trick 是可持久化 treap 可以 自己合并自己。注意到这么合并会导致一堆父节点共用了同样的子节点,也就是导致这个 treap 视觉上连树都不是。但是注意到你的复杂度还是对的。因为你实际上把这些粘在一起的子节点视为了多个节点只是没开出来而已,你 split 的时候本身就会新开一个节点作原本的意义,对你的复杂度根本产生不了影响。其实根本原因应该是 fhq 不需要记录与祖先有关的信息导致一个点就算连了多个父亲对应上去多个父亲也会被视作不同的点。反正好强就是了。

然后你在区间复制的时候如果把随机值复制出去显然有神犇能够构造数据卡你复制完形成的树,因为这相当于是假随机。但是你每次新给一个随机值会导致堆的性质根本没有而且你要对复制出来的每一个点都新给一个随机值复杂度直接爆炸。

考虑你直接不要那个随机值了,每次在合并的时候随机把一个当根。注意这里的概率不是 12,而是你把子树 x 拎成根的概率是 szxszx+szy。原因就是我们的随机值是随机给的,所以按理来说每一个点都等可能成为新树的根,于是在 szx+szy 个数中选到子树 x 的点的概率就很显然了。

有旋 Treap

咕咕咕。

lct

咕咕咕。

posted @   Wind_Leaves_ShaDow  阅读(34)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示