平衡树

平衡树就是为了实现一类元素在线性结构中动态变化的功能所需要的数据结构。

平衡树是一种基于二叉搜索树的数据结构。
满足:左儿子 \(<\)\(<\) 右儿子。
也就是一切小于根节点的在左边,一切大于根节点的在右边。
这样想要查找一个节点的位置时间复杂度就是 \(O(\log n)\)

平衡树主要有三种:Splay,fhq_Treap,Treap。
我这次主要讲前两种。
当然还有其他的像替罪羊树,红黑色。

Splay

Splay 是 LCT 的基础操作。
本人以为还是 Splay 比较好理解的。

核心操作

Splay 的基本操作就是将BST旋转,分左旋和右旋(其实都差不多)
旋转的要求:在不改变原有的中序遍历的前提下改变树的结构。
简化一下:把儿子节点与父亲节点的身份互换,且有BST性质。

旋转 \(zig(x)\),\(zag(x)\) 如下图:

具体描述

设要旋转的节点为 \(x\),它的父亲为 \(y\)\(y\) 的父亲为 \(z\)

  • \(y\) 的左儿子设为 \(x\) 的右儿子
  • \(x\) 的右儿子存在,将 \(x\) 的右儿子的父亲设为 \(y\)
  • \(x\) 的右儿子设为 \(y\)
  • \(y\) 的父亲设为 \(x\)
  • \(x\) 的父亲设为 \(z\)
  • \(z\) 存在,将 \(z\) 的某个子节点(原来 \(y\) 所在的子节点)设为 \(x\)

双旋操作

在 Splay 中,每加入一个新的节点就需要把它旋转到根。
设当前需旋转的节点为 \(x\),节点的旋转可分为以下三种:
\(1.\)\(x\) 的父亲是根,这时直接旋转即可。
\(2.\)父亲和 \(x\) 的儿子同侧(即同为左儿子或同为右儿子),这时先旋转父亲,再旋转 \(x\)
\(3.\)父亲和 \(x\) 的儿子不同侧,这时将 \(x\) 旋转两次。
如下图:

时间复杂度

对于这个时间复杂度的分析,我们需要用一下势能分析
\(siz(x)\) 表示以 \(x\) 为根节点的子树大小。
\(\phi(x)\) 表示在进行 \(x\) 次操作后的势能函数。
\(c(x)\) 表示时间的时间复杂度。
\(a(x)\) 表示均摊的时间复杂度。
所以根据定义,我们可以得出:
\(\phi(x)=\log(siz(x))\)
\(a(x)=c(x)+\phi(x)-\phi(x-1)\)
\(\sum a(x)=\phi(n)-\phi(0)+\sum c(x)\)
因为根据 \(\phi(x)=\log(siz(x))\),所以现在肯定有:\(|\phi(n)-\phi(0)|\le n\log n\)
考虑每一次的 \(\Delta\phi\)

对于zig:\[\Delta\phi=\phi'(p)-\phi'(p)+\phi'(x)-\phi(x)=\phi'(p)-\phi(x)\le\phi'(x)-\phi(x)\]

对于zig-zig\[\Delta\phi=\phi'(z)+\phi'(y)+\phi'(x)-\phi(z)-\phi(y)-\phi(x)\]\[=\phi'(z)-\phi'(y)-\phi(y)-\phi(x)\]\[\le\phi'(x)+\phi'(z)-2\phi(x)\]\[\le 3\phi'(x)-3\phi(x)+(\phi(x)+\phi'(z)-2\phi'(x))\]\[\le 3(\phi'(x)-\phi(x))-2k\]

那么Splay到根的均摊代价为 \(O(logn)\)\(zig\) 只有一次操作,所以只会使均摊代价增加\(k\)
再算上 \(\phi(n)-\phi(0)=n\log n\)
所以总时间复杂度为 \(O(n\log n)\)

操作实现

讲了核心操作和时间复杂度后,我们来看看它可以支持的操作。
注:由于我们只能保证 Splay 本身的时间复杂度,所以我们就必须只能通过旋转来实现一些操作。

查询数值

给定一个数 \(k\),查询排名为 \(k\) 的数。

  • \(k\) 小于等于左子树大小,就向左子树走
  • 否则,将 \(k\) 先减去左子树大小和当前节点的 \(cnt\),使得 \(k\), 等于在右子树中的排名。然而若 \(k\) 小于等于 \(0\),说明已经找到,进行旋转,并返回当前节点权值。

查询 \(x\) 的前驱和后继

我们先将 \(x\) 节点旋转到根节点。

  • 前驱:就是 \(x\) 左子树中最右边的节点。
  • 后继:就是 \(x\) 右子树中最左边的节点。

删除节点 \(x\)

我们需要先将 \(x\) 旋转到根节点,从而去得到它的前驱和后继。
然后将前驱旋转到根,再将后继旋转到根,就会得到下图:

直接把 \(x\) 删去即可。

查询区间 \(\left[l,r\right]\)

我们需要将 \(l\) 的前驱旋转到根,再将 \(r\) 的后继旋转到根节点,点像删除操作。
\(l\) 前驱的右子树就是整个 \([l,r]\) 区间了。

fhq_Treap(无旋Treap)前言

首先,我们要 \(sto\) 范浩强 \(orz\)
dalao 发明了 fhq_Treap,因为 fhq_Treap 是三种平衡树中最强悍的一种了,它可以维护值域,可以维护下标,可以维护区间修改,它还可以完成可持久化操作。其唯一弱于 splay 的就是在维护 LCT 上。

什么是 fhq_Treap

fhp_Treap 首先是一个 Treap。
而 Treap 是什么呢
Treap=BST+Heap。
所以 Treap 就是一个拥有二叉搜索树的性质,但是每一个节点都通过一个附加权值来满足符合堆的性质。
而这个附加权值就是 Treap 的一个关键,它是通过随机生成一个 \(key\) 来维护一个近似的平衡。
而我们随机生成的 \(key\) 要想让它退化成链的概率是微乎其微的。(教练:你让随机 \(key\) 退化成链的概率就像你出门被核弹直接创死的概率一样小。)
但是一个旋转的 Treap 是有点恶心的。
所以,我们的无旋 Treap 就登场了。

核心操作

我们要想 Treap 不旋转,我们就需要一个可以顶替旋转的一种操作来实现,那就是拆分与合并。

split

split 就是把 Treap 以 \(k\) 值分为两棵 Treap。
对于我们遍历到每一个点,假如它的权值小于 \(k\),那么它的左子树,就要分到左子树里,然后再遍历它的右儿子,反之亦然。
因为它的最多操作次数就是一直分到底,时间复杂度就是 \(O(\log n)\)
对于前 \(k\) 个版的,就像找第 \(k\) 大的感觉。每次减掉 \(siz\)
这个用递归就可以实现了。

merge

merge 就是把被分开的两颗 Treap 合并起来。
因为第一个 Treap 的权值都比较小,我们比较一下它的 \(kay\),假如第一个的 \(key\) 小,我们就可以直接保留它的所有左子树,再把第二个 Treap 变成它的右儿子,反之亦然。
也就是说,我们其实是遍历了第一个 Treap 的根定为最大节点,第二个 Treap 的根就是最小节点,时间复杂度就是 \(O(\log n)\) 了。

操作实现插入数值

插入一个权值为 \(v\) 的点。

  • 把 Treap 以权值 \(v\) 分成两颗。
  • 将权值 \(v\) 插入。
  • 再把两颗 Treap 合并以来。

删除数值

删除一个权值为 \(v\) 的点,很类似于插入操作。

  • 把 Treap 以权值 \(v\) 分成两颗。
  • 将权值 \(v\) 删去。
  • 再把两颗 Treap 合并以来。

查询指定值的排名

如果是在一个有序的序列中查询排名,我们可以二分查找这个序列,然后根据找到的元素的下标来确定排名,假设下标从 \(1\) 开始,那么排名就为该元素的下标 \(i\)。那么,在它之前,也就有 \(i−1\) 个元素。由此,我们可以得到排名的一种定义:在有序序列中,一个元素的排名就是它前面的元素的个数 \(+1\)
在 fhq_Treap 上,我们就直接按 \(key−1\) 分裂树,查一下值小于等于 \(key−1\) 的树的大小,再 \(+1\) 即可。

总结

平衡树大体就利用 BST 性质,通过一些如旋转,分裂合并的操作来实现将我们需要进行修改的节点或子树独立出来,在进行修改。