伸展樹的基本操作與應用
由上面的分析介紹,我們可以發現伸展樹有以下幾個優點: (1)時間複雜度低,伸展樹的各種基本操作的平攤複雜度都是 O(log n)的。在樹狀資料結構中,無疑是非常優秀的。
(2)空間要求不高。與紅黑樹需要記錄每個節點的顏色、AVL 樹需要記錄平衡因子不同,伸展樹不需要記錄任何資訊以保持樹的平衡。 (3)演算法簡單,程式設計容易。伸展樹的基本操作都是以 Splay 操作為基礎的,而Splay 操作中只需根據當前節點的位置進行旋轉操作即可。
上題參考程式碼:
1 /*View Code************************************************************* 2 Problem: 1588 3 User: SongHL 4 Language: C++ 5 Result: Accepted 6 Time:1284 ms 7 Memory:2068 kb 8 ****************************************************************/ 9 10 #include<bits/stdc++.h> 11const int INF=0x3f3f3f3f; 12 using namespace std; 13 int ans,n,t1,t2,rt,size; 14 int tr[50001][2],fa[50001],num[50001]; 15 void rotate(int x,int &k) 16 { 17 int y=fa[x],z=fa[y],l,r; 18 if(tr[y][0]==x)l=0;else l=1;r=l^1; 19 if(y==k)k=x; 20 else{if(tr[z][0]==y)tr[z][0]=x;else tr[z][1]=x;}21 fa[x]=z;fa[y]=x;fa[tr[x][r]]=y; 22 tr[y][l]=tr[x][r];tr[x][r]=y; 23 } 24 void splay(int x,int &k) 25 { 26 int y,z; 27 while(x!=k) 28 { 29 y=fa[x],z=fa[y]; 30 if(y!=k) 31 { 32 if((tr[y][0]==x)^(tr[z][0]==y))rotate(x,k); 33 else rotate(y,k); 34 } 35 rotate(x,k); 36 } 37 } 38 void ins(int &k,int x,int last) 39 { 40 if(k==0){size++;k=size;num[k]=x;fa[k]=last;splay(k,rt);return;} 41 if(x<num[k])ins(tr[k][0],x,k); 42 else ins(tr[k][1],x,k); 43 } 44 void ask_before(int k,int x) 45 { 46 if(k==0)return; 47 if(num[k]<=x){t1=num[k];ask_before(tr[k][1],x);} 48 else ask_before(tr[k][0],x); 49 } 50 void ask_after(int k,int x) 51 { 52 if(k==0)return; 53 if(num[k]>=x){t2=num[k];ask_after(tr[k][0],x);} 54 else ask_after(tr[k][1],x); 55 } 56 int main() 57 { 58 scanf("%d",&n); 59 for(int i=1;i<=n;i++) 60 { 61 int x;if(scanf("%d",&x)==EOF) x=0; 62 t1=-INF;t2=INF; 63 ask_before(rt,x); 64 ask_after(rt,x); 65 if(i!=1)ans+=min(x-t1,t2-x); 66 else ans+=x; 67 ins(rt,x,0); 68 } 69 printf("%d",ans); 70 return 0; 71 }
[NOI2005]維修數列(Splay的其他操作)
https://www.lydsy.com/JudgeOnline/problem.php?id=1500
演算法過程:
初始化
首先,對於原序列,我們不應該一個一個讀入,然後插入,那麼效率就是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])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])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])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,所以我們考慮一個回收機制,把用過但是已經刪掉的節點編號記錄到一個佇列或棧中,在新建節點時直接把佇列中的冗餘編號搞過來就好了。
參考程式碼:
1 #include<bits/stdc++.h> 2 #define RI register int 3 #define For(i,a,b) for (RI i=a;i<=b;++i) 4 using namespace std; 5 const int inf=0x3f3f3f3f; 6 const int N=1e6+17; 7 int n,m,rt,cnt; 8 int a[N],id[N],fa[N],c[N][2]; 9 int sum[N],sz[N],v[N],mx[N],lx[N],rx[N]; 10 bool tag[N],rev[N]; 11 //tag表示是否有統一修改的標記,rev表示是否有統一翻轉的標記 12 //sum表示這個點的子樹中的權值和,v表示這個點的權值 13 queue<int> q; 14 inline int read() 15 { 16 RI x=0,f=1;char ch=getchar(); 17 while(ch<'0'||ch>'9'){if(ch=='-') f=-1; ch=getchar();} 18 while('0'<=ch&&ch<='9'){x=(x<<1)+(x<<3)+ch-'0';ch=getchar();} 19 return x*f; 20 } 21 inline void pushup(RI x) 22 { 23 RI l=c[x][0],r=c[x][1]; 24 sum[x]=sum[l]+sum[r]+v[x]; 25 sz[x]=sz[l]+sz[r]+1; 26 mx[x]=max(mx[l],max(mx[r],rx[l]+v[x]+lx[r])); 27 lx[x]=max(lx[l],sum[l]+v[x]+lx[r]); 28 rx[x]=max(rx[r],sum[r]+v[x]+rx[l]); 29 } 30 //上傳記錄標記 31 inline void pushdown(RI x) 32 { 33 RI l=c[x][0],r=c[x][1]; 34 if(tag[x]) 35 { 36 rev[x]=tag[x]=0;//我們有了一個統一修改的標記,再翻轉就沒有什麼意義了 37 if(l) tag[l]=1,v[l]=v[x],sum[l]=v[x]*sz[l]; 38 if(r) tag[r]=1,v[r]=v[x],sum[r]=v[x]*sz[r]; 39 if(v[x]>=0) 40 { 41 if(l) lx[l]=rx[l]=mx[l]=sum[l]; 42 if(r) lx[r]=rx[r]=mx[r]=sum[r]; 43 } 44 else 45 { 46 if(l) lx[l]=rx[l]=0,mx[l]=v[x]; 47 if(r) lx[r]=rx[r]=0,mx[r]=v[x]; 48 } 49 } 50 if(rev[x]) 51 { 52 rev[x]=0;rev[l]^=1;rev[r]^=1; 53 swap(lx[l],rx[l]);swap(lx[r],rx[r]); 54 //注意,在翻轉操作中,前後綴的最長上升子序列都反過來了,很容易錯 55 swap(c[l][0],c[l][1]);swap(c[r][0],c[r][1]); 56 } 57 } 58 inline void rotate(RI x,RI &k) 59 { 60 RI y=fa[x],z=fa[y],l=(c[y][1]==x),r=l^1; 61 if (y==k)k=x;else c[z][c[z][1]==y]=x; 62 fa[c[x][r]]=y;fa[y]=x;fa[x]=z; 63 c[y][l]=c[x][r];c[x][r]=y; 64 pushup(y);pushup(x); 65 //旋轉操作,一定要上傳標記且順序不能變 66 } 67 inline void splay(RI x,RI &k) 68 { 69 while(x!=k) 70 { 71 int y=fa[x],z=fa[y]; 72 if(y!=k) 73 { 74 if((c[z][0]==y)^(c[y][0]==x)) rotate(x,k); 75 else rotate(y,k); 76 } 77 rotate(x,k); 78 } 79 } 80 //這是整個程式的核心之一,畢竟是伸展操作嘛 81 inline int find(RI x,RI rk) 82 {//返回當前序列第rk個數的標號 83 pushdown(x); 84 RI l=c[x][0],r=c[x][1]; 85 if(sz[l]+1==rk) return x; 86 if(sz[l]>=rk) return find(l,rk); 87 else return find(r,rk-sz[l]-1); 88 } 89 inline void recycle(RI x) 90 {//這就是用時間換空間的回收冗餘編號機制,很好理解 91 RI &l=c[x][0],&r=c[x][1]; 92 if(l) recycle(l); 93 if(r) recycle(r); 94 q.push(x); 95 fa[x]=l=r=tag[x]=rev[x]=0; 96 } 97 inline int split(RI k,RI tot)//找到[k+1,k+tot] 98 { 99 RI x=find(rt,k),y=find(rt,k+tot+1); 100 splay(x,rt);splay(y,c[x][1]); 101 return c[y][0]; 102 } 103 //這個split操作是整個程式的核心之三 104 //我們通過這個split操作,找到[k+1,k+tot],並把k,和k+tot+1移到根和右兒子的位置 105 //然後我們返回了這個右兒子的左兒子,這就是我們需要操作的區間 106 inline void query(RI k,RI tot) 107 { 108 RI x=split(k,tot); 109 printf("%d\n",sum[x]); 110 } 111 inline void modify(RI k,RI tot,RI val)//MAKE-SAME 112 { 113 RI x=split(k,tot),y=fa[x]; 114 v[x]=val;tag[x]=1;sum[x]=sz[x]*val; 115 if(val>=0) lx[x]=rx[x]=mx[x]=sum[x]; 116 else lx[x]=rx[x]=0,mx[x]=val; 117 pushup(y);pushup(fa[y]); 118 //每一步的修改操作,由於父子關係發生改變 119 //及記錄標記發生改變,我們需要及時上傳記錄標記 120 } 121 inline void rever(RI k,RI tot)//翻轉 122 { 123 RI x=split(k,tot),y=fa[x]; 124 if(!tag[x]) 125 { 126 rev[x]^=1; 127 swap(c[x][0],c[x][1]); 128 swap(lx[x],rx[x]); 129 pushup(y);pushup(fa[y]); 130 } 131 //同上 132 } 133 inline void erase(RI k,RI tot)//DELETE 134 { 135 RI x=split(k,tot),y=fa[x]; 136 recycle(x);c[y][0]=0; 137 pushup(y);pushup(fa[y]); 138 //同上 139 } 140 inline void build(RI l,RI r,RI f) 141 { 142 RI mid=(l+r)>>1,now=id[mid],pre=id[f]; 143 if(l==r) 144 { 145 mx[now]=sum[now]=a[l]; 146 tag[now]=rev[now]=0; 147 //這裡這個tag和rev的清0是必要,因為這個編號可能是之前冗餘了 148 lx[now]=rx[now]=max(a[l],0); 149 sz[now]=1; 150 } 151 if(l<mid) build(l,mid-1,mid); 152 if(mid<r) build(mid+1,r,mid); 153 v[now]=a[mid]; fa[now]=pre; 154 pushup(now); //上傳記錄標記 155 c[pre][mid>=f]=now; 156 //當mid>=f時,now是插入到又區間取了,所以c[pre][1]=now,當mid<f時同理 157 } 158 inline void insert(RI k,RI tot) 159 { 160 for(int i=1;i<=tot;++i) a[i]=read(); 161 for(int i=1;i<=tot;++i) 162 { 163 if(!q.empty()) id[i]=q.front(),q.pop(); 164 else id[i]=++cnt;//利用佇列中記錄的冗餘節點編號 165 } 166 build(1,tot,0); 167 RI z=id[(1+tot)>>1]; 168 RI x=find(rt,k+1),y=find(rt,k+2); 169 //首先,依據中序遍歷,找到我們需要操作的區間的實際編號 170 splay(x,rt);splay(y,c[x][1]); 171 //把k+1(注意我們已經右移了一個單位)和(k+1)+1移到根和右兒子 172 fa[z]=y;c[y][0]=z; 173 //直接把需要插入的這個平衡樹掛到右兒子的左兒子上去就好了 174 pushup(y);pushup(x); 175 //上傳記錄標記 176 } 177 //可以這麼記,只要用了split就要重新上傳標記 178 //只有find中需要下傳標記 179 int main() 180 { 181 n=read(),m=read(); 182 mx[0]=a[1]=a[n+2]=-inf; 183 For(i,1,n) a[i+1]=read(); 184 For(i,1,n+2) id[i]=i;//虛擬了兩個節點1和n+2,然後把需要操作區間整體右移一個單位 185 build(1,n+2,0);//建樹 186 rt=(n+3)>>1;cnt=n+2;//取最中間的為根 187 RI k,tot,val;char ch[10]; 188 while(m--) 189 { 190 scanf("%s",ch); 191 if(ch[0]!='M' || ch[2]!='X') k=read(),tot=read(); 192 if(ch[0]=='I') insert(k,tot); 193 if(ch[0]=='D') erase(k,tot);//DELETE 194 if(ch[0]=='M') 195 { 196 if(ch[2]=='X') printf("%d\n",mx[rt]);//MAX-SUM 197 else val=read(),modify(k,tot,val);//MAKE-SAME 198 } 199 if(ch[0]=='R') rever(k,tot);//翻轉 200 if(ch[0]=='G') query(k,tot);//GET-SUM 201 } 202 return 0; 203 } 204View Code