Splay 树

Splay 树的思想为通过双旋来改善数的平衡。它的基本操作是将一个点旋转至根或另一结点的儿子。在旋转过程中,Treap 树只是一层一层地旋转,不能改善平衡性;而 Splay 树一次考虑两层,再通过不同的方式来改善平衡。

1. Splay 旋转

有两种方式,分别为一字旋和之字旋。记单旋时左旋为 zay,右旋为 zig。

若要旋转点 \(u\) 至根或另一结点 \(p\) 的儿子,那么分为以下几种情况:

\(u\) 的父亲是根或 \(p\)

此时只需要一次单旋即可,\(u\) 会上升一层。

否则 \(u\) 有父亲 \(f\) 和祖父 \(g\)。若 \(u,f,g\) 在同一直线上,那么先旋转 \(f\),再旋转 \(u\)。否则旋转两次 \(u\)

我们来看一下它的旋转过程:

  1. 单旋

做一次 zig 或 zag 即可。

  1. 一字旋

做 zig-zig 或 zag-zag。应先旋转 \(f\),再旋转 \(u\)。下图是 zig-zig 的过程。

在极端情况下,比如树是一条链,那么一字旋可以改善平衡。如下图。

如果只是一层一层地单旋,那么不能改善平衡性。

  1. 之字旋

做 zig-zag 或 zag-zig。先旋转 \(u\),在旋转 \(f\)

不能先旋转 \(f\) 再旋转 \(u\),否则在旋转 \(f\) 时,\(u\) 的深度不会改变。

可以证明,Splay 树的时间复杂度平摊后为 \(\mathcal{O}(\log N)\),效率很高。证明参考这里

2. 常用操作和代码

Splay 树可以处理区间问题。若要处理 \([l,r]\) 的区间,方法如下:

  1. 将结点 \(l-1\) 旋转到根。

  2. 将结点 \(r+1\) 旋转到 \(l-1\) 的儿子。

此时结点 \(r+1\) 的左儿子即为需要操作的区间。

每个结点需要记录的信息有父亲、左儿子、右儿子、子树大小以及结点的值。

\(u\)\(f\) 的左儿子,\(x\)\(u\) 的右儿子,\(g\)\(f\) 的父亲。旋转前的形态如下图:

旋转前

旋转后的形态如下图:

旋转后

因此要更新的部分主要有下面几个:

\(x\) 的父亲、\(f\) 的左儿子、\(f\) 的父亲、\(u\) 的右儿子、\(u\) 的父亲、\(g\) 的其中一个儿子。

在处理区间问题时,我们需要先找到结点 \(l-1\)\(r+1\)。这与普通平衡树的查询第 \(k\) 小数的代码差不多,但这里返回的是节点编号。代码如下:

node* fd(int k){
	node *u=root;
	while(k!=u->ls->sz+1){
		if(k<u->ls->sz+1){
		    /*第k小在左子树*/
			u=u->ls;
		}else{
		    /*在右子树*/
			k-=u->ls->sz+1;
			u=u->rs;
		}
	}
	return u;
}

我们可以先写一个函数判断一个结点是左儿子还是右儿子。代码如下:

bool ls(node *u){
	return u->fa->ls==u;
	/*如果u是左儿子,返回true*/
}

因此单旋操作的代码如下。

void rtt(node *u){
	node *f=u->fa,*g=f->fa;
	if(ls(u)){
		/*zig操作*/
		f->ls=u->rs;
		/*更新f的左儿子*/
		if(f->ls!=t){
			/*f有左儿子,更新它的父亲*/
			f->ls->fa=f;
		}
		u->rs=f;
	}else{
		/*zag操作同理*/
		f->rs=u->ls;
		if(f->rs->fa!=t){
			f->rs->fa=f;
		}
		u->ls=f;
	}
	if(g!=t){
		/*更新g的儿子*/
		ls(f)?g->ls=u:g->rs=u;
	}else{
		/*u没有祖父,说明u的父亲为根,那么旋转后u就为根*/
		root=u;
	}
	f->fa=u;u->fa=g;
	push_up(f);push_up(u);
	/*不要忘记push_up*/
}

Splay 树的主要操作是将一个结点旋转至根或另一个节点的儿子,通过不断的旋转完成。代码如下:

void splay(node *u,node *p){
	/*如果p=t,将u旋转至根,否则旋转至p的儿子*/
	node *f,*g;
	while(u->fa!=p){
		f=u->fa;g=f->fa;
		/*父亲与祖父*/
		if(g!=p){
			/*u离p至少还有两层,做双旋*/
			rtt(ls(u)^ls(f)?u:f);
			/*ls(u)^ls(f)说明不在同一直线,先转u,否则先转f*/
		}
		rtt(u);
		/*不管单旋,一字旋还是之字旋,最后一次都是旋转u*/
	}
}

在操作的时候,如果初始时是空树,那么在找 \(l-1,r+1\) 时会出错。因此通常先设结点 \(1\) 为根,结点 \(2\)\(1\) 的右儿子,后面对结点 \(2\) 的左儿子操作,且区间都向后移一位。

下面给出例题和代码。

例1 洛谷-P4008

本题可以用 Splay 树实现。

代码

Splay 树也可以作为平衡树。下面给出基本操作的方法。

  1. 查询 \(k\) 的排名

代码与 Treap 树相同。

  1. 插入 \(k\)

\(r\) 为比 \(k\) 小的数的个数,\(x\) 为排名为 \(r\) 的结点,\(y\) 为排名为 \(r+1\) 的结点。

\(x\) 旋转到根,\(y\) 旋转为 \(x\) 的儿子。此时 \(y\) 的左子树为空,在这里插入结点。

  1. 删除 \(k\)

\(r\) 为比 \(k\) 小的数的个数,\(x\) 为排名为 \(r\) 的结点,\(y\) 为排名为 \(r+2\) 的结点。

\(x\) 旋转到根,\(y\) 旋转为 \(x\) 的儿子。此时 \(y\) 的左子树即为待删除的结点。

  1. 求第 \(k\) 小的数

直接用上面的 fd 函数即可。

  1. \(k\) 的前驱

代码和替罪羊树相同。

  1. \(k\) 的后继

代码和替罪羊树相同。

例题 LibreOJ-104

代码

posted @ 2024-02-08 19:58  lrx139  阅读(16)  评论(0编辑  收藏  举报