Treap,Splay & LCT 学习笔记

从二叉搜索树到平衡树

二叉搜索树(Binary Search Tree)是一种二叉树的树形数据结构,它维护一个集合,并保证它的中序遍历按照递增顺序给出了这个集合的所有元素。由此,可以完成插入,删除,查找元素,查询排名等操作:按照定义确定递归左子树或右子树即可。

可以看出 BST 的时间复杂度与树高相关,那么最优情况下可以达到单次操作 \(O(\log n)\)。但是 BST 很容易退化,最坏情况下会直接退化为链表。于是定义了 BST 的平衡。通常来说,“平衡”会定义为每个结点的左子树和右子树高度差不超过 \(1\)。但实际上在算法竞赛中,只要单次操作均摊 \(O(\log n)\),就可以称作平衡树了。

对不满足平衡条件的 BST 进行调整,可以使它重新具有平衡性。基本的调整操作是旋转,又分为左旋和右旋。如图所示(图源 OI-wiki),对于结点 \(A\) 的右旋操作是指:将 \(A\) 的左孩子 \(B\) 向右上旋转,代替 \(A\) 成为根节点,将 \(A\) 结点向右下旋转成为 \(B\) 的右子树的根结点,\(B\) 的原来的右子树变为 \(A\) 的左子树。左旋类似。

图源 OI-wiki

一句话概括,左旋是让右儿子变成根结点,右旋是让左儿子变成根结点。

接下来介绍两种平衡树:Treap 和 Splay。其中 Treap 又可以分为有旋和无旋。

Treap

Treap=Tree+Heap。

顾名思义,Treap 是一棵满足堆性质的 BST。很显然这两个性质是矛盾的,这里的堆性质实际上是给每个元素额外赋予的一个值 priority。这个值是随机给出的。感性理解,这样随机化之后树高就是期望 \(O(\log n)\) 的。

旋转Treap

我们知道旋转是不改变 BST 性质的,所以用旋转维护堆性质就可以了。下面来具体考察一下每个操作。

  • 插入:插入一个点之后,如果当前位置不满足堆性质,就要不断往上旋转。
  • 删除:先找到这个点,如果这个点不是叶子,就先用旋转把它变成叶子结点,然后删掉。旋转过程中选择左右儿子中更大的一个转到根(假如是大根堆)。
  • 查询排名,第 \(k\) 大,前驱,后继等操作不影响 BST 的结构,不需要额外说明。

注意实现的时候并不记录父亲结点,所以要区分左旋和右旋。模板题代码。

#include<bits/stdc++.h>
using namespace std;
mt19937 rnd(time(0));
const int N=1e5+10,INF=1<<30;
int Rand(int l=1,int r=(1<<29)){return rnd()%(r-l+1)+l;}
int rt,val[N],cnt[N],sz[N],ls[N],rs[N],pri[N],tot;
int node(int v){val[++tot]=v;pri[tot]=Rand();sz[tot]=cnt[tot]=1;return tot;}
void push_up(int p){sz[p]=cnt[p]+sz[ls[p]]+sz[rs[p]];}
void zig(int&p){int q=ls[p];ls[p]=rs[q];rs[q]=p;push_up(p);push_up(q);p=q;}
void zag(int&p){int q=rs[p];rs[p]=ls[q];ls[q]=p;push_up(p);push_up(q);p=q;}
void init(){rt=node(-INF);rs[rt]=node(INF);push_up(rt);}
void ins(int&p,int x){
    if(!p){p=node(x);return;}
    if(x==val[p])++cnt[p];
    else if(x<val[p]){ins(ls[p],x);if(pri[p]<pri[ls[p]])zig(p);}
    else {ins(rs[p],x);if(pri[p]<pri[rs[p]])zag(p);}
    push_up(p);
}
void del(int&p,int x){
    if(!p)return;
    if(x==val[p]){
        if(cnt[p]>1){--cnt[p];push_up(p);return;}
        if(ls[p]||rs[p]){
            if(!rs[p]||pri[ls[p]]>pri[rs[p]])zig(p),del(rs[p],x);
            else zag(p),del(ls[p],x); push_up(p);
        }else p=0; return;
    }
    if(x<val[p])del(ls[p],x);else del(rs[p],x);
    push_up(p);
}
int rnk(int p,int x){
    if(!p)return 1;
    if(x==val[p])return sz[ls[p]]+1;
    else if(x<val[p])return rnk(ls[p],x);
    else return sz[ls[p]]+cnt[p]+rnk(rs[p],x);
}
int kth(int p,int k){
    if(!p)return INF;
    if(k<=sz[ls[p]])return kth(ls[p],k);
    else if(k<=sz[ls[p]]+cnt[p])return val[p];
    else return kth(rs[p],k-sz[ls[p]]-cnt[p]);
}
int prev(int x){
    int p=rt,pre=-INF;
    while(p){
        if(val[p]<x)pre=val[p],p=rs[p];
        else p=ls[p];
    }
    return pre;
}
int next(int x){
    int p=rt,nxt=INF;
    while(p){
        if(val[p]>x)nxt=val[p],p=ls[p];
        else p=rs[p];
    }
    return nxt;
}
int main(){
    init();int q;scanf("%d",&q);
    while(q--){
        int op,x;scanf("%d%d",&op,&x);
        if(op==1)ins(rt,x);
        if(op==2)del(rt,x);
        if(op==3)printf("%d\n",rnk(rt,x)-1);
        if(op==4)printf("%d\n",kth(rt,x+1));
        if(op==5)printf("%d\n",prev(x));
        if(op==6)printf("%d\n",next(x));
    }
    return 0;
}

无旋Treap

无旋Treap,又称 Fhq-Treap,顾名思义就是不用旋转来满足堆性质的平衡树。它的两种基本操作是分裂与合并。

分裂(Split)是指将一棵 Treap 按照中序遍历的顺序分割成左右两半,满足两半组成的 Treap 所有值都不变。它需要一个参数 \(k\),表示把中序遍历的前 \(k\) 个结点分离出来。具体实现很容易,要么一个子树的左子树和根都在第一棵树内,要么一个子树的右子树和根都在第二棵树内,于是递归下去就可以了。

合并(Merge)是将两棵(由原先的 Treap Split 得到的)Treap 合并在一起,按照中序遍历的顺序,并且所有结点的值都不变。注意第一棵树的所有数小于第二棵树的所有数。合并操作先比较两棵树的根的 pri 值决定以那个点为根,然后递归到子树内即可。

听起来很玄学,那么具体看看各种操作怎么实现。

  • 查询排名和原来是一样的。
  • 插入 \(x\),先查询 \(x\) 的排名 \(k\),然后按照 \(k\) 做一次 Split,把 \(x\) 看作一个新结点,做两次 Merge。
  • 删除 \(x\),先查询 \(x\) 的排名 \(k\),然后按照 \(k-1,k\) 做两次 Split,丢掉中间那个点,把剩下两个树 Merge 起来。
  • 查询第 \(k\) 大,按照 \(k-1,k\) 做两次 Split,然后中间那个就是需要的答案。
  • 前驱就是 kth(rnk(val-1))。后继就是 kth(rnk(val+1))

无旋 Treap 相较于带旋 Treap 的优势,除了常数和(可能)好写之外,更重要的是它的可拓展性。比如说,无旋 Treap 可以方便地支持区间操作:Split 操作得到的就是一个个区间。那么进一步,还可以像线段树一样打区间标记和懒惰标记,等等。

模板题代码。给每个结点记录一个翻转标记。

#include<bits/stdc++.h>
using namespace std;
mt19937 rnd(time(0));
int Rand(int l=1,int r=(1<<29)){return rnd()%(r-l+1)+l;}
const int N=1e5+5;
int n,m;
int rt,val[N],sz[N],pri[N],ls[N],rs[N],tag[N],tot;
int node(int v){val[++tot]=v;sz[tot]=1;pri[tot]=Rand();return tot;}
void push_up(int p){sz[p]=sz[ls[p]]+sz[rs[p]]+1;}
void push_down(int p){
    if(!tag[p])return;tag[p]=0;
    tag[ls[p]]^=1;tag[rs[p]]^=1;swap(ls[p],rs[p]);
}
void split(int p,int k,int&u,int&v){
    if(!p){u=v=0;return;} push_down(p);
    if(k<=sz[ls[p]])v=p,split(ls[p],k,u,ls[p]);
    //如果分点在左子树中,那么把当前结点作为第二个子树的根
    //递归下去,两棵子树分别是第一个子树和当前结点的左儿子
    else u=p,split(rs[p],k-sz[ls[p]]-1,rs[p],v);
    push_up(p);
}
int merge(int p,int q){
    if(!p||!q)return p^q;
    if(pri[p]>pri[q]){//比较优先级
        push_down(p);rs[p]=merge(rs[p],q);
        push_up(p);return p;
    }
    else{
        push_down(q);ls[q]=merge(p,ls[q]);
        push_up(q);return q;
    }
}
void print(int p){
    if(!p)return;push_down(p);
    print(ls[p]),printf("%d ",val[p]),print(rs[p]);
}
int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++)node(i),rt=merge(rt,tot);
    for(int i=1,l,r;i<=m;i++){
        scanf("%d%d",&l,&r);int u,mid,v;
        split(rt,r,u,v);split(u,l-1,u,mid);
        tag[mid]^=1;rt=merge(merge(u,mid),v);
    }
    print(rt);
    return 0;
}

除了按照排名 Split,按照权值 Split 也是可以的。后者一般用在数字会有重复的情形下。但很多时候我们使用 Fhq-Treap 是为了按照顺序维护一个序列,所以并不常用。

Splay

Splay 树是一种均摊的平衡树,它也是用旋转来维持平衡的。这里和 Treap 的旋转可能略有区别,旋转是放在子结点上的,对左儿子的旋转叫右旋,对右儿子的旋转叫左旋。

Splay 树的独有操作是伸展(splay),即把一个点通过旋转变成根结点。一个直接的做法就是每一次都对目标结点旋转,这种做法称为单旋。然而单旋的复杂度是错误的,我们需要使用双旋。也就是说,我们额外判断一下当前结点的父结点是否同为左儿子(或同为右儿子),如果是,就先旋转父结点,再旋转子结点。

要维持平衡,只需要在每一次操作之后,都对最终访问的结点做 splay 操作。以 \(\sum \log(sz(x))\) 为势能函数分析可以得到复杂度。因此 splay 的实现并没有什么特殊的地方。

模板题代码。

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5,INF=1e9;
int n;
struct Splay{
	int rt,tot,fa[N],ch[N][2],val[N],cnt[N],siz[N];
	void push_up(int p){siz[p]=siz[ch[p][0]]+siz[ch[p][1]]+cnt[p];}
	bool get(int p){return p==ch[fa[p]][1];}
	void clear(int p){fa[p]=ch[p][0]=ch[p][1]=val[p]=cnt[p]=siz[p]=0;}
	void rotate(int x){
		int y=fa[x],z=fa[y],op=1-get(x);
		ch[y][op^1]=ch[x][op];if(ch[x][op])fa[ch[x][op]]=y;
		ch[x][op]=y;fa[y]=x;fa[x]=z;if(z)ch[z][y==ch[z][1]]=x;
		push_up(y);push_up(x);
	}
	void splay(int x,int goal=0){
		for(int p=fa[x];p!=goal;p=fa[x]){
			if(fa[p]!=goal)rotate(get(p)==get(x)?p:x);
			rotate(x);
		}if(!goal)rt=x;
	}
	void ins(int v){
		if(!tot){rt=tot=1;val[1]=v;cnt[1]=siz[1]=1;return;}
		int p=rt,f=0;
		while(1){
			if(val[p]==v){++cnt[p];++siz[p];push_up(f);splay(p);break;}
			f=p;p=ch[p][val[p]<v];
			if(!p){
				val[p=++tot]=v;cnt[p]=siz[p]=1;
				fa[tot]=f;ch[f][val[f]<v]=p;
				push_up(f);splay(p);break;
			}
		}
	}
	bool find(int v){
		int p=rt;
		while(p){
			if(val[p]==v){splay(p);return true;}
			p=ch[p][val[p]<v];
		}return false;
	}
	void merge(int x,int y){
		while(ch[x][1])x=ch[x][1];
		splay(x);ch[x][1]=y;fa[y]=x;push_up(x);
	}
	void del(int v){
		if(!find(v))return;
		if(cnt[rt]>1){--cnt[rt],--siz[rt];return;}
		int x=ch[rt][0],y=ch[rt][1];
		fa[x]=fa[y]=0;clear(rt);
		if(!x||!y){rt=x+y;return;}
		merge(x,y);
	}
	int rank(int v){
		find(v);
		return siz[ch[rt][0]]+1;
	}
	int kth(int k){
		int p=rt;
		while(1){
			if(ch[p][0]&&k<=siz[ch[p][0]])p=ch[p][0];
			else{
				k-=cnt[p]+siz[ch[p][0]];
				if(k<=0){splay(p);return val[p];}
				p=ch[p][1];
			}
		}
	}
	int nxt(int x,int op){
		ins(x);int p=ch[rt][op^1];
		if(!p)return -1;
		while(ch[p][op])p=ch[p][op];
		int res=val[p];del(x);
		return res;
	}
}cst;
int main(){
	scanf("%d",&n);
	cst.ins(INF);cst.ins(-INF);
	for(int i=1,op,x;i<=n;i++){
		scanf("%d%d",&op,&x);
		if(op==1)cst.ins(x);
		if(op==2)cst.del(x);
		if(op==3)printf("%d\n",cst.rank(x)-1);
		if(op==4)printf("%d\n",cst.kth(x+1));
		if(op==5)printf("%d\n",cst.nxt(x,1));
		if(op==6)printf("%d\n",cst.nxt(x,0));
	}
	return 0;
}

Splay 和 Fhq-Treap 一样可以进行区间操作。具体来说,在 splay 的时候,我们不一定会将一个结点旋转到根,而是可以旋转到某个结点的儿子。这时,我们注意到在维护序列时,Splay 的一棵子树就代表一个区间,因此要提取区间 \([l,r]\),只要先将 \(l-1\) splay 到根,再将 \(r+1\) splay 到根的右儿子,需要的子树就是 \(r+1\) 的左儿子。

模板题代码。

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5;
int n,m,ans[N],cnt;
struct Splay{
	int rt,tot,fa[N],ch[N][2],val[N],tag[N],siz[N];
	void push_up(int p){siz[p]=siz[ch[p][0]]+siz[ch[p][1]]+1;}
	int build(int l,int r){
		if(l==r){push_up(l);return l;}
		int p=l+r>>1;
		if(l<p)fa[ch[p][0]=build(l,p-1)]=p;
		if(p<r)fa[ch[p][1]=build(p+1,r)]=p;
		push_up(p);
		return p;
	}
	bool get(int p){return p==ch[fa[p]][1];}
	void push_down(int p){
		if(!tag[p])return;
		tag[ch[p][0]]^=1;tag[ch[p][1]]^=1;
		tag[p]=0;swap(ch[p][0],ch[p][1]);
	}
	void rotate(int x){
		int y=fa[x],z=fa[y],op=get(x)^1;
		ch[y][op^1]=ch[x][op];if(ch[x][op])fa[ch[x][op]]=y;
		ch[x][op]=y;fa[y]=x;fa[x]=z;if(z)ch[z][y==ch[z][1]]=x;
		push_up(y);push_up(x);
	}
	void splay(int x,int goal){
		for(int p=fa[x];p!=goal;p=fa[x]){
			if(fa[p]!=goal)rotate(get(p)==get(x)?p:x);
			rotate(x);
		}if(!goal)rt=x;
	}
	int kth(int k){
		int p=rt;
		while(1){
			push_down(p);
			if(ch[p][0]&&k<=siz[ch[p][0]])p=ch[p][0];
			else{
				k-=siz[ch[p][0]]+1;
				if(k<=0)return p;
				p=ch[p][1];
			}
		}
	}
	void update(int l,int r){
		l=kth(l-1);splay(l,0);
		r=kth(r+1);splay(r,l);
		tag[ch[r][0]]^=1;
	}
	void dfs(int p){
		push_down(p);
		if(ch[p][0])dfs(ch[p][0]);
		if(p!=1&&p!=n+2)printf("%d ",p-1);
		if(ch[p][1])dfs(ch[p][1]);
	}
}cst;
int main(){
	scanf("%d%d",&n,&m);
	cst.rt=cst.build(1,n+2);
	for(int i=1,l,r;i<=m;i++){
		scanf("%d%d",&l,&r);
		cst.update(l+1,r+1);
	}
	cst.dfs(cst.rt);
	return 0;
}

LCT

写了两天平衡树突然不想写了。会补吗?会补的。

posted @ 2024-04-28 08:19  by_chance  阅读(9)  评论(0编辑  收藏  举报