伸展树的基本操作与应用
【伸展树的基本操作】
伸展树是二叉查找树的一种改进,与二叉查找树一样,伸展树也具有有序性。 即伸展树中的每一个节点 x 都满足:该节点左子树中的每一个元素都小于 x,而 其右子树中的每一个元素都大于 x。与普通二叉查找树不同的是,伸展树可以自 我调整,这就要依靠伸展操作 Splay(x,S)。
伸展操作 Splay(x,S)
伸展操作 Splay(x,S)是在保持伸展树有序性的前提下,通过一系列旋转将伸 展树 S 中的元素 x 调整至树的根部。在调整的过程中,要分以下三种情况分别处 理:
情况一:节点 x 的父节点 y 是根节点。这时,如果 x 是 y 的左孩子,我们进 行一次 Zig(右旋)操作;如果 x 是 y 的右孩子,则我们进行一次 Zag(左旋)
操作。经过旋转,x 成为二叉查找树 S 的根节点,调整结束。如图 1 所示
情况二:节点 x 的父节点 y 不是根节点,y 的父节点为 z,且 x 与 y 同时是 各自父节点的左孩子或者同时是各自父节点的右孩子。这时,我们进行一次 Zig-Zig 操作或者 Zag-Zag 操作。如图 2 所示:
情况三:节点 x 的父节点 y 不是根节点,y 的父节点为 z,x 与 y 中一个是其 父节点的左孩子而另一个是其父节点的右孩子。这时,我们进行一次 Zig-Zag 操 作或者 Zag-Zig 操作。如图 3 所示:
如图 4 所示,执行 Splay(1,S),我们将元素 1 调整到了伸展树 S 的根部。再 执行 Splay(2,S),如图 5 所示,我们从直观上可以看出在经过调整后,伸展树比 原来“平衡”了许多。而伸展操作的过程并不复杂,只需要根据情况进行旋转就可
以了,而三种旋转都是由基本得左旋和右旋组成的,实现较为简单。
伸展树的基本操作
利用 Splay 操作,我们可以在伸展树 S 上进行如下运算:
(1)Find(x,S):判断元素 x 是否在伸展树 S 表示的有序集中。
首先,与在二叉查找树中的查找操作一样,在伸展树中查找元素 x。如果 x 在树中,则再执行 Splay(x,S)调整伸展树。
(2)Insert(x,S):将元素 x 插入伸展树 S 表示的有序集中。
首先,也与处理普通的二叉查找树一样,将 x 插入到伸展树 S 中的相应位置 上,再执行 Splay(x,S)。
(3)Delete(x,S):将元素 x 从伸展树 S 所表示的有序集中删除。
首先,用在二叉查找树中查找元素的方法找到 x 的位置。如果 x 没有孩子或 只有一个孩子,那么直接将 x 删去,并通过 Splay 操作,将 x 节点的父节点调整 到伸展树的根节点处。否则,则向下查找 x 的后继 y,用 y 替代 x 的位置,最后 执行 Splay(y,S),将 y 调整为伸展树的根。
(4)Join(S1,S2):将两个伸展树 S1 与 S2 合并成为一个伸展树。其中 S1 的所 有元素都小于 S2 的所有元素。
首先,我们找到伸展树 S1 中最大的一个元素 x,再通过 Splay(x,S1)将 x 调 整到伸展树 S1 的根。然后再将 S2 作为 x 节点的右子树。这样,就得到了新的 伸展树 S。如图 6 所示:
(5)Split(x,S):以 x 为界,将伸展树 S 分离为两棵伸展树 S1 和 S2,其中 S1 中所有元素都小于 x,S2 中的所有元素都大于 x。 首先执行 Find(x,S),将元素 x 调整为伸展树的根节点,则 x 的左子树就是 S1,而右子树为 S2。如图 7 所示
除了上面介绍的五种基本操作,伸展树还支持求最大值、求最小值、求前趋、 求后继等多种操作,这些基本操作也都是建立在伸展操作的基础上的。
时间复杂度分析
由以上这些操作的实现过程可以看出,它们的时间效率完全取决于 Splay 操 作的时间复杂度。下面,我们就用会计方法来分析 Splay 操作的平摊复杂度。 首先,我们定义一些符号:S(x)表示以节点 x 为根的子树。|S|表示伸展树 S 的节点个数。令μ(S) = [ log|S| ],μ(x)=μ(S(x))。如图 8 所示
我们用 1 元钱表示单位代价(这里我们将对于某个点访问和旋转看作一个单 位时间的代价)。定义伸展树不变量:在任意时刻,伸展树中的任意节点 x 都至 少有μ(x)元的存款。 在 Splay 调整过程中,费用将会用在以下两个方面: (1)为使用的时间付费。也就是每一次单位时间的操作,我们要支付 1 元钱。 (2)当伸展树的形状调整时,我们需要加入一些钱或者重新分配原来树中每个 节点的存款,以保持不变量继续成立。 下面我们给出关于 Splay 操作花费的定理: 定理:在每一次 Splay(x,S)操作中,调整树的结构与保持伸展树不变量的总 花费不超过 3μ(S)+1。 证明:用μ(x)和μ’(x)分别表示在进行一次 Zig、Zig-Zig 或 Zig-Zag 操作前后 节点 x 处的存款。 下面我们分三种情况分析旋转操作的花费: 情况一:如图 9 所示
我们进行 Zig 或者 Zag 操作时,为了保持伸展树不变量继续成立,我们需要 花费: μ’(x) +μ’(y) -μ(x) -μ(y) = μ’(y) -μ(x) ≤ μ’(x) -μ(x) ≤ 3(μ’(x) -μ(x)) = 3(μ(S) -μ(x)) 此外我们花费另外 1 元钱用来支付访问、旋转的基本操作。因此,一次 Zig 或 Zag 操作的花费至多为 3(μ(S) -μ(x))。 情况二:如图 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)。所以说,伸展树是一种时间效率非 常优秀的数据结构.
【伸展树的应用】
伸展树作为一种时间效率很高、空间要求不大的数据结构,在解题中有很大 的用武之地。下面就通过一个例子说明伸展树在解题中的应用。 例:营业额统计 Turnover (湖南省队 2002 年选拔赛) 题目大意 Tiger 最近被公司升任为营业部经理,他上任后接受公司交给的第一项任务 便是统计并分析公司成立以来的营业情况。Tiger 拿出了公司的账本,账本上记 录了公司成立以来每天的营业额。分析营业情况是一项相当复杂的工作。由于节 假日,大减价或者是其他情况的时候,营业额会出现一定的波动,当然一定的波 动是能够接受的,但是在某些时候营业额突变得很高或是很低,这就证明公司此 时的经营状况出现了问题。经济管理学上定义了一种最小波动值来衡量这种情 况: 该天的最小波动值= min { | 该天以前某一天的营业额-该天的营业额 | } 当最小波动值越大时,就说明营业情况越不稳定。而分析整个公司的从成立
到现在营业情况是否稳定,只需要把每一天的最小波动值加起来就可以了。你的 任务就是编写一个程序帮助 Tiger 来计算这一个值。 注:第一天的最小波动值为第一天的营业额。 数据范围:天数 n≤32767,每天的营业额 ai≤1,000,000。最后结果 T≤2 31。
初步分析
题目的意思非常明确,关键是要每次读入一个数,并且在前面输入的数中找 到一个与该数相差最小的一个。 我们很容易想到 O(n2)的算法:每次读入一个数,再将前面输入的数一次查 找一遍,求出与当前数的最小差值,记入总结果 T。但由于本题中 n 很大,这样 的算法是不可能在时限内出解的。而如果使用线段树记录已经读入的数,就需要 记下一个 2M 的大数组,这在当时比赛使用 TurboPascal 7.0 编程的情况下是不可 能实现的。而前文提到的红黑树与平衡二叉树虽然在时间效率、空间复杂度上都 比较优秀,但过高的编程复杂度却让人望而却步。于是我们想到了伸展树算法。
算法描述
进一步分析本题,解题中,涉及到对于有序集的三种操作:插入、求前趋、 求后继。而对于这三种操作,伸展树的时间复杂度都非常优秀,于是我们设计了 如下算法: 开始时,树 S 为空,总和 T 为零。每次读入一个数 p,执行 Insert(p,S),将 p 插入伸展树 S。这时,p 也被调整到伸展树的根节点。这时,求出 p 点左子树中 的最右点和右子树中的最左点,这两个点分别是有序集中 p 的前趋和后继。然后 求得最小差值,加入最后结果 T。
解题小结
由于对于伸展树的基本操作的平摊复杂度都是 O(log n)的,所以整个算法的 时间复杂度是 O(nlog n),可以在时限内出解。而空间上,可以用数组模拟指针 存储树状结构,这样所用内存不超过 400K,在 TP 中使用动态内存就可以了。 编程复杂度方面,伸展树算法非常简单,程序并不复杂。虽然伸展树算法并不是 本题唯一的算法,但它与其他常用的数据结构相比还是有很多优势的。下面的表 格就反映了在解决这一题时各个算法的复杂度。从中可以看出伸展树在各方面都 是优秀的,这样的算法很适合在竞赛中使用。
| 顺序查找 | 线段树 | AVL树 | 伸展树 | |
|---|---|---|---|---|
| 时间复杂度 | O(n^2) | O(nlog a) | O(nlog n) | O(nlog n) |
| 空间复杂度 | O(n) | O(a) | O(n) | O(n) |
| 编程复杂度 | 很简单 | 较简单 | 较复杂 | 较简单 |
【总结】
由上面的分析介绍,我们可以发现伸展树有以下几个优点: (1)时间复杂度低,伸展树的各种基本操作的平摊复杂度都是 O(log n)的。在 树状数据结构中,无疑是非常优秀的。
(2)空间要求不高。与红黑树需要记录每个节点的颜色、AVL 树需要记录平 衡因子不同,伸展树不需要记录任何信息以保持树的平衡。 (3)算法简单,编程容易。伸展树的基本操作都是以 Splay 操作为基础的,而 Splay 操作中只需根据当前节点的位置进行旋转操作即可。
上题参考代码:
1 /************************************************************** 2 Problem: 1588 3 User: SongHL 4 Language: C++ 5 Result: Accepted 6 Time:1284 ms 7 Memory:2068 kb 8 ****************************************************************/ 9 10 #include<bits/stdc++.h> 11 const int INF=0x3f3f3f3f; 12 using namespace std; 13 int ans,n,t1,t2,rt,size; 14 int tr[50001][2],fa[50001],num[50001]; 15 void rotate(int x,int &k) 16 { 17 int y=fa[x],z=fa[y],l,r; 18 if(tr[y][0]==x)l=0;else l=1;r=l^1; 19 if(y==k)k=x; 20 else{if(tr[z][0]==y)tr[z][0]=x;else tr[z][1]=x;} 21 fa[x]=z;fa[y]=x;fa[tr[x][r]]=y; 22 tr[y][l]=tr[x][r];tr[x][r]=y; 23 } 24 void splay(int x,int &k) 25 { 26 int y,z; 27 while(x!=k) 28 { 29 y=fa[x],z=fa[y]; 30 if(y!=k) 31 { 32 if((tr[y][0]==x)^(tr[z][0]==y))rotate(x,k); 33 else rotate(y,k); 34 } 35 rotate(x,k); 36 } 37 } 38 void ins(int &k,int x,int last) 39 { 40 if(k==0){size++;k=size;num[k]=x;fa[k]=last;splay(k,rt);return;} 41 if(x<num[k])ins(tr[k][0],x,k); 42 else ins(tr[k][1],x,k); 43 } 44 void ask_before(int k,int x) 45 { 46 if(k==0)return; 47 if(num[k]<=x){t1=num[k];ask_before(tr[k][1],x);} 48 else ask_before(tr[k][0],x); 49 } 50 void ask_after(int k,int x) 51 { 52 if(k==0)return; 53 if(num[k]>=x){t2=num[k];ask_after(tr[k][0],x);} 54 else ask_after(tr[k][1],x); 55 } 56 int main() 57 { 58 scanf("%d",&n); 59 for(int i=1;i<=n;i++) 60 { 61 int x;if(scanf("%d",&x)==EOF) x=0; 62 t1=-INF;t2=INF; 63 ask_before(rt,x); 64 ask_after(rt,x); 65 if(i!=1)ans+=min(x-t1,t2-x); 66 else ans+=x; 67 ins(rt,x,0); 68 } 69 printf("%d",ans); 70 return 0; 71 }