平衡树

平衡树

二叉搜索树

二叉搜索树是一种二叉树形的数据结构,各节点权值按中序遍历排列单调不增(或不降)。

中序遍历:先左再中后右

二叉搜索树上的基本操作所花费的时间与树的高度成正比。对于一个有 $n$​个结点的二叉搜索树中,这些操作的最优时间复杂度为 $O(\log{n})$​,最坏为 $O(n)$​。随机构造一棵二叉搜索树的期望高度为 $O(\log{n})$​​

二叉搜索树其实是一个暴力版本的平衡树,平衡树在二叉搜索树上通过一些算法,将树高严格或期望维持在$\log{n}$​​,但相差不会太大,保证不会退化成一条链。

各家的平衡树

平衡树按照应用可以分为两类:

  • 权值平衡树:维护一个有序可重集合(按权值排序,中序遍历为已排序的集合);
  • 序列平衡树:维护一个序列(按原始下标排序,中序遍历即为序列),并不是所有平衡树都能够用作序列平衡树

注意,并不是所有家的平衡树都能作为以上任意一种平衡树使用。

按照平衡树高度又可以分为两类:

  • 强平衡树:树高严格为$O(\log{n})$
  • 弱平衡树:树高期望为$O(\log{n})$

常见的平衡树有:

  • Splay—伸展树
  • Treap—树堆
    • 旋转式Treap
    • 分裂式Treap(又称fhq-treap,因为是fhq发明的)
  • AVL树
  • SgT—替罪羊树(比较有意思,发现树高不为$\log{n}$​时,整个推倒重新建树,复杂度竟然也不高!?)
  • RBT—红黑树(最快的,没有之一,C++ STL都是这个,yyds!)
  • …………

OI中较常用的是 Splay 和 fhq-treap(剩下的性价比不高,要就不是难写,要就不是即难写还慢)。

所以,你的推是哪个?

fhq-treap平衡树

这东西以一种不可思议的方式运行着。具体来说,就是一言不合,就把你劈成两半……怕了怕了

treap 这个单词是由 tree 和 heap 组合而来,这表明 Treap 是一种由组合形成的数据结构。

高级之处

  1. 代码短,比线段树长不了多少。

  2. 思想简单易懂,导致的就是Debug时间减少。

  3. 两种平衡树均可使用。

  4. 可支持持久化。

准备工作

先来康康需要哪些变量:
  1. son数组:记录每个节点的儿子节点的编号 son [i] [0] 表示 $i$​ 号节点左儿子的编号,son [i] [1] 为右儿子的编号。
  2. tot:记录总结点个数,声明一个新节点时会用到。
  3. root:记录根节点编号。
  4. size数组:size[i]标记以 $i$ 节点为根的子树大小(包括 $i$ 号节点本身)。
  5. val数组:记录每个节点的权值。
  6. rnd数组:记录每个节点的随机优先级。fhq-treap 的每个结点上具有一个$priority$值 ,fhq-treap 除了要满足中序遍历有序的性质之外,还需满足父节点的 $priority$(随便一种关系,如大于、小于等于……)两个儿子的$priority$。在一般的实现中, $priority$是每个结点建立时随机生成的,因此 fhq-treap 是期望平衡的。

最基本的平衡树的空间复杂度为 $O(5n)$​,当然也可以动态优化,但并不能起到较为显著的效果(每个节点都有可能被访问)。

宏定义

有效降低代码长度、出错概率以及Debug时间。

#define Ls son[pos][0]
#define Rs son[pos][1]
build_new_node函数

声明一个新的节点

inline int nnd(int x)  //声明一个新的节点并分配priority值
{
    size[++tot] = 1;
    val[tot] = x; //注意,维护有序队列时,应把x替换成tot,想清楚要维护的是什么
    rnd[tot] = rand();
    return tot;
}
update函数

更新当前节点的size信息

inline void upd(int pos) //更新节点size大小
{
    size[pos] = size[Rs] + size[Ls] + 1;
}

核心操作——分裂与合并

分裂

一共两种分裂方法,适用于不同场景。(作为区间平衡树时只有按节点数分裂一种).

分裂基本原理为每次判断当前根节点将被分到左右哪侧,再递归分裂根节点位于另一侧的子树,将分裂结果的同侧部分接在原位。

  • 按权值分裂:

    • 按照设定的key值为分裂条件,将小于等于key的节点挂在左边,大于key的挂在右边。

    • inline void spv(int pos, int key, int &l, int &r) //按照权值分裂,pos当前节点编号,key分裂值,&l左侧挂载位置,&r右侧挂载位置
      {
          if (pos == 0) //没有叶子节点,结束递归
          {
              l = r = 0; //没有叶子节点,结束递归,并将挂载点赋值0(空)
              return;
          }
          if (val[pos] <= key) //如果当前节点小于等于key,则将左子树(都小于当前节点,所以都小于key值)和当前节点挂在左侧,继续分裂右子树
          {
              l = pos;             //挂在上次传递的挂载点位置
              spv(Rs, key, Rs, r); //递归分裂右子树,因为右子树中所有值均大于当前节点,因此如果仍有小于key值的应挂在当前节点的右儿子的地方
          }
          else //如果当前节点大于key,则将右子树(都大于当前节点,所以都大于key值)和当前节点挂在右侧,继续分裂左子树
          {
              r = pos;             //挂在上次传递的挂载点位置
              spv(Ls, key, l, Ls); //递归分裂左子树,因为左子树中所有值均小于当前节点,因此如果仍有大于key值的应挂在当前节点的左儿子的地方
          }
          upd(pos); //更新节点size大小(因为分裂开后,子树大小有改变)
      }
      
  • 按节点个数分裂:

    • 按照设定的key值,在左侧挂key个最小的节点,其余的挂在右侧。

```c++
inline void spn(int pos, int key, int &l, int &r) //按照节点个数分裂,pos当前节点编号,key分裂值,&l左侧挂载位置,&r右侧挂载位置
{
    if (pos == 0) //没有叶子节点,结束递归,并将挂载点赋值0(空)
    {
        l = r = 0;
        return;
    }
    if (size[Ls] + 1 <= key) //如果左子树和当前节点个数之和小于等于key,则挂在左侧,继续在右子树中选key-size[Ls]-1个加入左侧
    {
        l = pos;                            //挂在上次传递的挂载点位置
        spn(Rs, key - size[Ls] - 1, Rs, r); //递归分裂右子树,因为已经在左侧又挂了size[Ls]+1个节点,所以要在右子树中再选key-size[Ls]-1个即可
    }
    else //如果左子树和当前节点个数之和大于key,不满足条件,继续在右子树查找size合适的子树
    {
        r = pos;             //挂在上次传递的挂载点位置
        spn(Ls, key, l, Ls); //递归分裂左子树,因为并没有在左侧挂任何节点,因此key不改变
    }
    upd(pos); //更新节点size大小(因为分裂开后,子树大小有改变)
}
```

合并

合并是分裂的逆操作,基本原理为按照优先级判断哪棵树的根将成为新树的根,之后递归地合并另一棵树与当前树的对应子树。

需要注意的是:fhq-treap 的合并操作相当于只是简单拼接两棵树的中序遍历,不会检查权值是否有序(理论上如果操作正确的话,分裂出的子树均为有序),如果被合并的两棵树权值大小关系有误,将导致之后按权值分裂是出现不可预期的结果!

inline int tog(int l, int r) //合并两棵树,l左侧树的根节点,r右侧树的根节点;递归思想
{
    if (l == 0 || r == 0) //如果任意一个树是空的,剩余的树的根节点就是挂载点
        return l + r;
    if (rnd[l] < rnd[r]) //优先级定义,可以随便写
    {
        son[l][1] = tog(son[l][1], r); //右侧树挂载左侧树的右侧
        upd(l);                        //更新左侧树根节点的信息
        return l;                      //返回新树的根节点
    }
    son[r][0] = tog(l, son[r][0]); //左侧树挂载右侧树的左侧
    upd(r);                        //更新右侧树根节点的信息
    return r;                      //返回新树的根节点
}

各种操作

总之就是使用上面三种核心操作,实现的一系列qwq的实际操作。

感受fhq脑洞之大吧

inside插入
inline void ins(int v)
{
    spv(root, v, ll, rr);            //按照v分割成根节点为ll和rr两棵子树
    root = tog(tog(ll, nnd(v)), rr); //先合并ll和新加入的节点,再和rr合并,然后更新总树的根节点
}
delete删除

若有多个相同的数,只删除一个。

inline void del(int val)
{
    spv(root, val, ll, rr);        //按照v分割成根节点为ll和rr两棵子树
    spn(ll, size[ll] - 1, ll, LL); //再在左侧分割出1个节点挂在右侧
    root = tog(ll, rr);            //合并新ll和rr,不合并第二次分裂的右侧1个节点,即删除,然后更新总树的根节点
}
order-value查询key值的排名

排名定义为比当前数小的数的个数 $+1$ 。

inline int ordv(int v)
{
    spv(root, v - 1, ll, rr); //将所有小于v的数分割
    int r = size[ll];         //记录根节点size,即所有小于v的节点的数量,后续合并时可能变化
    root = tog(ll, rr);       //合并分裂开的两棵树
    return r + 1;             //返回注意+1
}
order-num查询第key个数的值
inline int ordn(int k)
{
    spn(root, k, ll, rr);        //分裂出前k个节点
    spn(ll, k - 1, ll, RR);      //再分裂出前k-1个节点,这样右侧就有一个排名为k的节点,其下标为RR
    root = tog(tog(ll, RR), rr); //合并分裂开的两棵树
    return val[RR];              //返回编号为RR的节点的权值
}
fir查询前驱(前驱定义为小于x,且最大的数)
inline int fit(int v)
{
    spv(root, v - 1, ll, rr);      //先分裂出所有小于v的节点
    spn(ll, size[ll] - 1, ll, RR); //再在小于v的节点中分裂出最大的一个,因为ll是根节点,size[ll]即为小于v的节点数量
    root = tog(tog(ll, RR), rr);   //合并分裂开的ll,前缀和rr
    return val[RR];                //返回前驱的值
}
back查询后继(后继定义为大于x,且最小的数)
inline int back(int v)
{
    spv(root, v, ll, rr);        //先分裂出所有小于等于v的节点
    spn(rr, 1, LL, rr);          //再在大于v的节点中分裂出最小的一个
    root = tog(tog(ll, LL), rr); //合并分裂开的ll,后缀和rr
    return val[LL];              //返回后缀的值
}

“懒”标记

在某些时候,当实现一个对于区间的操作时,也可以在平衡树上使用“懒”标记以达到降低时间复杂度的目的。

线段树一样,也应该注意标记的下放和优先级关系。

上一页
下一页