伸展树
我们知道,二叉查找树能够支持多种动态集合操作,因此在程序设计竞赛中,二叉查找树起着非常重要的作用,它可以用来表示有序集合,建立索引或优先队列等。作用于二叉树的基本操作时间是与树的高度成正比的:对于一颗含n个节点的二叉查找树,如果呈完全二叉树结构,则这些操作的最坏情况的运行时间为O(log2 n); 但如果呈线性链结构,则这些操作的最坏情况运行时间退化为O(n)。 针对二叉树这种不平衡、不稳定的弊病,人们做了大量改进优化的尝试,使其基本操作在最坏情况下的性能尽量保持良好。本节将介绍两种改进型的二叉查找树:
1、伸展树(splay tree):
注意:伸展树也是一颗有序树,左儿子小于本身,本身小于右儿子
这里要介绍的伸展树是一种改进型的二叉查找树。 虽然它并不能完全保证树结构始终平衡,但对于伸展树的一系列操作,我们可以证明其每一步操作的平摊复杂度都是O(log2 n)。所以从某种意义上来说,这种改进厚的二叉查找树能够基本达到一种平衡状态。至于伸展树的空间要求与编程复杂度,在各种树状数据结构中都是很优秀的。
伸展树的基本操作包括:
1、伸展操作,即伸展树作自我调整。
2、判断元素x是否在伸展树中。
3、将元素x插入到伸展树。
4、将元素x从伸展树中删除。
5、将两颗伸展树S1和S2合并为一颗伸展树(其中S1的所有元素都小于S2的元素)。
6、以x为界,将伸展树S分离成两颗伸展树S1和S2,其中S1中所有元素都小于x,S2中所有元素都大于x。
其中基本操作2~6都是在伸展操作的基础上进行的。
1、伸展操作------Splay(x,S)
伸展操作是在保持伸展树有序性的前提下,通过一系列旋转将伸展树S中的元素x调整至树的根部。 在调整过程中,要分为以下三种情况分别处理:
情况1:节点x的父节点y是根节点。
这时,如果x是y的左儿子,我们进行一次Zig(右旋)操作;如果x是y的右儿子,则进行一次Zag(左旋)操作。经过旋转,x成为二叉树S的根节点,调整结束。我们称这种旋转为单旋转。如下图:
可以仔细看一下这个图:
左图到右图:Zig(右旋)就是将x变为根,y变为x的右儿子,x的右儿子变为y的左儿子,这样就保证相对有序了,左儿子都小于本身,本身又小于右儿子。
右图到左图:Zag(左旋)就是将y变为根,x变为y的左儿子,y的左儿子变为x的右儿子,这样就保证相对有序了。
情况2:节点x的父节点y不是根节点,y的父节点为z且x与y同时是各自父节点的左孩子或者同时是各自父节点的右儿子。
这时进行一次Zig-Zig操作或者Zag-Zag操作,即 假设当前节点为x,x的父节点为y,y的父节点为z,如果y和x同为其父亲的左孩子或者右孩子,那么先旋转y,再旋转x。我们称这种旋转为一字旋转,如下图:
这里是怎么旋转的呢? 先旋转y,这时就变成了
再旋转x就变成了:
情况3:节点x的父节点y不是根节点,y的父节点为z,x与y中一个是其父节点的左儿子,另一个是其父节点的右儿子。
这是进行一次Zig-Zag操作或者进行一次Zag-Zig操作,即连续旋转两次X。我们称这种旋转为之字型旋转。如下图所示:
如下图所示,执行Splay(1,S),我们将元素1调整到了伸展树S的根部
再执行Splay(2,S),如下图所示
我们从直观上可以看出经调整厚,伸展树比原来“平衡”了许多。伸展操作的过程并不复杂,只需要根据情况进行旋转就可以了,而三种旋转都是由基本的左旋转和右旋组成,实现较为简单。
利用Splay操作,我们可以在伸展树S上进行如下运算。
2、判断元素x是否在伸展树S表示的有序集中-------Find(x,S)
首先,与在二叉树中查找操作一样,在伸展树中查找元素x。如果x 在树中,则将x 调整至伸展树S 的根部(执行Splay(x,S) )。
3、将元素插入伸展树S表示的有序集中------Insert(x,S)
与处理普通的二叉查找树一样,将x 插入到伸展树S中的相应位置上,再将x 调整至伸展树S的根部(执行Splay(x,S))。
4、将元素从伸展树S所表示的有序集中删除-------Delete(x,S)。
首先,用在二叉树中查找元素的方法找到x的位置。如果x没有孩子或者只有一个孩子,那么直接将x删掉,并通过Splay操作,将x节点的父节点调整到伸展树的根节点处,否则,向下查找x的后继y,用y替代x 的位置,最后执行Splay(y,S),将y调整为伸展树的根。
5、将两颗伸展树S1和S2合并成为一颗伸展树------Join(S1,S2)(其中S1的所有元素值都小于S2的所有元素值)
首先,找到伸展树S1中最大的一个元素x,再通过Splay(x,S1)将x调整到伸展树S1的根。然后再将S2作为x节点的右子树。这样,就得到了新的伸展树S,如下图所:
6、以x为界,将伸展树S分离为两颗伸展树S1和S2------Split(x,S)(其中S1中所有元素都小于x,S2中所有元素都大于x)
首先执行Find(x,S),将元素x调整为伸展树的根节点,则x的左子树就是S1,二右子树就是S2。然后去除x通往左右儿子的边,如下图所示:
在伸展操作的基础上,除了上面介绍的五种最基本操作,伸展树还支持求最大值,最小值,前驱,后继等多种操作,这些基本操作也都是建立在伸展操作的基础上的。
由上述操作的实现过程可以看出,伸展树基本操作的时间效率完全取决于Splay操作的时间复杂度。
下面将一道伸展树的基础题:
题目链接:https://www.lydsy.com/JudgeOnline/problem.php?id=1588
1588: [HNOI2002]营业额统计
Time Limit: 5 Sec Memory Limit: 162 MBSubmit: 19721 Solved: 8394
[Submit][Status][Discuss]
Description
营业额统计 Tiger最近被公司升任为营业部经理,他上任后接受公司交给的第一项任务便是统计并分析公司成立以来的营业情况。 Tiger拿出了公司的账本,账本上记录了公司成立以来每天的营业额。分析营业情况是一项相当复杂的工作。由于节假日,大减价或者是其他情况的时候,营业额会出现一定的波动,当然一定的波动是能够接受的,但是在某些时候营业额突变得很高或是很低,这就证明公司此时的经营状况出现了问题。经济管理学上定义了一种最小波动值来衡量这种情况: 该天的最小波动值 当最小波动值越大时,就说明营业情况越不稳定。 而分析整个公司的从成立到现在营业情况是否稳定,只需要把每一天的最小波动值加起来就可以了。你的任务就是编写一个程序帮助Tiger来计算这一个值。 第一天的最小波动值为第一天的营业额。 输入输出要求
Input
Output
输出文件仅有一个正整数,即Sigma(每天最小的波动值) 。结果小于2^31 。
Sample Input
5
1
2
5
4
6
Sample Output
题意很简单,就不多解释了。
直接看代码:
#include<iostream> #include<string> #include<cstring> using namespace std; const int maxn=32768;//伸展树的规模上限 const int inf=1e9+7; struct treetype//伸展树的节点类型 { int data;//数据 int fa,l,r;//父指针和左右指针 }; treetype t[maxn];//伸展树 int m,root;//伸展树的根为root,节点数为m int n,k,ans;//天数为n,n天最小波动值总和为ans void init() { cin>>n; m=0; memset(t,0,sizeof(t));//伸展树为空 ans=0;//n天最小波动值的总和初始化 } void LeftRotate(int x)//以x节点为基准左旋,这个过程需要自己理解一下 { int y; y=t[x].fa; t[y].r=t[x].l; if(t[x].l!=0) t[t[x].l].fa=y;//为什么要判断是否为0呢,因为为0代表没有节点,但是你又给它加一个父亲,这样会影响根节点的判断 t[x].fa=t[y].fa;//x的祖父成为x的父节点, t[x].l=y;//x的原父节点成为x 的左儿子 if(t[y].fa!=0)//在x存在祖父节点的情况下,若x的原父节点是祖父节点的左儿子 //则祖父节点的左儿子改为x;否则祖父节点的右儿子改为x { if(y==t[t[y].fa].l) t[t[y].fa].l=x; else t[t[y].fa].r=x; } t[y].fa=x; } void RightRotate(int x)//以x节点右旋 { int y; y=t[x].fa; t[y].l=t[x].r; if(t[x].r!=0) t[t[x].r].fa=y; t[x].fa=t[y].fa; t[x].r=y; if(t[y].fa!=0) { if(y==t[t[y].fa].l) t[t[y].fa].l=x; else t[t[y].fa].r=x; } t[y].fa=x; } void splay(int x)//伸展操作:通过一系列旋转操作,将x节点调整至根部 { int l; while(t[x].fa!=0)//反复进行旋转,直至将x节点调整至根部为止 { l=t[x].fa; if(t[l].fa==0)//在x的父节点为根的情况下,若x在左儿子位置,则右旋;否则左旋 { if(x==t[l].l) RightRotate(x); else LeftRotate(x); break; } //不退出代表x的父节点不是根,继续循环 if(x==t[l].l)//在x位于左位置的情况下,若x的父节点位于左位置,则分别 //以x的父节点和x为基准两次右旋;否则以x为基准右旋和左旋 { if(l==t[t[l].fa].l) { RightRotate(l); RightRotate(x); } else { RightRotate(x); LeftRotate(x); } } else//在x位于右位置的情况下,若x的父节点位于右位置,则分别以 //x的父节点和x为基准两次左旋;否则以x为基准左旋和右旋 { if(l==t[t[l].fa].r) { LeftRotate(l); LeftRotate(x); } else { LeftRotate(x); RightRotate(x); } } } root=x;//x为伸展树的根 } void Insert(int x)//将x插入到伸展树 { int l,f; t[++m].data=x; t[m].fa=0; t[m].l=0; t[m].r=0; if(root==0) {root=m;return ;}//若原伸展树为空 则返回该节点 l=root;//从伸展树的树根出发,寻找x的插入位置m do{ f=l; if(x<=t[l].data) l=t[l].l; else l=t[l].r; }while(l!=0); t[m].fa=f; if(x<=t[f].data) t[f].l=m; else t[f].r=m; splay(m); } void Cal()//累计当天的最小波动值 { int l,mi; mi=inf;//当天的最小波动值初始化 l=t[root].l;//从根的左儿子出发,寻找左儿子中的最右点(有序集中当天营业额的前驱 while(l!=0) { if(t[l].r==0) break; l=t[l].r; } if(l!=0) mi=t[root].data-t[l].data; l=t[root].r;//从根的右儿子出发,寻找右子树中的最左点,即有序集中当天营业额的后继 while(l!=0) { if(t[l].l==0) break; l=t[l].l; } if((l!=0)&&(t[l].data-t[root].data<mi)) mi=t[l].data-t[root].data; ans+=mi; } void Make() { int i,k; for(i=1;i<=n;i++) { cin>>k; Insert(k); if(m>=2) Cal();//若伸展树的节点数不少于2,则通过伸展树计算最小波动值的总和 //否则第i天的营业额计入最小波动值的总和 else ans+=k; } cout<<ans<<endl; } int main() { init(); Make(); return 0; }