更多文章可以在本人的个人小站:https://kaiserwilheim.github.io 查看。
转载请注明出处。
热身
平衡树一般都是一个二叉搜索树,其满足中序遍历得到的序列就是我们需要维护的原序列。
当然,二叉搜索树可以不平衡,这样就可以构造一个特殊的数据使之退化成为一条链。
那我们怎么定义一棵二叉搜索树“不平衡”呢?
这里需要引入一个概念:平衡指数 α \alpha α。
一棵二叉搜索树的平衡常数等于其子节点大小与其大小的比值。
这里取的是最大值。
平衡常数 α \alpha α 的取值是 α ∈ [ 0.5 , 1 ] \alpha \in [ 0.5 , 1 ] α∈[0.5,1]。
其两个边界代表了两个极端情况:
当 α = 1 \alpha = 1 α=1 时,我们不管怎样建造搜索树都会被认为是平衡的,因为其子节点的子树大小永远不可能超过其本身的子树大小。
当 α = 0.5 \alpha = 0.5 α=0.5 时,我们每一个节点的子节点的子树大小必须恰好是其本身的子树大小的一半。
AVL树就在尽力维持这样的平衡,这就导致其代码十分冗长,没有能在OI上有太多实际的应用。
在这里有一个AVL树的可视化。
红黑树比较特殊,通过放宽一些过于严苛的要求,其追求的是 α = 2 3 \alpha = \frac{2}{3} α=32,同时降低了常数和代码长度。
在这里有一个红黑树的可视化。
其他的平衡树都是通过一些思想来维持 α \alpha α 的尽量低。
基于这里的数据,我们可以大概得知不同平衡树的 α \alpha α 大小:
1 | 2 | 3 | 4 | 5 | 平均 | |
---|---|---|---|---|---|---|
Splay | 0.758 | 0.588 | 0.582 | 0.612 | 0.759 | 0.659 |
Treap | 0.766 | 0.578 | 0.601 | 0.587 | 0.781 | 0.662 |
FHQ-Treap | 0.914 | 0.860 | 0.613 | 0.678 | 0.803 | 0.773 |
可见,一般的平衡树都能将 α \alpha α 维持到 0.6 到 0.8 范围内。
替罪羊树
替罪羊树最大的特点就是暴力。
怎么暴力呢?
替罪羊树会将不平衡的子树进行重构来保证其平衡。
而其判断子树平衡与否就是根据刚才讲的平衡因数 α \alpha α,只不过这里是人为设定的,称之为平衡常数。
思想
暴力重构
替罪羊树之所以能够平衡,是在于其重构时不是瞎重构,而是将被重构的子树重构为一棵完全二叉树。
当然我们都知道这样费时又费力,更何况还是暴力重构的。
所以我们认为设定的平衡常数 α \alpha α 在此时就起到了决定性的作用。
当其值合适的时候,我们就可以将所有的时间复杂度均摊到一个 O ( log n ) O(\log n) O(logn) 的水平。
具体如何暴力重构就不用太多赘述了,我们可以使用简单的方法来保证线性建树,然后将新建的树接过来即可。
查询
替罪羊树的查询与其他二叉搜索树一样,并且因为其没有对树进行修改,还不会导致产生重构操作,所以最终时间复杂度为 O ( log n ) O(\log n) O(logn)。
插入
替罪羊树的插入操作与其他的二叉搜索树差不多。只不过因为其导致了树形态的改变,我们在插入完回溯的过程中还需要判断一下是否需要重构。
当然,还会有一条链上多棵子树不平衡的情况。
我们可以将最大的子树重构,但是这样在实际写代码的时候会略显复杂。
如果你真的很懒的话,只需要在回溯的时候找到第一棵不平衡的树重构即可,并且据说这个样子对于时间复杂度的影响不会很大。
删除
替罪羊树使用惰性删除,只需要将对应节点上代表节点内数据数量的标记自减一即可。
对于一个节点内数据数量为0的点,我们会忽略对其的任何操作,并在下一次重构时将其丢弃掉,除非再有插入操作将其插入回去。
实现
节点
替罪羊树的一个节点内需要存储很多信息。
struct Scapegoat
{
int ls, rs;
int w, wn;
int s, sz, sd;
}tr[N];
解释一下:
ls
&rs
:左右儿子。w
:节点权值。wn
:节点内数据数量。s
:子树内节点个数。sz
:子树内数据个数。sd
:子树内不计删除节点的节点个数。
我们这样来(重新)计算当前节点的子树大小:
void calc(int p)
{
tr[p].s = tr[tr[p].ls].s + tr[tr[p].rs].s + 1;
tr[p].sz = tr[tr[p].ls].sz + tr[tr[p].rs].sz + tr[p].wn;
tr[p].sd = tr[tr[p].ls].sd + tr[tr[p].rs].sd + (tr[p].wn != 0);
}
重构
我们重构分两种情况:一是子树不平衡了,即左右子树之一的大小占其本身子树大小的比例超过 α \alpha α;二是被删除的节点太多了,这样也会影响效率。
首先我们需要判断是否需要重构:
bool canrbu(int p)
{
if(!tr[p]