leafy tree和WBLT 学习笔记

· · 个人记录

我最早接触leafy应该是在18年寒假的luogu省选营,但当时太菜了什么都没听懂.

orz lxl独立想出来这玩意

因为太菜了不擅长数据结构复杂度分析,以下没有复杂度证明

Leafy Tree

leafy tree是一棵满二叉树(这里满二叉树指的是任何非叶节点都有两个儿子的二叉树),叶子节点存储真实的信息,非叶节点的权值等于其右儿子的权值,满足二叉搜索树和堆性质,可以O(1)取单侧最值.一个例子如下:

这种结构的缺点是空间为两倍,优点是和线段树相似,可以很方便地合并信息.

以下将使用leafy来实现二叉查找树的基本功能.

节点信息

一个节点的信息包括左右儿子(ch[2])、子树内叶子节点数量(size)、权值(w).

维护子树大小不仅是为了查询,后面将会看到这个可以用来维持平衡.

struct Node
{
    int w,size,ch[2];
};
int newnode(){return poolsize?pool[poolsize--]:++node_cnt;}//pool是垃圾回收栈
int newnode(int x)//新开一个权值为x的叶子
{
    int u=newnode();
    a[u].ch[0]=a[u].ch[1]=0,a[u].w=x,a[u].size=1;
    return u;
}

合并信息(pushup)

就直接按照之前提到的,w=right.w,size=left.size+right.size

void pushup(int u)
{
    a[u].size=a[a[u].ch[0]].size+a[a[u].ch[1]].size;
    a[u].w=a[a[u].ch[1]].w;
}

对于区间问题也可以像线段树一样合并.

合并两个节点(merge)

这是leafy很特殊的一点,有了这个功能之后可以很方便地进行操作.

具体地,合并两个节点x,y就只需要新开一个节点,将其左右儿子分别设为x,y,然后pushup一下即可.

int merge(int x,int y)
{
    int u=newnode();
    a[u].ch[0]=x,a[u].ch[1]=y;
    pushup(u);return u;
}

但是注意这个功能不能滥用,需要垃圾回收.下面将会看到如果不进行垃圾回收相当于进行了可持久化.

插入(ins)

从根开始向下走,按照权值大小决定走哪个儿子.走到叶子之后建新节点并和原来的叶子合并.

void ins(int &u,int x)
{
    if(!u){u=newnode(x);return;}
    if(a[u].size==1)
    {
        u=x>a[u].w?merge(u,newnode(x)):merge(newnode(x),u);
        return;
    }
    int d=x>a[a[u].ch[0]].w;
    ins(a[u].ch[d],x),pushup(u),maintain(u);
}   

maintain函数是用来维持平衡的,下面会提到.

删除(del)

从根开始向下走,走到一个两个儿子都是叶子的节点,将这个节点设为其未被删除的叶子并回收原来的这个节点以及被删除的叶子.

注意进行垃圾回收

void del(int &u,int x)
{
    if(a[u].size==1){recycle(u),u=0;return;}
    int d=x>a[a[u].ch[0]].w;
    if(a[a[u].ch[d]].size==1)
    {
        recycle(a[u].ch[d]),recycle(u),u=a[u].ch[d^1];
        return;
    }
    del(a[u].ch[d],x),pushup(u),maintain(u);
}

维持平衡(maintain)

注意插入删除可能会导致不平衡,从而影响复杂度.需要进行一些维持平衡的操作.可以像替罪羊一样重构,也可以旋转.这里采用的是旋转.

单旋

和普通的平衡树没有什么区别.不过leafy的特殊性使得旋转可以很方便地进行.设置一个阈值\alpha,当一个儿子的size小于\alpha\cdot \text{该子树size}时进行单旋.

下面是一个单旋维持平衡的代码.复杂度不正确但是很难卡经过最新测试没有双旋跑得快

void maintain(int u)
{
    if(a[a[u].ch[0]].size<a[u].size*alpha)
    {
        a[u].ch[0]=merge(a[u].ch[0],a[a[u].ch[1]].ch[0]);
        recycle(a[u].ch[1]),a[u].ch[1]=a[a[u].ch[1]].ch[1];
        pushup(u);
        return;
    }
    if(a[a[u].ch[1]].size<a[u].size*alpha)
    {
        a[u].ch[1]=merge(a[a[u].ch[0]].ch[1],a[u].ch[1]);
        recycle(a[u].ch[0]);a[u].ch[0]=a[a[u].ch[0]].ch[0];
        pushup(u);return;
    }
}

双旋

就是转两次Z.

具体地,当Z.size>=Y.size\times \frac{1-2\alpha}{1-\alpha}时进行一次双旋,否则进行一次单旋.画一下图感受一下,这个东西确实可以使树变得更加平衡.

以下是双旋维护平衡的代码.

void rotate(int u,int d)
{
    if(d)
    {
        a[u].ch[0]=merge(a[u].ch[0],a[a[u].ch[1]].ch[0]);
        recycle(a[u].ch[1]);a[u].ch[1]=a[a[u].ch[1]].ch[1];
        pushup(u);return;
    }
    else
    {
        a[u].ch[1]=merge(a[a[u].ch[0]].ch[1],a[u].ch[1]);
        recycle(a[u].ch[0]);a[u].ch[0]=a[a[u].ch[0]].ch[0];
        pushup(u);
    }
}
void maintain(int u)
{
    int d;
    if(a[a[u].ch[0]].size<a[u].size*alpha)d=1;
    else if(a[a[u].ch[1]].size<a[u].size*alpha)d=0;
    else return;
    if(a[a[a[u].ch[d]].ch[d^1]].size>=a[a[u].ch[d]].size*aalpha)
        rotate(a[u].ch[d],d^1);
    rotate(u,d);
}

普通平衡树基本操作

包括查某个数的排名、查排名为k的数、查前驱后继.

```cpp int getrk(int u,int k) { if(a[u].size==1)return k>a[u].w; if(k>a[a[u].ch[0]].w)return a[a[u].ch[0]].size+getrk(a[u].ch[1],k); return getrk(a[u].ch[0],k); } int findkth(int u,int k) { if(a[u].size==1)return a[u].w; if(k>a[a[u].ch[0]].size)return findkth(a[u].ch[1],k-a[a[u].ch[0]].size); return findkth(a[u].ch[0],k); } int prep(int u,int k) { if(a[u].size==1)return k>a[u].w?a[u].w:-2147483647; if(k>a[a[u].ch[0]].w)return max(a[a[u].ch[0]].w,prep(a[u].ch[1],k)); return prep(a[u].ch[0],k); } int succ(int u,int k) { if(a[u].size==1)return k<a[u].w?a[u].w:2147483647; if(k>=a[a[u].ch[0]].w)return succ(a[u].ch[1],k); return succ(a[u].ch[0],k); } ``` 使用[普通平衡树](https://www.luogu.org/problem/P3369)进行测试,速度略快于旋转$Treap$,效率很高. ---- 以下内容针对区间操作进行,会有pushdown ### 合并(merges) 合并指的是将两棵树$X,Y$合并成一棵树,其中$X$中任意节点的权值不大于$Y$中任意节点的权值. 最睿智的写法长这样: ```cpp int merges(int x,int y) { if(!x||!y)return x+y; return merge(x,y); } ``` ~~然而令人惊讶的是,这也是跑的最快的写法~~ 上面的代码复杂度极不正确,然而目前从来没被卡过并且跑得极快. lxl的写法长这样: ```cpp int merges(int x,int y) { if(!x||!y)return x|y; if(a[x].size<alpha*(a[x].size+a[y].size)) { pushdown(y),a[y].ch[0]=merges(x,a[y].ch[0]); pushup(y);return y; } if(a[y].size<alpha*(a[x].size+a[y].size)) { pushdown(x),a[x].ch[1]=merges(a[x].ch[1],y); pushup(x);return x; } return merge(x,y); } ``` 这个复杂度也是不正确的,但是至少看起来有道理了不少. 以下注意区分$merge$和合并,$merge$是刚才的函数,合并是现在在做的这个过程($merges$). 正确的做法如下:不失一般性,设$X.size\geq Y.size$,如果$Y.size\geq \alpha(X.size+Y.size)$,那么可以直接$merge$起来;否则,如果$X.left.size\geq \alpha(X.size+Y.size)$,那么可以递归合并$X.right$和$Y$,再把得到的结果和$X.left$合并;否咋,递归合并$X.left$和$X.right.left$,递归合并$X.right.right$和$Y$,再把这两次得到的结果$merge$起来.通过某种神奇的证明,这样合并的复杂度是$O(\log \frac{X.size}{Y.size})$的.$X.size<Y.size$的同理. 代码如下: ```cpp int merges(int x,int y) { if(!x||!y)return x|y; if(min(a[x].size,a[y].size)>=alpha*(a[x].size+a[y].size))return merge(x,y); if(a[x].size>=a[y].size) { pushdown(x); if(a[a[x].ch[0]].size>=alpha*(a[x].size+a[y].size)) { a[x].ch[1]=merges(a[x].ch[1],y); pushup(x);return x; } pushdown(a[x].ch[1]); a[x].ch[0]=merges(a[x].ch[0],a[a[x].ch[1]].ch[0]); a[x].ch[1]=merges(a[a[x].ch[1]].ch[1],y); pushup(x);return x; } else { pushdown(y); if(a[a[y].ch[1]].size>=alpha*(a[x].size+a[y].size)) { a[y].ch[0]=merges(x,a[y].ch[0]); pushup(y);return y; } pushdown(a[y].ch[0]); a[y].ch[1]=merges(a[a[y].ch[0]].ch[1],a[y].ch[1]); a[y].ch[0]=merges(x,a[a[y].ch[0]].ch[0]); pushup(y);return y; } } ``` ~~然而复杂度越正确跑得就越慢~~ 实践中建议还是直接$merge$~~毕竟没人卡~~ ### 分裂(split) 将一棵树的前$k$个节点分离出来成为一棵新树. 可以直接递归分裂,并把得到的结果调用$merges$合并. ```cpp void split(int u,int k,int &x,int &y) { if(!k){x=0,y=u;return;} if(a[u].size==1){x=u,y=0;return;} pushdown(u); if(k>a[a[u].ch[0]].size) { split(a[u].ch[1],k-a[a[u].ch[0]].size,x,y); x=merges(a[u].ch[0],x);recycle(u); } else { split(a[u].ch[0],k,x,y); y=merges(y,a[u].ch[1]);recycle(u); } } ``` 仍然是注意垃圾回收. 通过$merges$和$split$操作我们可以像fhq treap一样处理问题.以下是区间翻转的代码: ```cpp void reverse(int u){if(a[u].ch[0])a[u].tag^=1,swap(a[u].ch[0],a[u].ch[1]);} void update(int l,int r) { int x,y,z; split(root,l-1,x,y),split(y,r-l+1,y,z); reverse(y); root=merges(merges(x,y),z); } ``` 注意这样分离区间常数很大,最好只用于不能使用线段树的区间操作(比如翻转区间).其他的操作可以类似线段树地去做,比如以下是区间求和的代码: ```cpp long long query(int rot,int lq,int rq) { if(lq==1&&rq==a[rot].size)return a[rot].s; pushdown(rot);int mid=a[a[rot].ch[0]].size; if(rq<=mid)return query(a[rot].ch[0],lq,rq); if(lq>mid)return query(a[rot].ch[1],lq-mid,rq-mid); return query(a[rot].ch[0],lq,mid)+query(a[rot].ch[1],1,rq-mid); } ``` 使用[文艺平衡树](https://www.luogu.org/problem/P3391)进行测试,略快于$Splay$.但是注意一些操作只能使用分裂合并时远劣于$Splay$,比如[维护数列](https://www.luogu.org/problem/P2042)