【算法】数据结构
【平衡树】★平衡树 by onion_cyc
【莫队算法】
问题:给定长度为n的序列和m个区间询问,支持快速增减相邻元素维护区间信息。
将询问按左端点分块,块大小为$Q=\frac{n}{\sqrt m}$,块内按右端点排序。
然后依次回答询问,需要O(1)从(l,r)转移到(l,r+1),(l,r-1),(l-1,r),(l+1,r)。
复杂度分析:
左端点的移动,每个询问至多移动Q次,复杂度O(mQ)。
右端点的移动,每个块内至多移动n次,复杂度O(n*n/Q)。
平衡之后可以得到最佳块大小,复杂度$O(n\sqrt m)$。
【堆】
二叉堆
#include<cstdio> #include<algorithm> using namespace std; const int maxn=20010; int n,heap[maxn],sz; void heap_push(int x) { heap[++sz]=x;//新数入堆底 int now=sz;//以堆底为起点 while(now>1&&heap[now]<heap[now>>1])//非根节点的父亲>儿子时------注意非根判断 { swap(heap[now],heap[now>>1]);//交换即上推 now>>=1;//转移到父亲 } } int heap_pop() { int ans=heap[1];//取出答案 heap[1]=heap[sz--];//将堆底最后一个元素调上来 int now=1;//以堆顶为起点 while(now<=(sz>>1))//若now有儿子------儿子存在判断 { int next=now<<1;//令next为now的左儿子------儿子赋变量 if(next<sz&&heap[next]>heap[next|1])next++;//now有右儿子且右儿子更小时,令next为右儿子------左右儿子判断---注意右儿子存在判断 if(heap[next]>heap[now])return ans;//若根比儿子小,满足条件,退出 else { swap(heap[now],heap[next]);//交换即下推 now=next;//转移到儿子 } } return ans; } int main() { scanf("%d",&n); for(int i=1;i<=n;i++) { int u; scanf("%d",&u); heap_push(u); } long long ans=0; for(int i=1;i<n;i++) { int u=heap_pop(),v=heap_pop(); heap_push(u+v); ans+=u+v; } printf("%lld",ans); return 0; }
可并堆:左偏树(左偏树:定义沿右子节点往下到叶子的距离为深度,当x的左子节点深度小时交换,维护左子节点深度大的左偏性质)
#include<cstdio> #include<algorithm> using namespace std; const int maxn=1000010; int l[maxn],r[maxn],fa[maxn],d[maxn],a[maxn],n,m; bool die[maxn];//0生1死 int find(int x) {return fa[x]==x?x:fa[x]=find(fa[x]);} int merge(int x,int y)//返回x和y合并后子树的根 { if(!x)return y; if(!y)return x;//遇到一边为空节点则把另一边剩余的子树整颗接上去(返回) if(a[x]>a[y])swap(x,y);//将根节点更小的树放在左边 r[x]=merge(r[x],y);//递归合并左树右孩子和右树 if(d[l[x]]<d[r[x]])swap(l[x],r[x]);//维护左偏性质 d[x]=d[r[x]]+1;//更新节点距离 return x;//返回新树根 } int main() { scanf("%d",&n); for(int i=1;i<=n;i++)scanf("%d",&a[i]); for(int i=1;i<=n;i++)fa[i]=i; d[0]=-1;//因为后面的空节点都表示为0,因此会多次调用0。 scanf("%d",&m); for(int i=1;i<=m;i++) { char c=getchar(); while(c!='M'&&c!='K')c=getchar(); if(c=='M') { int x,y; scanf("%d%d",&x,&y); if(die[x]||die[y])continue; int p=find(x),q=find(y); if(p!=q) { int t=merge(p,q);//t是新根,可能是fa[x]或fa[y] fa[p]=fa[q]=t;//p,q的父亲变为新根,其他点父亲均不变 } } else { int x; scanf("%d",&x); if(die[x]){printf("0\n");continue;} int p=find(x);die[p]=1; printf("%d\n",a[p]); fa[p]=merge(l[p],r[p]);//返回新根(l[p]或r[p]),令原根的父亲为新根,由于并查集,不需要再修改 fa[fa[p]]=fa[p];//注意改变新根的父亲 //为什么不能直接加个if判断新根左右然后修改左右父亲啊?改完交了RE,存疑…… } } return 0; }
可并堆支持整体标记,详见【CodeForces】D. Roads in Yusland。
斜堆:在左偏树的基础上,每次直接暴力交换swap,可以证明复杂度均摊O(n log n),但是单次有可能爆栈。
题意:有一些点和区间的限制,求最多选择区间。
核心思想:双关键字排序实现扫描线,按关键字顺序不同有两种角度:
从区间角度出发:按区间右端点排序(消除右点对区间影响),从而当前堆里的区间只考虑向左。
从点的角度出发:按区间左端点排序(消除点对左区间影响),从而当前堆里的区间只考虑向右。
【反悔元素】Buy Low Sell High股票买卖 BZOJ1572
排序,直接选择所有元素然后把反悔元素加入堆中,每次超限就从堆中弹出。
可以反悔多次就加多个。
【一种套路】利用题目自带优劣情况,每次只考虑少量最优状态后拓展一些情况入堆。
求前k优问题:如果是满足每次取出一个元素,然后拓展出少量个元素,并且满足取出的元素不劣于拓展出的元素的问题,都可以用以上套路解决。
eg.给一个非负序列,输出前k小的子区间和。先把所有[i,i]加入堆,然后取最小拓展[i-1,i]&[i,i+1]入堆,因为本身满足大区间包含小区间,所以一次只需要考虑n个区间。
eg.超级钢琴
【线段树】标记的维护技巧和平衡树通用
特点:线段树又称“区间树”,对区间问题有强大的处理能力。
只要满足可并性(可以从左右子区间O(1)合并信息)和可标记性(区间可以仅根据标记修改参数),就可以使用线段树。(单点操作可以不用可标记性)
懒标记:打lazy标记的时候顺便把子树的其它参数都修改完毕,方便直接调用。
访问到有lazy标记的子树时若需要继续往下访问(即该子树区间不完全在规定区间内)就把标记下传给左右子树并修改左右子树的其他参数。
传递:修改时要下传和上传,查询时要下传。
技巧:
1.子树收缩:下传的逆过程,当两棵子树信息相同时合并存放在根节点处,减少访问量。
2.标记永久化:与顺序无关的标记(满足交换律),如区间叠最小,区间加等。
特别地,只有单点查询时不需要维护区间信息,都可以标记永久化。
3.矩阵面积并:一维差分并留下差分标记,一维维护线段树询问和根据差分标记修改。
4.维护幂和:要求查询区间数字x次幂的和(不是和幂),支持区间加值和区间覆盖。
线段树维护0~x次幂和,区间加值利用二项式定理,例如加y并维护二次幂和:
Σ(x+y)^2=Σ(x^2+2*x*y+y^2),其中Σx^2就是维护的二次幂和,Σx就是维护的幂和。
5.区间对一个数取max:无法维护区间信息
①单点查询,可以直接标记永久化或者传递修改都可以。
②区间查询,在序列满足单调性的前提下转化为区间覆盖。
例题:【CodeForces】671 C. Ultimate Weirdness of an Array
6.多标记相互影响时:假设B标记影响A标记,那么做modify(B)时要修改A标记,下传先传B标记,就可以了。(比如乘法把加法也乘了,覆盖把加值变0)
7.线段树上二分:先根遍历
①判断当前区间是否符合(一般用区间最右端点),否则返回r+1
②若l=r,返回
③依次查询左区间、中间、右区间,查到停止。
例题:【BZOJ】4293: [PA2015]Siano 线段树上二分
线段树上倍增:中根遍历(问一个端点L开始往左信息累加达到x的第一个位置)
①若l=r,返回(只进入一定有信息的区间,故能到叶子的一定是需要累加的)
②若L>=mid,直接访问右子树
③先访问左子树,返回累加到的位置y。
如果y不是左子树最后一位或者右子数第一位不满足, 那么直接返回y
如果右子树可以整棵都满足,那么直接加。
否则进入右子树。
#include<cstdio> #include<cstring> #include<cctype> #include<algorithm> using namespace std; int read(){ int s=0,t=1;char c; while(!isdigit(c=getchar()))if(c=='-')t=-1; do{s=s*10+c-'0';}while(isdigit(c=getchar())); return s*t; } const int maxn=500010; int n,m,a[maxn],ll,rr,L,R; char s[maxn]; struct tree{int l,r,L,R;}t[maxn*4]; void merge(int x,int y,int a,int b,int &p,int &q){ p=x,q=b; if(a>y)p+=a-y;else q+=y-a; } void up(int k){merge(t[k<<1].L,t[k<<1].R,t[k<<1|1].L,t[k<<1|1].R,t[k].L,t[k].R);} void build(int k,int l,int r){ t[k].l=l;t[k].r=r; if(l==r){t[k].L=a[l];t[k].R=!a[l];return;} int mid=(l+r)>>1; build(k<<1,l,mid);build(k<<1|1,mid+1,r); up(k); } void modify(int k,int x){ if(t[k].l==t[k].r){t[k].L^=1;t[k].R^=1;return;} int mid=(t[k].l+t[k].r)>>1; if(x<=mid)modify(k<<1,x);else modify(k<<1|1,x); up(k); } void query(int k,int l,int r){ if(l<=t[k].l&&t[k].r<=r){merge(ll,rr,t[k].L,t[k].R,ll,rr);return;} int mid=(t[k].l+t[k].r)>>1; if(l<=mid)query(k<<1,l,r); if(r>mid)query(k<<1|1,l,r); } int findr(int k,int pos,int x){ if(t[k].l==t[k].r)return merge(L,R,t[k].L,t[k].R,L,R),t[k].l; int mid=(t[k].l+t[k].r)>>1; if(pos>mid)return findr(k<<1|1,pos,x);else{ int y=findr(k<<1,pos,x); if(L==x)return y; int l,r; merge(L,R,t[k<<1|1].L,t[k<<1|1].R,l,r); if(l>=x)return findr(k<<1|1,pos,x); else return L=l,R=r,t[k].r; } } int findl(int k,int pos,int x){ if(t[k].l==t[k].r)return merge(t[k].L,t[k].R,L,R,L,R),t[k].l; int mid=(t[k].l+t[k].r)>>1; if(pos<=mid)return findl(k<<1,pos,x);else{ int y=findl(k<<1|1,pos,x); if(R==x)return y; int l,r; merge(t[k<<1].L,t[k<<1].R,L,R,l,r); if(r>=x)return findl(k<<1,pos,x); else return L=l,R=r,t[k].l; } } int main(){ freopen("grancrevasse.in","r",stdin); freopen("grancrevasse.in","r",stdin); n=read();m=read(); scanf("%s",s+1); for(int i=1;i<=n;i++)a[i]=s[i]-'0'; build(1,1,n); while(m--){ int k=read(); if(k==1)modify(1,read()); else{ int l=read(),r=read(),x=read(); ll=0,rr=0;query(1,l,r);L=0;R=0; if(x>ll+rr)printf("-1\n"); else if(x<=ll)printf("%d\n",findr(1,l,x)); else printf("%d\n",findl(1,r,ll+rr-x+1)); } } return 0; }
留坑:zkw线段树 统计的力量
【树状数组】
推荐:搞懂树状数组(只要耐心读就能明白了)
特点:树状数组利用二进制分组规律,主要用于维护动态前缀和。
树状数组本质是将数字按二进制的1进行分组,每个1统领一部分。
c[i]只统领i的二进制中最低位的1代表的部分。
如c[100]统领a[001].a[010].a[011].a[100]
c[1100]统领a[1001].[1010].[1011].[1100]
求和时,将一个数字拆成各个1统领的分组求和,如:
sum(1110)=c(1110)+c(1100)+c(1000)
c(1110)统领1101-1110(2个数字)
c(1100)统领1001-1100(4个数字)
c(1000)统领1-1000(8个数字)
显然,1110---1100---1000可以通过每次消去最低位的1(获取次低位的1)来推进
此时就需要lowbit(k)=k&(-k)表示k最低位的1代表的数字,lowbit(1110)=10,lowbit(1100)=100等。
而每个数字(设初始数组或插入或改变本质上都是一样的)对c数组会影响统领它的1,如1010会影响1100.10000
1010归1100直接统领,1010也就会直接影响1100。
1100(显然不是归1000统领)归10000直接统领,1100也就会直接影响10000。
c[1010]改变,影响了c[1100];c[1100]改变,又影响了c[10000]。
显然这种推进可以用+lowbit(k),得到比当前最低位1上一位的1完成。
int lowbit(int x){return x&(-x);} int query(int x){int ss=0;while(x<=n){ss+=c[x];x-=lowbit(x);}return ss;} void modify(int x){while(x<=n){c[x]+=k;x+=lowbit(x);}}
应用:
1.可以O(n log n)地查询前缀最小值(因为不需要可差分性)
2.可以O(n log n)地寻找前缀和为k的最小位置,也就是可以代替平衡树的排名功能。
见【BZOJ】3173: [Tjoi2013]最长上升子序列(树状数组)
3.可以线性建树,1~n每个数字对自身+1,再对父亲贡献,具体可以见上面链接。
4.树状数组求逆序对:离散化后按顺序将对应位置+1,每次ans+=i-getsum(i)。
【扫描线】链接
【并查集】fa[i]=i;
用于合并集合。用于维护图的连通(支持加边)。
int getfa(int x) {return fa[x]==x?x:(fa[x]=getfa(fa[x]));} for(int i=1;i<=n;i++)fa[i]=i;
带权并查集要注意父亲顺序问题,即先用原父亲计算距离再更换为新父亲。
★<支持删除地查找前驱后继>合并几个空的点和一个满的点,思想是将处理过的合并起来,如花神的游历
★<排序+并查集>
经典套路:找树上所有路径的边最值(对于每条路径,求出路径上的最大边)。
排序后,按顺序对边两端并起来,这样路径的最值就会自然在最后并,在每次合并时这条边就是两端集合互通的所有路径的最值。
同时,并查集保证每个点刚好和其它所有点配对一次,这样的套路是:
【把一部分点和另一部分点配对,然后并起来,重复到只剩一个集合为止,此时保证两两配对完毕】
eg.Codeforces From Y to Y
经典套路:利用无后效性,将处理过的点并起来。
MST的kruskal算法就是这样的思想,处理过的边就最优了且两集合可以视为整体,于是并起来。
还有bzoj安全路径也是,排序后将最小的处理后并起来,下次处理就可以直接跳过(因为不可能更优了,于是不可能处理到已经并起来的点)。
无用并之,是排序并查集思想的核心,排序就是为了满足扫过的都无用了。
★<倒序>将删边改为加边,从而变成并查集。
【可持久化权值线段树(主席树)】解决区间权值相关的查询问题
可持久化原本是指保存历史版本的经典手法——只赋值修改部分,对于线段树而言就是只复制一条链。
后来这种手法不仅用于保存历史版本,还大量用于可以基于原线段树直接构造线段树的情境,这之中重要的应用就是可持久化权值线段树。
可持久化权值线段树,一般也称之为主席树,线段树中存放每个权值相关的变量(一般为出现次数和),建树时旧树传递变量作为依据,新树传地址,新树作为被修改的链需要赋初值(或改)。y=++sz;
不带修改时,第i棵表示1~i的前缀和,第i棵在第i-1棵的基础上建树,差分查询区间,复杂度O(n log n)。
例题:【BZOJ】3524: [Poi2014]Couriers
带修改时,树状数组套可持久化权值线段树,第i棵表示树状数组中的第i个,基于本身建树(只建一条链),基于本身修改,复杂度O(n log2n)。
例题:【BZOJ】1901: Zju2112 Dynamic Rankings
特别注意,
1.空间开大。
2.分清tot(权值范围)和n(数组范围)的区别。
一般在结构体中开左右孩子,左右区间直接传参。
应用:
通过可持久化很容易取出指定区间的权值线段树,那么一个区间能被解决关键看能否在其权值线段树上询问。
而且可持久化权值线段树只支持单点修改。
查询区间不同数字的个数:记录每个数字i上一次出现的位置lasti,维护不带修改可持久化权值线段树,权值为lasti,对于区间找lasti<L的和。
区间第k小:找到sum<=k的最靠左的权值(位置)。
树上区间第k小:count on a tree。对于每个点在其父亲的基础上可持久化,然后查询ans=ask(l)+ask(r)-ask(lca(l,r))-ask(fa[lca(l,r)]),这样刚好不重不漏一条链。
【启发式合并】
普通的启发式合并就是把size偏小的数据结构依次弹出后插入size较大的。
这样将n个合并成一个(假设一次合并复杂度为O(size))的均摊复杂度是O(n log n)。
【线段树合并】
例题:【BZOJ】4756: [Usaco2017 Jan]Promotion Counting
用到线段树合并的题目通常有个特点:很容易想出一种DP方法,每个点的状态是一个数组,状态转移需要考虑数组合并。
然后把数组换成线段树就可以了www。
merge(x,y)的三步骤:
1.x和y有空,返回x^y。
2.叶子结点信息直接合并返回(可能不需要)
2.左右儿子合并x.l=merge(x.l,y.l) x.r=merge(x.r,y.r)
3.信息上传x.sum=calc(x.l,x.r)
显然需要动态开点。
n棵单链树合并n-1次的复杂度为O(n log n),证明:每次合并等价于消除两棵线段树的交集,n棵单链树总共n log n个结点。
带标记的线段树合并:
1.动态开点:下传的时候开新点!因为每次操作至多log n次,所以复杂度正确。
2.合并:合并的过程切忌下传,因为下传开新点最终会遍历整棵树。
正确方法是直接连同标记一起合并,记得标记也要一起合并,这样也不需要上传。
#include<stdio.h> #include<string.h> #include<stdlib.h> #include<algorithm> //#include<iostream> //#include<assert.h> #include<ctime> using namespace std; int n; #define maxn 100011 struct Edge{int to,next;}edge[maxn<<1]; int first[maxn],le=2,val[maxn]; bool in(int x,int y) {Edge &e=edge[le]; e.to=y; e.next=first[x]; first[x]=le++; return 0;} int lisa[maxn],li=0; int root[maxn]; struct SMT { struct Node { int ls,rs; int Min,Max,be,add; }a[maxn*40]; int size,n; void clear(int m) {size=0; n=m;} void New(int &x) {x=++size; a[x].ls=a[x].rs=0; a[x].Min=0; a[x].Max=0; a[x].be=-1; a[x].add=0;} void up(int x) { a[x].Min=min(a[a[x].ls].Min,a[a[x].rs].Min); a[x].Max=max(a[a[x].ls].Max,a[a[x].rs].Max); } void besingle(int &x,int v) { if (!x) New(x); a[x].Min=a[x].Max=a[x].be=v; a[x].add=0; } void addsingle(int &x,int v) { if (!x) New(x); a[x].Min+=v; a[x].Max+=v; if (a[x].be==-1) a[x].add+=v; else a[x].be+=v; } void down(int x) { if (a[x].be!=-1) besingle(a[x].ls,a[x].be),besingle(a[x].rs,a[x].be),a[x].be=-1; if (a[x].add) addsingle(a[x].ls,a[x].add),addsingle(a[x].rs,a[x].add),a[x].add=0; } void combine(int &x,int y,int L,int R) { if (!x || !y) {x=x^y; return;} if (a[y].be!=-1) {addsingle(x,a[y].be); return;} if (a[x].be!=-1) {addsingle(y,a[x].be); x=y; return;} a[x].Max+=a[y].Max-a[y].add; a[x].Min+=a[y].Min-a[y].add; addsingle(x,a[y].add); if (L==R) return; int mid=(L+R)>>1; combine(a[x].ls,a[y].ls,L,mid); combine(a[x].rs,a[y].rs,mid+1,R); } void combine(int &x,int y) {combine(x,y,1,n);} int mo(int &x,int L,int R,int pos,int v){ if(!x)New(x); if(L==R){besingle(x,v); return R;} int mid=(L+R)>>1, y; down(x); if(pos<=L && a[x].Max<=v) {besingle(x,v); return R;} if(pos>mid) y=mo(a[x].rs, mid+1, R, pos, v);else{ y=mo(a[x].ls, L, mid, pos, v); if(y!=mid || a[a[x].rs].Min>=v) return up(x), y; if(a[x].Max<=v) besingle(a[x].rs, v),y=R; else y=mo(a[x].rs, mid+1, R, pos, v); } up(x); return y; } void modify(int &rt,int pos,int v) { mo(rt,1,n,pos,v);} int query(int &x,int L,int R,int pos) { if (!x) New(x); if (L==R) return a[x].Min; down(x); int mid=(L+R)>>1; if (pos<=mid) return query(a[x].ls,L,mid,pos); else return query(a[x].rs,mid+1,R,pos); } int query(int &rt,int pos) {return query(rt,1,n,pos);} }t; void dfs(int x,int dep) { int v=1; for (int i=first[x];i;i=edge[i].next) { Edge &e=edge[i]; dfs(e.to,dep+1); v+=t.query(root[e.to],val[x]-1); t.combine(root[x],root[e.to]); } t.modify(root[x],val[x],v); } int main() { //freopen("tree.in","r",stdin); //freopen("tree.out","w",stdout); int o1=clock(); scanf("%d",&n); for (int i=1,x;i<=n;i++) scanf("%d%d",&val[i],&x),(x && in(x,i)),lisa[++li]=val[i]; lisa[++li]=0; sort(lisa+1,lisa+1+li); li=unique(lisa+1,lisa+1+li)-lisa-1; for (int i=1;i<=n;i++) val[i]=lower_bound(lisa+1,lisa+1+li,val[i])-lisa; t.clear(li); dfs(1,1); printf("%d\n",t.query(root[1],li)); int o2=clock(); printf("time=%d\n",o2-o1); return 0; }
【CDQ分治】时间分治算法
推荐课件:(Day1)cdq分治相关
CDQ分治适用于 不单调的斜率优化 和 在偏序问题中代替一维数据结构。
CDQ分治的核心思想是对时间分治,每次只统计时间维左边的修改对时间维右边的询问的影响。CDQ分治的结构类似线段树,将对询问x有影响的修改分成log n次处理(祖先),也就是每个询问和修改只在其LCA处处理,这样复杂度O(n log n)。
所以CDQ分治解决问题仅限于:离线,修改影响可拆分,单点修改,偏序询问。
三维偏序问题:t维CDQ分治(离散化),x维排序扫描线,y维树状数组。
按操作顺序分治,每次只计算时间维左区间的修改对时间维右区间的影响,递归进行,一般有以下步骤:
★先全部按x,y,t顺序从小到大排序。
①按x维顺序计算t左区间的修改和t右区间的影响。
②消除t左区间的修改。
③将数组按t维分成左区间和右区间(子区间内仍为x维顺序)。
④递归处理两个子区间。
★经典例题:【BZOJ】3262: 陌上花开
矩阵和点:只能在[矩阵修改单点查询]和[单点修改矩阵查询]中二选一。
①前缀和表示单点信息,矩阵修改转化为四个单点修改,单点查询转化为前缀和查询。
②单点表示单点信息,单点修改,矩阵差分成四次查询前缀和。
★经典例题:【BZOJ】1176: [Balkan2007]Mokia
CDQ分治优化DP:辅助斜率优化
当$j<k,x_j<x_k,ans_k>ans_j$时,存在两种方程:
$$\frac{y_j-y_k}{x_j-x_k}>k_i$$
此时$k_i$从大到小排序,维护斜率从大到小的上凸包。
$$\frac{y_j-y_k}{x_j-x_k}<k_i$$
此时$k_i$从小到大排序,维护斜率从小到大的下凸包。
列出决策比较式(y[j]-y[k])/(x[j]-x[k])>k[i],如果不满足x[]和k[]均单调,就使用CDQ分治优化。
第 i 阶段决策实际上是在前i-1个点的上凸包中找到斜率最接近的k[i]的边,然后将第i个点加入维护动态上凸包(平衡树)。
这实际上是二维偏序,阶段一维默认排序,斜率一维用平衡树动态维护。
现在我们考虑用CDQ分治离线代替平衡树,需要改变之前CDQ分治的写法。
初始按斜率$k_i$排序,分配区间按编号$i$分治,退出时按横坐标$x_i$排序,这样每次处理左子区间按$x_i$构造凸包,右子区间按$k_i$顺序决策。
★按斜率排序(ki),对阶段分治,每次:
1.按阶段分配左右子区间。
2.递归分治左子区间
3.左子区间用栈构造凸包(已按x[]排序),右子区间顺序决策(已按k[]排序)
4.递归分治右子区间
5.按x[]归并排序整个区间
阶段分配左右子区间,左子区间按x[]排序,右子区间按k[]排序,最终按x[]排序。
★经典例题:【BZOJ】1492: [NOI2007]货币兑换Cash
【Link-Cut Tree】
Link-Cut Tree简称lct,是解决动态树问题的常用数据结构。
lct=树链剖分+splay。
一、lct和树链剖分一样将树分成若干重链,对每条重链维护一棵按深度排序的splay。
二、轻边x-y(y深度大)表现为y所在spaly的根的父亲设为x,但是x不记y这个儿子(因为lct的唯一核心操作access是从下往上,所以不用担心父亲变更的问题)
三、一棵splay只能有一个父亲,记为根的父亲(可以随时替换根,父亲不变),表示这棵splay的最左端节点和根的父亲之间有一条轻边。
每颗splay的根没有意义,而最左端节点是重链最小深度的点,最右端节点是重链最大深度的点。
lct的根是变动的,是主链splay(深度最小的splay)的最左端节点。
<splay(x)>将x旋转到所在splay的根,旋转前先整链下传,所以rotate就不需要下传了。
<isroot(x)>判断x是否splay的根,只须判断x的父亲的儿子是否为x。(还有!x的情况也是根)
<access(x)>将根到x的路径变成一条重链。方法是每跳到一条重链的位置x,将x旋转到根后,右节点设为上一棵splay的根,这样根到x的路径就会接成一棵完整的splay。
而每次x和原来的右节点断开后,其父亲仍指向x而x不指向它,就变成了一条轻边。结束后x为主链splay的最右端结点,一般后面加splay(x)来定位到根(切记不能在access里最后来个splay,因为x已经变了)。
void access(int x) { int y=0; while(x) { splay(x); t[x][1]=y; y=x;x=f[x]; } }
<reserve>access(x);splay(x);g[x]^=1;
将x变成主链splay的根,翻转后x就是主链的根,即所在树lct的根。
<link>reserve(x);fa[x]=y;
将x变成所在lct的根,然后作为y的轻儿子连入。
<cut>reserve(x);access(y);splay(y);t[y][0]=f[x]=0;
reserve(x)使x成为该树根节点,access(y)使y接到主链上,splay(y)使y成为splay的根,此时x是y的左子节点(原树根),断连即可。
<findroot>将x变成主链splay的根之后,不断往左就能找到,一般用于判断两点是否连通(在同一棵树上)。
★例题:【BZOJ】2049: [Sdoi2008]Cave 洞穴勘测 LCT
一道神奇的题:[WerKeyTom_FTD的模拟赛]Sone0,下面几点比较有趣:
1.在原树形态的基础上,换根求新子树:分类讨论新根root和查询位置x的位置即可,不用真正换根。(重组病毒)
2.开方到区间全1即可返回。(花神游历各国)
3.动态树的链splay轮换:分别建形态splay和权值splay,改权只改权,改形一起改。
4.动态维护子树大小:sz记录每个节点的虚子树节点总数。
【点分治】
每次找到一个区域的重心,以重心为根划分成若干子区域。点分治中每个点都会作为重心一次,一条路径只会作为跨越重心的路径被访问一次,因此主要用于处理树上所有路径的询问问题。
这样至多log n层,所以总复杂度O(n log n)。需要特别注意点分治的常数很大。还需要注意点分治过程中的所有操作必须和点数相关(不能和权值相关),否则复杂度不对。
三种统计方法:
1.加所有子树信息,依次删除一棵子树进去统计后再加回。(4)
特点:最万能的方法,复杂度也最高,要求信息支持删除。
例题:【CodeForces】914 E. Palindromes in a Tree 点分治
2.加所有树的信息,然后进入每棵子树统计,然后再进入每棵子树删除来自同一棵子树的路径。(3)
特点:要求答案支持删除。
3.每棵子树 i 和前1~i-1棵子树的信息合并后加入。(2)
特点:路径只能单向统计。
例题:【BZOJ】2599: [IOI2011]Race 点分治
4.将所有子树的信息取出来单独处理。(1)
特点:适用于特殊的题目,例如排序双指针(n log2n),但是注意取出来后复杂度依然要保证O(点数)。
例题:【BZOJ】1468: Tree(POJ1741) 点分治
【dsu on tree】
例题:
未完待续——
【点分治】
其它:树的重心及动态维护
【树套树】
一个数据结构里附加了另一个数据结构的根节点,如树状数组套线段树、线段数套线段数,都是O(log2n)。
动态逆序对:一维序号一维大小,一行为一颗线段树,列为树状数组。