[SinGuLaRiTy] SplayTree 伸展树

时间:2022-08-03 08:39:37

【SinGuLaRiTy-1010】Copyrights (c) SinGuLaRiTy 2017. All Rights Reserved.

Some Method Are Reprinted From 杨思雨-《伸展树的基本操作与应用》

引言

二叉查找树(Binary Search Tree)能够支持多种动态集合操作。因此,在信息学竞赛中,二叉排序树起着非常重要的作用,它可以被用来表示有序集合、建立索引或优先队列等。作用于二叉查找树上的基本操作的时间是与树的高度成正比的。对一个含 n各节点的完全二叉树,这些操作的最坏情况运行时间为 O(log n)。但如果树是含n 个节点的线性链,则这些操作的最坏情况运行时间为 O(n)。而有些二叉查找树的变形,其基本操作在最坏情况下性能依然很好,比如红黑树、AVL 树等等。本文将要介绍的伸展树(Splay Tree),也是对二叉查找树的一种改进,虽然它并不能保证树一直是“平衡”的,但对于伸展树的一系列操作,我们可以证明其每一步操作的平摊复杂度都是 O(log n)。所以从某种意义上说,伸展树也是一种平衡的二叉查找树。而在各种树状数据结构中,伸展树的空间要求与编程复杂度也都是很优秀的。

伸展树的基本操作

伸展树是二叉查找树的一种改进,与二叉查找树一样,伸展树也具有有序性。即伸展树中的每一个节点 x 都满足:该节点左子树中的每一个元素都小于 x,而其右子树中的每一个元素都大于 x。与普通二叉查找树不同的是,伸展树可以自我调整,这就要依靠伸展操作 Splay(x,S)。

伸展操作 Splay(x,S)

情况一:节点 x 的父节点 y 是根节点。这时,如果 x 是 y 的左孩子,我们进行一次 Zig(右旋)操作;如果 x 是 y 的右孩子,则我们进行一次 Zag(左旋)操作。经过旋转,x 成为二叉查找树 S 的根节点,调整结束。如图 1 所示

[SinGuLaRiTy] SplayTree 伸展树图 1

情况二:节点 x 的父节点 y 不是根节点,y 的父节点为 z,且 x 与 y 同时是各自父节点的左孩子或者同时是各自父节点的右孩子。这时,我们进行一次Zig-Zig 操作或者 Zag-Zag 操作。如图 2 所示

[SinGuLaRiTy] SplayTree 伸展树图 2

情况三:节点 x 的父节点 y 不是根节点,y 的父节点为 z,x 与 y 中一个是其父节点的左孩子而另一个是其父节点的右孩子。这时,我们进行一次 Zig-Zag 操作或者 Zag-Zig 操作。如图 3 所示

[SinGuLaRiTy] SplayTree 伸展树图 3

如图 4 所示,执行 Splay(1,S),我们将元素 1 调整到了伸展树 S 的根部。再执行 Splay(2,S),如图 5 所示,我们从直观上可以看出在经过调整后,伸展树比原来“平衡”了许多。而伸展操作的过程并不复杂,只需要根据情况进行旋转就可以了,而三种旋转都是由基本得左旋和右旋组成的,实现较为简单。

[SinGuLaRiTy] SplayTree 伸展树图 4 Splay(1,S)

[SinGuLaRiTy] SplayTree 伸展树图 5 Splay(2,S)

伸展树的基本操作

利用 Splay 操作,我们可以在伸展树 S 上进行如下运算:

(1)查找操作

Find(x,S):判断元素 x 是否在伸展树 S 表示的有序集中。首先,与在二叉查找树中的查找操作一样,在伸展树中查找元素 x。如果 x在树中,则再执行 Splay(x,S)调整伸展树。

基本查找

int find(int now,int val){//pos
if(mx[son[now][]]>=val)return find(son[now][],val);
if(key[now]==val)return now;
if(mi[son[now][]]<=val)return find(son[now][],val);
return now;
}

另一个查找(查找标号)

int rank(int now,int val){
if(key[now]==val)return size[son[now][]]+;
else if(val<key[now])return rank(son[now][],val);
else if(val>key[now])return size[son[now][]]++rank(son[now][],val);
}

查找K小值的标号

int kth(int now,int th){
if(size[son[now][]]+==th)return now;
else if(size[son[now][]]+>th)return kth(son[now][],th);
else return kth(son[now][],th-size[son[now][]]-);
}

查找前驱

int pre(int val){
int tmp=kth(son[val][],size[son[val][]]);
splay(tmp,val);
return key[tmp];
}

查找后驱

int succ(int val){
int tmp=kth(son[val][],);
splay(tmp,val);
return key[tmp];
}

(2)插入操作

也与处理普通的二叉查找树一样,将 x 插入到伸展树 S 中的相应位置上,再执行 Splay(x,S)。

首先,找到一个合适的位置

int getpos(int now,int val){
if((key[now]<val)&&(size[son[now][]]))return getpos(son[now][],val);
else if((key[now]>val)&&(size[son[now][]]))return getpos(son[now][],val);
return now;
}

但是我们并不能保证这个点是大于还是小于插入节点

void insert(int root,int pos,int val){
int sroot=father[root];
splay(root=getpos(root,val),sroot);
if(key[root]<val){
succ(root);//十分重要
rotate(root=son[root][]);
}
int tmp=son[root][];key[pos]=val;
son[root][]=pos;father[pos]=root;
son[pos][]=tmp;father[tmp]=pos;
updata(pos);updata(root);
splay(pos,);
}

我们把刚好比插入点大的点作为根,在左子树插入。如果根小于插入点,则要将根的后继变成根(!!!注意不是根的右儿子!!!)

(3)删除操作

将元素 x 从伸展树 S 所表示的有序集中删除。首先,用在二叉查找树中查找元素的方法找到 x 的位置。如果 x 没有孩子或只有一个孩子,那么直接将 x 删去,并通过 Splay 操作,将 x 节点的父节点调整到伸展树的根节点处。否则,则向下查找 x 的后继 y,用 y 替代 x 的位置,最后执行 Splay(y,S),将 y 调整为伸展树的根。

void dele(int root,int val){
int sroot=father[root];
splay(root=find(root,val),sroot);
if(son[root][])splay(succ(root),root);
son[sroot][flag(root)]=son[root][];
father[son[root][]]=sroot;
son[son[root][]][]=son[root][];
father[son[root][]]=son[root][];
updata(son[root][]);
}

(4)Join(S1,S2):

将两个伸展树 S1 与 S2 合并成为一个伸展树。其中 S1 的所有元素都小于 S2 的所有元素。

首先,我们找到伸展树 S1 中最大的一个元素 x,再通过 Splay(x,S1)将 x 调整到伸展树 S1 的根。然后再将 S2 作为 x 节点的右子树。这样,就得到了新的伸展树 S。如图 6 所示

[SinGuLaRiTy] SplayTree 伸展树图 6

(个人觉得这个操作运用的比较少)

(5)Split(x,S):

以 x 为界,将伸展树 S 分离为两棵伸展树 S1 和 S2,其中 S1中所有元素都小于 x,S2 中的所有元素都大于 x。首先执行 Find(x,S),将元素 x 调整为伸展树的根节点,则 x 的左子树就是S1,而右子树为 S2。如图 7 所示

[SinGuLaRiTy] SplayTree 伸展树图 7

(6)Splay操作

splay是基本操作,rotate是splay的基本操作。splay(now,root)把now splay到root的下面。单旋和双旋就不说了。我们可以简化操作,如果是一字型(方向相同)则先旋father,否则先旋now。然后再旋now。

可将一字型与之字形的情况合并:

void splay(int now,int root){
while(father[now]!=root){
if(father[father[now]]!=root){
if(flag(now)==flag(father[now]))rotate(father[now]);
else rotate(now);
}
rotate(now);
}
}

rotate now是指将now绕father rotate。有两个方向,表示now是father的左儿子还是右儿子。

bool flag(int now){
return son[father[now]][]==now;
}

通过方向标记,我们可以将两个方向的rotate合成一个:

void rotate(int now){
int fa=father[now],fx=flag(now);
son[fa][fx]=son[now][!fx];
if(son[now][!fx])father[son[now][!fx]]=fa;
//连接father和son[now]
son[father[fa]][flag(fa)]=now;
father[now]=father[fa];
//连接grandpa和now
son[now][!fx]=fa;
father[fa]=now;
//连接now和father
updata(fa);updata(now);
}

别忘了rotate完成后要updata:

void updata(int pos){
size[pos]=size[son[pos][]]+size[son[pos][]]+;
}

这里还有另一个标准版本的Splay操作:

Splay:

void splay(int x,int goal)
{
for(int y;(y=fa[x])!=goal;rotate(x))
{
int z;
if((z=fa[y])!=goal)
{
if((ch[z][]==y)==(ch[y][]==x))
rotate(y);
else rotate(x);
}
}
if(goal==)root=x;
}

Rotate:

void rotate(int x)
{
if(x==||fa[x]==)return;
int y=fa[x],z=fa[y];
bool flag=(ch[y][]==x);
ch[y][flag]=ch[x][!flag];
if(ch[x][!flag])fa[ch[x][!flag]]=y;
ch[x][!flag]=y;
fa[y]=x;
fa[x]=z;
if(z)ch[z][ch[z][]==y]=x;
}

Insert:

int insert(int &r,int x,int f)
{
int temp1,temp2;
if(r==)
{
sale[++tot]=x;
r=tot;
fa[r]=f;
splay(r,);
return INF;
}
if(x==sale[r])
return ;
else if(x<sale[r])
{
temp1=sale[r]-x;
temp2=insert(ch[r][],x,r);
return min(temp1,temp2);
}
else
{temp1=x-sale[r];
temp2=insert(ch[r][],x,r);
return min(temp1,temp2);
}
}

时间复杂度

由以上这些操作的实现过程可以看出,它们的时间效率完全取决于 Splay 操作的时间复杂度。下面,我们就用会计方法来分析 Splay 操作的平摊复杂度。首先,我们定义一些符号:S(x)表示以节点 x 为根的子树。|S|表示伸展树 S的节点个数。令μ(S) = [ log|S| ],μ(x)=μ(S(x))。如图 8 所示

[SinGuLaRiTy] SplayTree 伸展树图 8

我们用 1 元钱表示单位代价(这里我们将对于某个点访问和旋转看作一个单位时间的代价)。定义伸展树不变量:在任意时刻,伸展树中的任意节点 x 都至少有μ(x)元的存款。

在 Splay 调整过程中,费用将会用在以下两个方面:

(1)为使用的时间付费。也就是每一次单位时间的操作,我们要支付 1 元钱。

(2)当伸展树的形状调整时,我们需要加入一些钱或者重新分配原来树中每个节点的存款,以保持不变量继续成立。

下面我们给出关于 Splay 操作花费的定理:

每一次Splay(x,S)操作中,调整树的结构与保持伸展树不变量的总花费不超过3u(S)+1.

证明:用μ(x)和μ’(x)分别表示在进行一次 Zig、Zig-Zig 或 Zig-Zag 操作前后节点 x 处的存款。

下面我们分三种情况分析旋转操作的花费:

情况一:如图 9 所示

[SinGuLaRiTy] SplayTree 伸展树图 9

我们进行 Zig 或者 Zag 操作时,为了保持伸展树不变量继续成立,我们需要花费:

μ’(x) +μ’(y) -μ(x) -μ(y) = μ’(y) -μ(x)

≤ μ’(x) -μ(x)

≤ 3(μ’(x) -μ(x))

=3(μ(S) -μ(x))

此外我们花费另外 1 元钱用来支付访问、旋转的基本操作。因此,一次 Zig 或 Zag 操作的花费至多为 3(μ(S) -μ(x))。

情况二:如图 10 所示

[SinGuLaRiTy] SplayTree 伸展树图 10

我们进行 Zig-Zig 操作时,为了保持伸展树不变量,我们需要花费:

μ’(x) +μ’(y) +μ’(z) -μ(x) -μ(y) -μ(z) = μ’(y) +μ’(z) -μ(x) -μ(y)

=(μ’(y) -μ(x)) + (μ’(z) -μ(y))

≤ (μ’(x) -μ(x)) + (μ’(x) -μ(x))

=2 (μ’(x) -μ(x))

与上种情况一样,我们也需要花费另外的 1 元钱来支付单位时间的操作。

当μ’(x) <μ(x) 时,显然 2 (μ’(x) -μ(x)) +1 ≤ 3 (μ’(x) -μ(x))。也就是进行Zig-Zig 操作的花费不超过 3 (μ’(x) -μ(x))。

当μ’(x) =μ(x) 时,我们可以证明μ’(x) +μ’(y) + μ’(z) <μ(x) +μ(y) +μ(z),也就是说我们不需要任何花费保持伸展树不变量,并且可以得到退回来的钱,用其中的 1 元支付访问、旋转等操作的费用。为了证明这一点,我们假设μ’(x) +μ’(y)+ μ’(z) >μ(x) +μ(y) +μ(z)。

联系图 9,我们有μ(x) =μ’(x) =μ(z)。那么,显然μ(x) =μ(y) =μ(z)。于是,可以得出μ(x) =μ’(z) =μ(z)。令 a = 1 + |A| + |B|,b = 1 + |C| + |D|,那么就有

[log a] = [log b] = [log (a+b+1)]  ①

我们不妨设 b≥a,则有

[log (a+b+1)] ≥ [log (2a)]

= 1+[log a]

> [log a]   ②

①与②矛盾,所以我们可以得到μ’(x) =μ(x) 时,Zig-Zig 操作不需要任何花费,显然也不超过 3 (μ’(x) -μ(x))。

情况三:与情况二类似,我们可以证明,每次 Zig-Zag 操作的花费也不超过3 (μ’(x) -μ(x))。

以上三种情况说明,Zig 操作花费最多为 3(μ(S)-μ(x))+1,Zig-Zig 或 Zig-Zag操作最多花费 3(μ’(x)-μ(x))。那么将旋转操作的花费依次累加,则一次 Splay(x,S)操作的费用就不会超过 3μ(S)+1。也就是说对于伸展树的各种以 Splay 操作为基础的基本操作的平摊复杂度,都是 O(log n)。所以说,伸展树是一种时间效率非常优秀的数据结构。

总结

由上面的分析介绍,我们可以发现伸展树有以下几个优点:

(1)时间复杂度低,伸展树的各种基本操作的平摊复杂度都是 O(log n)的。在树状数据结构中,无疑是非常优秀的。

(2)空间要求不高。与红黑树需要记录每个节点的颜色、AVL 树需要记录平衡因子不同,伸展树不需要记录任何信息以保持树的平衡。

(3)算法简单,编程容易。伸展树的基本操作都是以 Splay 操作为基础的,而Splay 操作中只需根据当前节点的位置进行旋转操作即可。虽然伸展树算法与 AVL 树在时间复杂度上相差不多,甚至有时候会比 AVL树慢一些,但伸展树的编程复杂度大大低于 AVL 树。在竞赛中,使用伸展树在编程和调试中都更有优势。

  顺序查找 线段树 AVL树 伸展树
时间复杂度 O(n^2) O(nlog a) O(nlog n) O(nlog n)
空间复杂度 O(n) O(a) O(n) O(n)
编程复杂度 简单 较简单 复杂 较简单

相关题目

都是一些模板题,适合练练手。

Play with Chain

Problem Description

YaoYao is fond of playing his chains. He has a chain containing n diamonds on it. Diamonds are numbered from 1 to n.
At first, the diamonds on the chain is a sequence: 1, 2, 3, …, n.
He will perform two types of operations:
CUT a b c: He will first cut down the chain from the ath diamond to the bth diamond. And then insert it after the cth diamond on the remaining chain.
For example, if n=8, the chain is: 1 2 3 4 5 6 7 8; We perform “CUT 3 5 4”, Then we first cut down 3 4 5, and the remaining chain would be: 1 2 6 7 8. Then we insert “3 4 5” into the chain before 5th diamond, the chain turns out to be: 1 2 6 7 3 4 5 8.
FLIP a b: We first cut down the chain from the ath diamond to the bth diamond. Then reverse the chain and put them back to the original position.
For example, if we perform “FLIP 2 6” on the chain: 1 2 6 7 3 4 5 8. The chain will turn out to be: 1 4 3 7 6 2 5 8
He wants to know what the chain looks like after perform m operations. Could you help him? 

Input

There will be multiple test cases in a test data. 
For each test case, the first line contains two numbers: n and m (1≤n, m≤3*100000), indicating the total number of diamonds on the chain and the number of operations respectively.
Then m lines follow, each line contains one operation. The command is like this:
CUT a b c // Means a CUT operation, 1 ≤ a ≤ b ≤ n, 0≤ c ≤ n-(b-a+1).
FLIP a b    // Means a FLIP operation, 1 ≤ a < b ≤ n.
The input ends up with two negative numbers, which should not be processed as a case.

Output

For each test case, you should print a line with n numbers. The ith number is the number of the ith diamond on the chain.

Sample Input

8 2
CUT 3 5 4
FLIP 2 6
-1 -1

Sample Output

1 4 3 7 6 2 5 8

STD Code

#include<cstdio>
#include<cstring>
#include<iostream>
using namespace std;
const int N=3e5+;
int data[N],num[N],t[N][],id,fa[N];
int flip[N],root,f;
void pushup(int x)
{
num[x]=num[t[x][]]+num[t[x][]]+;
}
void pushdown(int x)
{
if(flip[x])
{
flip[x]=;
flip[t[x][]]^=;
flip[t[x][]]^=;
swap(t[x][],t[x][]);
}
}
void Rotate(int x,int w)
{
int y=fa[x];
int z=fa[y];
pushdown(y);
t[y][!w]=t[x][w];
fa[t[x][w]]=y;
t[z][t[z][]==y]=x;
fa[x]=z;
t[x][w]=y;
fa[y]=x;
pushup(y);
}
void newnode(int &x,int y,int v)
{
x=++id;
t[x][]=t[x][]=;
fa[x]=y;
data[x]=v,flip[x]=;
num[x]=;
}
void build(int &x,int y,int l,int r)
{
if(l<=r)
{
int mid=(l+r)>>;
newnode(x,y,mid);
build(t[x][],x,l,mid-);
build(t[x][],x,mid+,r);
pushup(x);
}
}
void init(int n)
{
f=id=root=;
t[][]=t[][]=num[]=data[]=flip[]=fa[]=;
newnode(root,,-);
newnode(t[][],root,-);
build(t[t[][]][],t[][],,n);
pushup(t[][]);
pushup();
}
void Splay(int x,int y)
{
if(x!=y)
{
pushdown(x);
while(fa[x]!=y)
{
if(t[fa[x]][]==x)
Rotate(x,);
else
Rotate(x,);
}
pushup(x);
if(!y)
root=x;
}
}
int Kth(int k)
{
int x=root;
pushdown(root);
for(;num[t[x][]]+!=k;)
{
if(num[t[x][]]+>k)
x=t[x][];
else
k-=num[t[x][]]+,x=t[x][];
pushdown(x);
}
return x;
}
void Flip(int a,int b)
{
a=Kth(a);
b=Kth(b+);
Splay(a,);
Splay(b,a);
flip[t[b][]]^=;
}
void Cut(int a,int b,int c)
{
int tmp,d; a=Kth(a);b=Kth(b+);
Splay(a,);Splay(b,a);
tmp=t[b][];t[b][]=;
pushup(b);pushup(a); d=Kth(c+);c=Kth(c+);
Splay(c,);Splay(d,c);
t[d][]=tmp;fa[tmp]=d;
pushup(d);pushup(c);
}
void Inorder(int x)
{
if(x)
{
pushdown(x);
Inorder(t[x][]);
if(data[x]>)
{
if(f)
putchar(' ');
else
f=;
printf("%d",data[x]);
}
Inorder(t[x][]);
}
}
int main()
{
int n,m;
char op[];
while(scanf("%d%d",&n,&m)!=EOF)
{
if(n==-)
break;
init(n);
int a,b,c;
while(m--){
scanf("%s",op);
if(op[]=='F')
{
scanf("%d%d",&a,&b);
Flip(a,b);
}
else
{
scanf("%d%d%d",&a,&b,&c);
Cut(a,b,c);
}
}
Inorder(root);
puts("");
}
return ;
}

营业额统计

题目描述

Tiger最近被公司升任为营业部经理,他上任后接受公司交给的第一项任务便是统计并分析公司成立以来的营业情况。
Tiger拿出了公司的账本,账本上记录了公司成立以来每天的营业额。分析营业情况是一项相当复杂的工作。由于节假日,大减价或者是其他情况的时候,营业额会出现一定的波动,当然一定的波动是能够接受的,但是在某些时候营业额突变得很高或是很低,这就证明公司此时的经营状况出现了问题。经济管理学上定义了一种最小波动值来衡量这种情况:
该天的最小波动值 = min {该天以前某一天的营业额 - 该天营业额}
当最小波动值越大时,就说明营业情况越不稳定。
而分析整个公司的从成立到现在营业情况是否稳定,只需要把每一天的最小波动值加起来就可以了。你的任务就是编写一个程序帮助Tiger来计算这一个值。
第一天的最小波动值为第一天的营业额。
天数小于100000.

输入

第一行为正整数 ,表示该公司从成立一直到现在的天数

接下来的n行每行有一个整数(一定有数据小于〇) ,表示第i天公司的营业额。

输出

输出文件仅有一个正整数,即每一天的最小波动值之和。答案保证在int范围内。

样例输入

6
5
1
2
5
4
6

样例输出

12

提示

结果说明:5+|1-5|+|2-1|+|5-5|+|4-5|+|6-5|=5+4+1+0+1+1=12

STD Code

#include<iostream>
#include<cstdio>
#include<cstring>
using namespace std;
#define MAXN 100005
#define INF 10000000
int ch[MAXN][],fa[MAXN],sale[MAXN];
int ans,tot,root;
#define min(a,b) ((a)>(b)?(b):(a))
void rotato(int x)
{
if(x==||fa[x]==)return;
int y=fa[x],z=fa[y];
bool flag=(ch[y][]==x);
ch[y][flag]=ch[x][!flag];
if(ch[x][!flag])fa[ch[x][!flag]]=y;
ch[x][!flag]=y;
fa[y]=x;
fa[x]=z;
if(z)ch[z][ch[z][]==y]=x;
}
void splay(int x,int goal)
{
for(int y;(y=fa[x])!=goal;rotato(x))
{
int z;
if((z=fa[y])!=goal)
{
if((ch[z][]==y)==(ch[y][]==x))
rotato(y);
else rotato(x);
}
}
if(goal==)root=x;
}
int insert(int &r,int x,int f)
{
int temp1,temp2;
if(r==)
{
sale[++tot]=x;
r=tot;
fa[r]=f;
splay(r,);
return INF;
}
if(x==sale[r])
return ;
else if(x<sale[r])
{
temp1=sale[r]-x;
temp2=insert(ch[r][],x,r);
return min(temp1,temp2);
}
else
{temp1=x-sale[r];
temp2=insert(ch[r][],x,r);
return min(temp1,temp2);
}
}
int main()
{
int n,t;
scanf("%d",&n);
for(int i=;i<n;i++)
{
scanf("%d",&t);
if(i)ans+=insert(root,t,);
else {ans=t;
insert(root,t,);
}
}
printf("%d\n",ans);
}

Time: 2017-03-22