序列之王SPLAY

来源:互联网 发布:linux下pcb 编辑:程序博客网 时间:2024/04/28 01:14
  • 至今为止不会打Spaly的蒟蒻前来总结splay,hhhh
  • 当昨天把维护数列调过去的时候,蒟蒻的心情那简直了……感觉以后什么splay神题都可以直接贴板子了hhhh

一些基础的东西

首先我们要知道什么是Splay
即伸展树,一切操作基于伸展(Splay)操作不属于平衡树(不满足任意一种平衡树的性质)。
如果你不会Splay,那么我来简单介绍一下:

  • 作为一棵二叉搜索树,Splay满足搜索树的一切性质:节点的左子树的关键字全部小于节点的关键字,节点右子树的关键字全部大于节点的关键字。根据题目需要确定关键字。如果是序列,那么一般把它的坐标(位置)作为关键字。所以一棵维护序列的Splay中序遍历下来就是这个序列。支持旋转、插入、删除、查找节点x、得到一个数的rank值等。
  • 最基本的旋转操作,如果在左边就向右边转,在右边就向左边转。如果刚好节点的和它的父亲同为自己父亲的左/右节点,就可以转它的父亲了。
  • 寻找关键字v所在的节点,一路根据关键字大小找下去。
  • 最基本的插入,就是一直向下,插入的v比节点储存的值大就向右找,小就往左,知道找到一个空节点直接插入。然后把插入的这个节点旋至根。
  • 最基本的删除,找到这个节点,旋至根,删除,把它的左子树和右子树接上。
  • 最基本的确定排名x的数是什么,也是一路向下,如果sz(左子树)+sz(根)还没有x大,就可以去根的右子树找了,否则,如果sz(左子树)比x大,进入其左子树的左子树,如果没有,说明就是根。(请画图)调用伸展操作。
  • 寻找前驱/后继,就是找它的左子树的最右或者右子树的最左。我喜欢带一个参数然后打在一个操作里,不过好像大家都喜欢分开打的样子啊。
  • 据说这七(八)个操作可以解决一切,但是蒟蒻显然并不可以……
  • 还有一些操作等等再补,再补……

一切有关于序列的操作splay几乎都可以完美地做到,只是splay是真的慢到死的那种慢,为什么呢?
我们来看一看splay的代码(以最基本的Ins为例):

inline void ins(int x,int v){    int y=0;    for(;x&&tr[x].v!=v;) y=x,x=tr[x].ch[v>tr[x].v];    if(x) ++tr[x].cnt;    else {        x=++sz,tr[x].sz=tr[x].cnt=1,tr[x].fa=y,tr[x].v=v;        if(y) tr[y].ch[v>tr[y].v]=x;    }    splay(x,0);}

你会发现它调用了splay操作(有什么操作不调用splay操作呢):

inline void splay(int x,int tp) {    for(int y,z;(y=tr[x].fa)!=tp;rot(x)) {        if((z=tr[y].fa)==tp) continue;        if((tr[y].ch[0]==x)==(tr[z].ch[0]==y)) rot(y);        else rot(x);    }    if(!tp) rt=x;Up(x);}

rot操作:

inline void rot(int x) {    int y=tr[x].fa,z=tr[y].fa;bool f=(tr[y].ch[1]==x);    tr[y].ch[f]=tr[x].ch[!f];    if(tr[y].ch[f]) tr[tr[y].ch[f]].fa=y;    tr[x].ch[!f]=y,tr[y].fa=x,tr[x].fa=z;    if(z) tr[z].ch[tr[z].ch[1]==y]=x;    Up(y);}

由于这里只是最基本的Rot,所以没有标记无需下传,转完之后统计size就是了。
而在splay之前,各种基本操作就已经是均摊O(logn)了,现在又自带一个均摊旋转O(logn)次的splay操作,Rot又并不是严格O(1),如果需要标记下传、有区间操作,会十分繁琐而复杂。
此时拼的就是大家的底层优化技术了。
所以为了常数,一般来说我很少写递归,但是线段树递推什么的太过于玄学了啊啊啊啊

那么把基础操作的板子贴在这里:
Del

inline void del(int v) {    int p=nxt(v,0),s=nxt(v,1);    splay(p,0),splay(s,p),p=tr[s].ch[0];    if(tr[p].cnt>1) tr[p].cnt--,splay(p,0);    else tr[s].ch[0]=0;}

Find

inline void find(int v) {    int x=rt;    for(;tr[x].ch[v>tr[x].v]&&tr[x].v!=v;) x=tr[x].ch[v>tr[x].v];    splay(x,0);}

Nxt(前驱/后继):

inline int nxt(int v,bool f) {    find(v);    if((tr[rt].v>v&&f)||(tr[rt].v<v&&!f)) return rt;    int p=tr[rt].ch[f];    for(;tr[p].ch[!f];) p=tr[p].ch[!f];    return p;}

Kth找到rank K的节点:

inline int kth(int x) {    int y=rt,p;    if(x>tr[rt].sz) return 0;    for(;;) {        p=tr[y].ch[0];        if(tr[p].sz+tr[y].cnt<x) x=x-tr[p].sz-tr[y].cnt,y=tr[y].ch[1];        else if(tr[p].sz>=x) y=p; else return tr[y].v;    }}

这里还有一个小技巧:
为了方便处理一些复杂的情况,在开始的时候我们通常在首尾插入不存在的节点,这一点马上就可以体会到作用:
ins(rt,0x3fffffff),ins(rt,-0x3fffffff)
(贴起来再加一个主函数就是bzoj3224普通平衡树)


上区间操作

怎么得到一个区间[l,r]???
其实也很简单,以坐标/位置作为关键字插入,将节点(l-1)旋成根,节点(r+1)旋成根的右儿子,那么节点(r+1)的左子树就是我们要找区间[l,r]
(现在明白那两个不存在的节点是用来干什么的吧?)
然后你就可以利用它打标记啊各种噼里啪啦地搞事情啊……
咳咳,暂且不上车,我再来介绍两个很有用的操作:

Merge,即合并,至于怎么搞,暴力就是了
(合并A,B两个子树的时候,首先钦定B子树中的所有关键字都要比A大(合并区间时显然B要么在左要么在右),然后找到A的最右,旋至根,令B为A的最右(此时已经是根了)的右子树,pushup一下A的根即可)

inline void Merge(int &l,int r) {    int p=rt;    for(;t[p].ch[1];) p=t[p].ch[1];    Splay(p,0,l),t[l].ch[1]=r,t[r].f=l,Up(l);}

Split,即分裂,至于怎么搞,暴力就是了
(好吧还是简单介绍一下:比如说在以x为根的子树中分裂出k个数,那么先找到第k个数的位置,将它旋成x的右儿子,然后清除x的右儿子。这里大家可以make_pair一下,不过我多用两个参数牺牲一点空间解决掉了,好像常数还比较小?)

inline void Split(int &x,int k,int &l,int &r) {    Kth(k,x),l=x,r=t[x].ch[1],t[r].f=0,t[x].ch[1]=0,Up(l);}

(压行压得很诡异啊)
好像也没有什么鬼用的样子????
但是我的区间插入和删除没有他们两个调不过去wawawawawa
然后这里的splay和无旋Treap有区别。

  • Treap是搞事的时候一路分裂下去,搞完事再一路合并上来
  • Splay是搞事的时候分裂一次,搞一搞事,然后合并一/两次

(应该有蛮清晰易懂的啊)

那么,我们来看问题:

一个序列[1…n],支持区间翻转,不删不插。(bzoj3223文艺平衡树)

**卧曹这还要splay????线段树秒啊**
咳咳,这样不好不好,不要传播邪教(**明明没有插入删除还用splay才是邪教**

好,由于用不着什么插入删除,所以这里搞个Merge、Split纯属没事找事干……

那么简单介绍一下正常人的Rev(翻转):
翻转区间[l,r]时,将节点(l-1)旋成根,节点(r+1)旋成根的右儿子,那么节点(r+1)的左子树就是我们要找区间[l,r],打一个标记,即t[x].rev^=1,此时不要急着下传,因为再来一个翻转它就回到了原位。
翻译成代码:

inline void Rev(int l,int r) {    int a=Kth(l),b=Kth(r+2);Splay(a,0),Splay(b,a),t[t[b].ch[0]].rev^=1;}

请叫我压行小能手hhhhhhh
为什么我直接转l和r+2了呢?
这个请看主函数:

for(in(n),in(m),rt=Build(1,n+2);m--;) in(l),in(r),Rev(l,r);

对,我把a[1]和a[n+2]搞成了虚拟节点。

然后大家可能奇怪啊,明明直接插入就好了啊,为什么要有没有任何鬼用的Build
作为一个压行小能手,我难道不知道这样可以省掉几行啊?
可是,我们来看看Build:

int Build(int l,int r) {    if(l>r) return 0;    int mid=l+r>>1;    t[mid].ch[0]=Build(l,mid-1);    t[mid].ch[1]=Build(mid+1,r);    Up(mid),t[t[mid].ch[0]].f=t[t[mid].ch[1]].f=mid;    return mid;}

是的,Build全程没有调用Splay操作,硬生生地省去了一个巨大的常数。
所以卡常重要还是压行重要咯?
当然你要说是压行我也没有什么办法。

那么换一个问题:

给出一个序列,要求支持把区间修改。

**要是谁这道题打了Splay那就是真闲着没事干啊**

好吧,组合一下:

给出一个序列,要求支持区间修改、区间翻转。

**啊我也好想打线段树多好啊但是我要讲Splay啊啊啊啊啊啊**

这里只说明一件事:
如果区间set了,那么rev也就可以清零了。为什么呢?

  • 整个区间都是一个数了,你翻来翻去有意思嘛?
  • 但是,它还是可以用线段树来做,而且常数不知道小到哪里去了

    那么,什么不可以用线段树呢?
    我们再来修改一下题目:

    给出一个序列,要求:
    1.插入一个数。
    2.删除一个数。
    3.翻转一个区间。
    4.对一个区间进行修改。

    事实上这道题还是可以用线段树而且常数不知道小到哪里去了
    ~~ 至于怎么做,以后再慢慢写吧~~

    Splay的话组合起来就是了。
    但是好像普通的插入删除搞不定了啊?
    为什么呢?
    普通插入,怎么做到在精确地一个数后面插入这个数?
    就算你把x旋至根,(x+1)成为右儿子……你可以自己试一试

    此时Merge和Split就有了无可替代的作用。
    但凡是遇见区间操作的、有插入删除的,用合并分裂来搞准没错。

    这样就很简单了嘛,在插入的时候,如果要求在x后插入一个节点v,先把v作为一棵只有一个节点的Splay,在原序列分裂出前x-1个数作为t1,其余作为t2,合并t1和v,再合并t1和t2。
    删除更简单,分裂前x-1作为t1,其余作为t2,再在t2中切出一个数扔掉,合并起来,完美。
    代码如下:

    void Del(int k) {    int t1,t2,t3;    Split(rt,k,t1,t2),Split(t2,1,t2,t3);    Merge(t1,t3),rt=t1;}void Ins(int k,int x) {    int t1,t2;t[++sz].v=t[sz].mn=x,t[sz].sz=1;    Split(rt,k+1,t1,t2),Merge(t1,sz),Merge(t1,t2),rt=t1;}

    如果你讨厌压行:

    void Del(int k) {    int t1,t2,t3;    Split(rt,k,t1,t2);    Split(t2,1,t2,t3);    Merge(t1,t3);    rt=t1;}void Ins(int k,int x) {    int t1,t2;    t[sz].sz=1;    t[++sz].v=t[sz].mn=x;    Split(rt,k+1,t1,t2);    Merge(t1,sz);    Merge(t1,t2);    rt=t1;}

    还是短到不行啊?
    然后区间操作的话,我yy了一下,发现其实可以把这段区间分裂出来?
    然后爱怎么搞怎么搞?
    天哪太神奇了……
    然后区间翻转的代码就可以再短一点了……
    好吧,恕我实在是懒得打了。

    那么,我们再加一点点诡异的小操作:

    一个序列,要求支持:
    1.插入一个数。
    2.删除一个数。
    3.区间求min。
    4.区间修改。
    5.区间翻转。
    6.区间流动:l,r,T,把区间的最后T个数搬到前面去。

    好高能的样子……
    但是会了合并分裂之后简直就是智障题啊除了真的要调很久

    切出来[l,r]作为A,在A中切出[l+T-1,r]作为B,然后Merge(B,A),成为一棵全新的Splay,再与原来的Splay合并就是了。

    然而。。。
    大家有没有看出来区间翻转和区间流动之间诡异的关系?

    inline void Revolve(int l,int r,int T) {    (T=T%(r-l+1)+(r-l+1))%=(r-l+1);    if(!T) return;    Rev(l,r),Rev(l,l+T-1),Rev(l+T,r);}

    此题告诉我们:

  • 要学会用题目中的一个操作去实现另一个操作。
  • 至于算法效率,连poj都可以通过,还有什么做不到?

    好吧,简直了。

    然后你来猜猜我们要做什么题呢?

    对,就是这道splay的终结者:bzoj1500

    Splay的终结者

    >
    要求支持:
    1.插入一段区间
    2.删除一段区间
    3.将一个区间的数全部改成一个数
    4.区间求和
    5.区间翻转
    6.全局最大子序列(要求必选一个数,不能为空,并且是连续的)

    看上去你都会的样子啊。
    但是你知道要调多久吗??????

    虽然其实思路很简单,但是呢,有一些细节是防不胜防的。

    关于空间

    首先,由于题面说了有500w个数,所以你首先会爆掉空间
    怎么办呢?只能牺牲时间来换了。
    以前新建一个节点是怎样的?
    直接++size赋值就是了。但是,注意,删除的时候,会产生一大堆已经没有作用但是却占用了空间的节点,那么你就需要写一个垃圾回收站来回收这些没有什么鬼用的节点,达到不浪费空间的目的:

    inline void Rec(int x) {    if(!x) return;    Rec(t[x].ch[0]),rubbish[++cnt]=x,Rec(t[x].ch[1]);    t[x]=t[0];}

    hhh那个t[x]=t[0]是我独创的哦
    为了压行也真是拼了

    那么新建节点的时候就可以优先考虑从垃圾堆里面拿出一个接着用:

    inline int New() {    if(rubbish[cnt]) return rubbish[cnt--];    return ++sz;}

    是不是也有蛮简洁的?

    为什么说牺牲时间呢,因为调用一个函数那必须比++sz要慢啊。
    所以Build的时候:

    inline int Build(int l,int r) {    if(l>r) return 0;    int mid=l+r>>1,x=New();t[x].v=a[mid];    if(l==r) {t[x].sz=1,setv(x,a[mid]),t[x].tag=0;return x;}    t[x].ch[0]=Build(l,mid-1);    t[x].ch[1]=Build(mid+1,r),Up(x);    if(t[x].ch[0]) t[t[x].ch[0]].f=x;    if(t[x].ch[1]) t[t[x].ch[1]].f=x;    return x;}

    Delet的时候:

    inline void Del(int l,int r) {    int t1,t2,t3;    Split(rt,l,t1,t2),Split(t2,r,t2,t3);    Rec(t2),Merge(t1,t3),rt=t1;}

    如何上传

    这个必须要讲一讲。
    卡了我n个小时的错啊啊啊啊
    你会发现,如果一个结点的儿子中有一个空节点,那么它的mc(最大子序列)的最小值就变成了0,因为节点0的一切初始值都是0
    怎么办呢?
    改一改初始值不就好了?
    以及,如何求它的最大子序列?
    这个也真是有蛮简单,线段树也用的到。
    维护序列的和sum,包含最左边的最大连续子序列,包含最右边的最大连续子序列。

    inline int Up(int x) {    static int l,r;    l=t[x].ch[0],r=t[x].ch[1];    t[x].sz=t[l].sz+t[r].sz+1;    t[x].sum=t[l].sum+t[r].sum+t[x].v;    t[x].mc=max(t[r].mc,t[l].mc);    t[x].mc=max(t[x].mc,t[l].rc+t[r].lc+t[x].v);    t[x].lc=max(t[l].lc,t[l].sum+t[r].lc+t[x].v);    t[x].rc=max(t[r].rc,t[r].sum+t[l].rc+t[x].v);}

    标记下传

    然后,关于标记下传的时候如何才能省下你的一点点代码而且不容易出错:
    肯定是需要修改的节点都要下传完标记。
    那么,想一想,什么东西是你干什么都要用并且又会到达你需要修改的所有节点的?
    当然是Splay啊。
    那么,标记下传又何必留到操作里去呢?
    旋转的时候搞完不久好了?

    inline void rot(int x) {    int y=t[x].f,z=t[y].f,f=(t[y].ch[1]==x);    pd(y),pd(x),t[y].ch[f]=t[x].ch[!f];    if(t[y].ch[f]) t[t[y].ch[f]].f=y;    t[x].ch[!f]=y,t[y].f=x,t[x].f=z;    if(z) t[z].ch[t[z].ch[1]==y]=x;Up(y),Up(x);}

    就是这样改一改。

    那么,如何标记下传才正确呢?
    Set的时候弄一个标记tag表示是否需要Set,因为在这道题里面Set的值是可以为0的。
    Rev的时候需要交换lc和rc、左儿子和右儿子。
    你会发现标记下传的时候其实根节点和儿子的所有要做的事都是一样的,所以你可以专门为下传Rev和Set写两个函数

    inline void reve(int x) {    t[x].rev^=1,swap(t[x].lc,t[x].rc);    swap(t[x].ch[0],t[x].ch[1]);}inline void setv(int x,int v) {    t[x].tag=1,t[x].v=v,t[x].sum=v*t[x].sz;    if(v>0) t[x].lc=t[x].rc=t[x].mc=t[x].sum;    else t[x].lc=t[x].rc=0,t[x].mc=v;}

    然后下传的时候还必须判断它有没有左/右儿子,万一没有,你把节点0修来修去,上传的时候出错了怎么办?

    inline void pd(int x) {    static int l,r;    l=t[x].ch[0],r=t[x].ch[1];    if(t[x].tag) {        if(l) setv(l,t[x].v);        if(r) setv(r,t[x].v);t[x].tag=t[x].rev=0;    }    if(t[x].rev) {        if(l) reve(l);        if(r) reve(r);t[x].rev=0;    }}

    插入

    插入的时候,把要插入的序列先单独建成一棵Splay,合并。

    inline void Ins(int x,int l) {    int t1,t2,p=Build(1,l);Split(rt,x+1,rt,t2);    Merge(rt,p),Merge(rt,t2);}

    哦真是要死要活的……

    小总结

    1.一头一尾插入两个不存在的节点可以节省很多事。
    2.注意节点0是否会影响答案,如果影响,如何避免。
    3.尽量多利用Merge和Split,这两个基本就是板子,不容易错,错起来也方便调。
    4.左右子树是否存在会影响很多操作。
    5.标记的优先顺序甚至会影响常数大小,遇到set这种可以清除其它一切操作的标记就乐呵呵了。
    6.能写成函数的就写成函数,分块写。
    7.我突然想不到了……总之还是要细心啊。

    谢谢观看!

    尽请期待下一篇线段树/lct/ett/toptree(如果我写得到的话)的总结