NOI2005 维护数列

来源:互联网 发布:宾得k3ii知乎 编辑:程序博客网 时间:2024/06/07 12:29

在线评测地址:洛谷,大视野

题目描述

1500: [NOI2005]维修数列
Time Limit: 10 Sec Memory Limit: 64 MB

Description

Input
输入的第1 行包含两个数N 和M(M ≤20 000),N 表示初始时数列中数的个数,M表示要进行的操作数目。
第2行包含N个数字,描述初始时的数列。
以下M行,每行一条命令,格式参见问题描述中的表格。
任何时刻数列中最多含有500 000个数,数列中任何一个数字均在[-1 000, 1 000]内。
插入的数字总数不超过4 000 000个,输入文件大小不超过20MBytes。
Output
对于输入数据中的GET-SUM和MAX-SUM操作,向输出文件依次打印结果,每个答案(数字)占一行。
Sample Input
9 8
2 -6 3 5 1 -5 -3 6 3
GET-SUM 5 4
MAX-SUM
INSERT 8 3 -5 7 2
DELETE 12 1
MAKE-SAME 3 3 2
REVERSE 3 6
GET-SUM 5 4
MAX-SUM
Sample Output
-1
10
1
10
HINT

问题解析

做题前的思路预备

首先,要有一点splay维护区间操作的基础。
splay维护区间的基本原理,就是将区间[l,r]的端点l-1,和r+1不断的通过伸展操作即splay到根,将l-1伸展到根,将r+1伸展到根的右儿子,那么[l,r]这段区间就在根的右儿子的左儿子上了。
特别要注意的是,==这里的l,r不是给出的区间端点的编号,而是我们在平衡树的中序遍历中区间端点的编号。即在平衡树中排名(rank)为l,r的两个节点的真实编号,而对于l=1或r=n的情况就非常特殊了,我们有两种解决方案,一种就是分类讨论,将这4种情况枚举,然后进行操作,这么做固然可行,但是当操作变多时,会使整个程序显得繁琐,并且难于调试。另一种解决方案就是建立虚拟节点,我们把需要维护的区间全部变成[l+1,r+1],那么我们虚拟出一个1号节点和一个n+2号节点,那么整个操作就显得十分自然了==。
那么问题就明显是一个splay的基本模板题了。而维护区间翻转,在洛谷的P3391文艺平衡树中有更裸的题目。
这里,一个操作一个操作的解决。

初始化

首先,对于原序列,我们不应该一个一个读入,然后插入,那么效率就是O(nlogn),而splay的常数本身就很大,所以考虑一个优化,就是把原序列一次性读入后,直接类似线段树的build,搞一个整体建树,即不断的将当前点维护的区间进行二分,到达单元素区间后,就把对应的序列值插入进去,这样,我们一开始建的树就是一个非常平衡的树,可以使后续操作的常数更小,并且建树整个复杂度只是O(2n)的。

Insert操作

其次,我们来考虑一下如何维护一个insert操作。我们可以这么做,首先如上将需要insert的区间变成节点数目为tot的平衡树,然后把k+1(注意我们将需要操作的区间右移了一个单位,所以题目所给k就是我们需要操作的k+1)移到根节点的位置,把原树中的k+2移到根节点的右儿子的位置。然后把需要insert的区间,先build成一个平衡树,把需要insert的树的根直接挂到原树中k+1的左儿子上就行了。

Delete操作

再然后,我们来考虑一下delete操作,我们同样的,把需要delete的区间变成[k+1,k+tot](注意,是删去k后面的tot个数,那么可以发现我们需要操作的原区间是[k,k+tot-1]!),然后把k号节点移到根节点的位置,把k+tot+2移到根节点的右儿子位置,然后直接把k+tot+2的左儿子的指针清为0,就把这段区间删掉了。可以发现,比insert还简单一点。

Reverse操作

接下来,这道题的重头戏就要开始了。splay的区间操作基本原理还类似于线段树的区间操作,即延迟修改,又称打懒标记。
对于翻转(reverse)操作,我们依旧是将操作区间变成[k+1,k+tot],然后把k和k+tot+1分别移到对应根的右儿子的位置,然后对这个右儿子的左儿子打上翻转标记即可。

Make-Same操作

对于Make-Same操作,我们同样需要先将需要操作的区间变成[k+1,k+tot],然后把k和k+tot+1分别移到根和右儿子的位置,然后对这个右儿子的左儿子打上修改标记即可。

Get-Sum操作

对于Get-Sum操作,我们还是将操作区间变成[k+1,k+tot],然后把k和k+tot+1分别移到根和右儿子的位置,然后直接输出这个右儿子的左儿子上的sum记录的和。

Max-Sum操作

对于这个求最大子序列的操作,即Max-Sum操作,我们不能局限于最开始学最大子序列的线性dp方法,而是要注意刚开始,基本很多书都会介绍一个分治的O(nlogn)的方法,但是由于存在O(n)的方法,导致这个方法并不受重视,但是这个方法确实很巧妙,当数列存在修改操作时,线性的算法就不再适用了。
这种带修改的最大子序列的问题,最开始是由线段树来维护,具体来说就是,对于线段树上的每个节点所代表的区间,维护3个量:lx表示从区间左端点l开始的连续的前缀最大子序列。rx表示从区间右端点r开始的连续的后缀最大子序列。mx表示这个区间中的最大子序列。
那么在合并[l,mid]和[mid+1,r]时,就类似一个dp的过程了!其中
lx[l,r]=max(lx[l,mid],sum[l,mid]+lx[mid+1,r])
rx[l,r]=max(rx[mid+1,r],sum[mid+1,r]+rx[l,mid])
mx[l,r]=max(mx[l,mid],mx[mid+1,r],lx[mid+1,r]+rx[l,mid+1])
这个还是很好理解的。就是选不选mid的两个决策。但是其实在实现的时候,我们并不用[l,r]的二维方式来记录这三个标记,而是用对应的节点编号来表示区间,这个可以看程序,其实是个很简单的东西。
那么最大子序列这个询问操作就可以很简单的解决了,还是类比前面的方法,就是把k和k+tot+1移到对应的根和右儿子的位置,然后直接输出右儿子的左儿子上的mx标记即可

懒标记的处理

最后,相信认真看了的童鞋会有疑问,这个标记怎么下传呢?首先,我们在每次将k和k+tot+1移到对应的位置时,需要一个类似查找k大值的find操作,即找出在平衡树中,实际编号为k在树中中序遍历的编号,这个才是我们真正需要处理的区间端点编号,那么就好了,我们只需在查找的过程中下传标记就好了!(其实线段树中也是这么做的),因为我们所有的操作都需要先find一下,所以我们可以保证才每次操作的结果计算出来时,对应的节点的标记都已经传好了。而我们在修改时,直接修改对应节点的记录标记和懒标记,因为我们的懒标记记录的都是已经对当前节点产生贡献,但是还没有当前节点的子树区间产生贡献!然后就是每处有修改的地方都要pushup一下就好了。

一些细节

另外,由于本题数据空间卡的非常紧,我们就需要用时间换空间,直接开4000000*logm的数据是不现实的,但是由于题目保证了同一时间在序列中的数字的个数最多是500000,所以我们考虑一个回收机制,把用过但是已经删掉的节点编号记录到一个队列或栈中,在新建节点时直接把队列中的冗余编号搞过来就好了。

后言

实现时还有诸多细节,务必要细看代码,加深了解。

参考代码:

#include<cstdio>#include<algorithm>#include<cstring>#include<queue>#define rint register int#define For(i,a,b) for (rint i=a;i<=b;++i)using namespace std;const int inf=0x3f3f3f3f;const int N=1e6+17;int n,m,rt,cnt;int a[N],id[N],fa[N],c[N][2];int sum[N],sz[N],v[N],mx[N],lx[N],rx[N];bool tag[N],rev[N];//tag表示是否有统一修改的标记,rev表示是否有统一翻转的标记//sum表示这个点的子树中的权值和,v表示这个点的权值queue<int> q;inline int read(){    rint x=0,f=1;char ch=getchar();    while (ch<'0' || ch>'9'){if (ch=='-')f=-1;ch=getchar();}    while ('0'<=ch && ch<='9')x=(x<<1)+(x<<3)+(ch^48),ch=getchar();    return x*f;}inline void pushup(rint x){    rint l=c[x][0],r=c[x][1];    sum[x]=sum[l]+sum[r]+v[x];    sz[x]=sz[l]+sz[r]+1;    mx[x]=max(mx[l],max(mx[r],rx[l]+v[x]+lx[r]));    lx[x]=max(lx[l],sum[l]+v[x]+lx[r]);    rx[x]=max(rx[r],sum[r]+v[x]+rx[l]);}//上传记录标记inline void pushdown(rint x){    rint l=c[x][0],r=c[x][1];    if (tag[x]){        rev[x]=tag[x]=0;//我们有了一个统一修改的标记,再翻转就没有什么意义了        if (l)tag[l]=1,v[l]=v[x],sum[l]=v[x]*sz[l];        if (r)tag[r]=1,v[r]=v[x],sum[r]=v[x]*sz[r];        if (v[x]>=0){            if (l)lx[l]=rx[l]=mx[l]=sum[l];            if (r)lx[r]=rx[r]=mx[r]=sum[r];        }else{            if (l)lx[l]=rx[l]=0,mx[l]=v[x];            if (r)lx[r]=rx[r]=0,mx[r]=v[x];        }    }    if (rev[x]){        rev[x]=0;rev[l]^=1;rev[r]^=1;        swap(lx[l],rx[l]);swap(lx[r],rx[r]);        //注意,在翻转操作中,前后缀的最长上升子序列都反过来了,很容易错        swap(c[l][0],c[l][1]);swap(c[r][0],c[r][1]);    }}//下传标记inline void rotate(rint x,rint &k){    rint y=fa[x],z=fa[y],l=(c[y][1]==x),r=l^1;    if (y==k)k=x;else c[z][c[z][1]==y]=x;    fa[c[x][r]]=y;fa[y]=x;fa[x]=z;    c[y][l]=c[x][r];c[x][r]=y;    pushup(y);pushup(x);    //旋转操作,一定要上传记录标记}inline void splay(rint x,rint &k){    while (x!=k){        int y=fa[x],z=fa[y];        if (y!=k){            if (c[z][0]==y ^ c[y][0]==x)rotate(x,k);                else rotate(y,k);        }        rotate(x,k);    }}//这是整个程序的核心之一,毕竟是伸展操作嘛inline int find(rint x,rint rk){    pushdown(x);    //因为所有的操作都需要find,所以我们只需在这里下传标记就行了    rint l=c[x][0],r=c[x][1];    if (sz[l]+1==rk)return x;    if (sz[l]>=rk)return find(l,rk);        else return find(r,rk-sz[l]-1);}//这个find是我们整个程序的核心之二//因为我们的区间翻转和插入及删除的操作的存在//我们维护的区间的实际编号并不是连续的//而,我们需要操作的区间又对应着平衡树的中序遍历中的那段区间//所以这个find很重要inline void recycle(rint x){    rint &l=c[x][0],&r=c[x][1];    if (l)recycle(l);    if (r)recycle(r);    q.push(x);    fa[x]=l=r=tag[x]=rev[x]=0;}//这就是用时间换空间的回收冗余编号机制,很好理解inline int split(rint k,rint tot){    rint x=find(rt,k),y=find(rt,k+tot+1);    splay(x,rt);splay(y,c[x][1]);    return c[y][0];}//这个split操作是整个程序的核心之三//我们通过这个split操作,找到[k+1,k+tot],并把k,和k+tot+1移到根和右儿子的位置//然后我们返回了这个右儿子的左儿子,这就是我们需要操作的区间inline void query(rint k,rint tot){    rint x=split(k,tot);    printf("%d\n",sum[x]);}inline void modify(rint k,rint tot,rint val){    rint x=split(k,tot),y=fa[x];    v[x]=val;tag[x]=1;sum[x]=sz[x]*val;    if (val>=0)lx[x]=rx[x]=mx[x]=sum[x];        else lx[x]=rx[x]=0,mx[x]=val;    pushup(y);pushup(fa[y]);    //每一步的修改操作,由于父子关系发生改变    //及记录标记发生改变,我们需要及时上传记录标记}inline void rever(rint k,rint tot){    rint x=split(k,tot),y=fa[x];    if (!tag[x]){        rev[x]^=1;        swap(c[x][0],c[x][1]);        swap(lx[x],rx[x]);        pushup(y);pushup(fa[y]);    }    //同上}inline void erase(rint k,rint tot){    rint x=split(k,tot),y=fa[x];    recycle(x);c[y][0]=0;    pushup(y);pushup(fa[y]);    //同上}inline void build(rint l,rint r,rint f){    rint mid=(l+r)>>1,now=id[mid],pre=id[f];    if (l==r){        mx[now]=sum[now]=a[l];        tag[now]=rev[now]=0;        //这里这个tag和rev的清0是必要,因为这个编号可能是之前冗余了        lx[now]=rx[now]=max(a[l],0);        sz[now]=1;    }    if (l<mid)build(l,mid-1,mid);    if (mid<r)build(mid+1,r,mid);    v[now]=a[mid]; fa[now]=pre;    pushup(now);    //上传记录标记    c[pre][mid>=f]=now;    //当mid>=f时,now是插入到又区间取了,所以c[pre][1]=now,当mid<f时同理}inline void insert(rint k,rint tot){    For(i,1,tot)a[i]=read();    For(i,1,tot)        if (!q.empty())id[i]=q.front(),q.pop();        else id[i]=++cnt;//利用队列中记录的冗余节点编号    build(1,tot,0);//将读入的tot个树建成一个平衡树    rint z=id[(1+tot)>>1];//取中点为根    rint x=find(rt,k+1),y=find(rt,k+2);    //首先,依据中序遍历,找到我们需要操作的区间的实际编号    splay(x,rt);splay(y,c[x][1]);    //把k+1(注意我们已经右移了一个单位)和(k+1)+1移到根和右儿子    fa[z]=y;c[y][0]=z;    //直接把需要插入的这个平衡树挂到右儿子的左儿子上去就好了    pushup(y);pushup(x);    //上传记录标记}//对于具体在哪里上传标记和下传标记//可以这么记,只要用了split就要重新上传标记//只有find中需要下传标记//但其实,你多传几次是没有关系的,但是少传了就不行了int main(){    n=read(),m=read();    mx[0]=a[1]=a[n+2]=-inf;    For(i,1,n)a[i+1]=read();    For(i,1,n+2)id[i]=i;//虚拟了两个节点1和n+2,然后把需要操作区间整体右移一个单位    build(1,n+2,0);//建树    rt=(n+3)>>1;cnt=n+2;//取最中间的为根    rint k,tot,val;char ch[10];    while (m--){        scanf("%s",ch);        if (ch[0]!='M' || ch[2]!='X') k=read(),tot=read();        if (ch[0]=='I')insert(k,tot);        if (ch[0]=='D')erase(k,tot);        if (ch[0]=='M'){            if (ch[2]=='X')printf("%d\n",mx[rt]);            else val=read(),modify(k,tot,val);        }        if (ch[0]=='R')rever(k,tot);        if (ch[0]=='G')query(k,tot);    }    return 0;}