人的知识就好比一个圆圈,圈里面是已知的,圈外面是未知的。你知道的越多,圆圈也就越大,你不知道的也就多。
——芝诺
【序言】在学习了一系列的算法之后,虽然内心是充实了一些,可是依旧对那些更厉害的算法抱有很大的渴望和疑惑。今天我就好好来研究一下伸展树(splay)的有关基础操作。以下是我对2004年国家集训队杨思雨的论文解读。先声明:本人对splay一无所知,菜鸟们可以和我一起探讨,同时也希望各路神牛路过后能多多留言指导!(因网速原因,图插不进去,请谅解)
《伸展树的基本操作与应用》
安徽省芜湖一中 杨思雨
【引言】二叉查找树(Binary Search Tree)能够支持多种动态集合操作。因此,在信息学竞赛中, 二叉排序树起着非常重要的作用,它可以被用来表示有序集合、建立索引或优先队列等。作用于二叉查找树上的基本操作的时间是与树的高度成正比的。对一个含 n各节点的完全二叉树,这些操作的最坏情况运行时间为 O(log n)。但如果树是含n 个节点的线性链, 则这些操作的最坏情况运行时间为 O(n)。 而有些二叉查找树的变形,其基本操作在最坏情况下性能依然很好,比如红黑树、AVL 树等等。本文将要介绍的伸展树(Splay Tree),也是对二叉查找树的一种改进,虽然它并不能保证树一直是 “平衡”的,但对于伸展树的一系列操作,我们可以证明其每一步操作的平摊复杂度都是 O(l og n)。所以从某种意义上说,伸展树也是一种平衡的二叉查找树。而在各种树状数据结构中,伸展树的空间要求与编程复杂度也都是很优秀的。
这段大概意思是splay是一种非常强大的算法。它虽然不是一直“平衡”的,但性能相对很稳定、编写也简单。
而且splay是在二叉查找树的基础上改进的,有优秀的时间效率。
伸展树的基本操作
伸展树是二叉查找树的一种改进,与二叉查找树一样,伸展树也具有有序性。即伸展树中的每一个节点 x 都满足:该节点左子树中的每一个元素都小于x,而其右子树中的每一个元素都大于x。与普通二叉查找树不同的是,伸展树可以自我调整,这就要依靠伸展操作Splay(x,S)。
【伸展操作】Splay(x,S)
伸展操作 Spl ay(x,S)是在保持伸展树有序性的前提下,通过一系列旋转将伸展树 S 中的元素 x 调整至树的根部。在调整的过程中, 要分以下三种情况分别处理。
情况一:节点 x 的父节点 y 是根节点。这时,如果 x 是 y 的左孩子,我们进行一次 Zig(右旋)操作;如果 x 是 y 的右孩子,则我们进行一次 Zag(左旋)操作。经过旋转,x 成为二叉查找树S的根节点,调整结束。
情况二:节点 x 的父节点 y 不是根节点,y 的父节点为 z,且 x 与 y 同时是各自父节点的左孩子或者同时是各自父节点的右孩子。这时,我们进行一次Zi g-Zig 操作或者 Zag-Zag 操作。
情况三: 节点 x 的父节点 y 不是根节点, y 的父节点为 z, x 与 y 中一个是其父节点的左孩子而另一个是其父节点的右孩子。这时,我们进行一次 Zi g-Zag 操作或者 Zag-Zig 操作。如图 3 所示
中途疑问:如果不止这些情况呢?想了一下,这些操作应该是基本操作,可以用来拓展所有旋转操作。
如图4,执行 Splay(1,S),我们将元素1调整到了伸展树S的根部。再可以了,而三种旋转都是由基本得左旋和右旋组成的,实现较为简单。执行Splay(2,S),如图5,我们从直观上可以看出在经过调整后,伸展树比原来“平衡”了许多。而伸展操作的过程并不复杂,只需要根据情况进行旋转就可以了,而三种旋转都是由基本得左旋和右旋组成的,实现较为简单。
可惜图传不上……原来的图4完全退化成一条链子,而经过若干旋转之后,图5的确“平衡”了不少。原来,为了使二叉查找树不会被数据卡成一条链,就相当于加了个不断旋转的操作,来保证log(n)的效率。
中途疑问:zig和zag效率是多少?会不会因为执行过多使常数太大,拖慢运行时间?
【伸展树的基本操作】利用 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。
(5)Split(x,S):以 x 为界,将伸展树 S 分离为两棵伸展树 S1 和 S2,其中 S1中所有元素都小于 x,S2 中的所有元素都大于 x。首先执行 Find(x,S),将元素 x 调整为伸展树的根节点,则 x 的左子树就是S1,而右子树为 S2。
除了上面介绍的五种基本操作,伸展树还支持求最大值、 求最小值、 求前趋、求后继等多种操作,这些基本操作也都是建立在伸展操作的基础上的。
乍一看还有点像线段树嘛!第一和二的操作容易理解,就是普通的BST操作,只是在做完后还要把点旋转一次。第三次操作的疑问:若x只有一个孩子,按步骤把它删去,那么当他的父亲变成根节点时,它的孩子该怎么办?
第四、五次操作的疑问:若如何确保S1和S2所有元素的大小都大于(或小于)x?
【时间复杂度分析】由以上这些操作的实现过程可以看出,它们的时间效率完全取决于 Splay操作的时间复杂度。下面,我们就用会计方法来分析Splay操作的平摊复杂度。首先,我们定义一些符号:S(x)表示以节点x为根的子树。|S|表示伸展树 S的节点个数。令μ(S)= [log|S|],μ(x)=μ(S(x))。
我们用 1元钱表示单位代价(这里我们将对于某个点访问和旋转看作一个单位时间的代价)。定义伸展树不变量:在任意时刻,伸展树中的任意节点 x 都至少有μ(x)元的存款。在 Splay 调整过程中,费用将会用在以下两个方面:
(1)为使用的时间付费。也就是每一次单位时间的操作,我们要支付 1 元钱。
(2)当伸展树的形状调整时, 我们需要加入一些钱或者重新分配原来树中每个节点的存款,以保持不变量继续成立。
下面我们给出关于 Splay 操作花费的定理:
定理:在每一次Splay(x,S)操作中,调整树的结构与保持伸展树不变量的总花费不超过3μ(S)+1。
证明:用μ(x)和μ’(x)分别表示在进行一次 Zig、Zig-Zig 或 Zig-Zag 操作前后节点 x 处的存款。
下面我们分三种情况分析旋转操作的花费:
情况一:我们进行 Zig 或者 Zag 操作时,为了保持伸展树不变量继续成立,我们需要花费:
μ’(x)+μ’(y)-μ(x)-μ(y)= μ’(y)-μ(x)
≤ μ’(x)-μ(x)
≤ 3(μ’(x)-μ(x))
= 3(μ(S)-μ(x))
此外我们花费另外1元钱用来支付访问、旋转的基本操作。因此,一次Zig或Zag操作的花费至多为 3(μ(S)-μ(x))。
情况二:我们进行 Zig-Zig 操作时,为了保持伸展树不变量,我们需要花费:
μ’(x)+μ’(y)+μ’(z)-μ(x)-μ(y)-μ(z)
=μ’(y)+μ’(z)-μ(x)-μ(y)
=(μ’(y)-μ(x))+(μ’(z)-μ(y))
≤(μ’(x)-μ(x)) + (μ’(x)-μ(x))
= 2(μ’(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]= [logb]= [log (a+b+1)]。 ①
我们不妨设 b≥a,则有
[log (a+b+1)] ≥ [log (2a)]
= 1+[log a]
> [log a] ②
①与②矛盾,所以我们可以得到μ’(x)=μ(x)时,Zig-Zig 操作不需要任何花费,显然也不超过3(μ’(x)-μ(x))。
情况三:与情况二类似,我们可以证明,每次 Zi g-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).所以说,伸展树是一种时间效率非常优秀的数据结构。
看到这里就彻底迷茫了。不过安慰自己:这可是国家队的论文!果断跳过。(o(≧v≦)o~~好棒
伸展树的应用
伸展树作为一种时间效率很高、空间要求不大的数据结构,在解题中有很大的用武之地。下面就通过一个例子说明伸展树在解题中的应用。
例:营业额统计 Turnover (湖南省队 2002 年选拔赛)
【题目大意】Tiger最近被公司升任为营业部经理,他上任后接受公司交给的第一项任务便是统计并分析公司成立以来的营业情况。Tiger 拿出了公司的账本,账本上记录了公司成立以来每天的营业额。分析营业情况是一项相当复杂的工作。 由于节假日,大减价或者是其他情况的时候,营业额会出现一定的波动,当然一定的波动是能够接受的, 但是在某些时候营业额突变得很高或是很低,这就证明公司此时的经营状况出现了问题。经济管理学上定义了一种最小波动值来衡量这种情况:该天的最小波动值=min{|该天以前某一天的营业额-该天的营业额|} 当最小波动值越大时,就说明营业情况越不稳定。而分析整个公司的从成立到现在营业情况是否稳定, 只需要把每一天的最小波动值加起来就可以了。你的任务就是编写一个程序帮助Tiger来计算这一个值。
注:第一天的最小波动值为第一天的营业额。
【数据范围】天数 n≤32767,每天的营业额 ai≤1,000,000。最后结果 T≤2^31.
【初步分析】题目的意思非常明确,关键是要每次读入一个数,并且在前面输入的数中找到一个与该数相差最小的一个。我们很容易想到 O(n^2)的算法:每次读入一个数,再将前面输入的数一次查找一遍,求出与当前数的最小差值,记入总结果 T。但由于本题中n很大,这样的算法是不可能在时限内出解的。 而如果使用线段树记录已经读入的数,就需要记下一个2M的大数组,这在当时比赛使用 TurboPascal 7.0 编程的情况下是不可能实现的。 而前文提到的红黑树与平衡二叉树虽然在时间效率、 空间复杂度上都比较优秀,但过高的编程复杂度却让人望而却步。于是我们想到了伸展树算法。
【算法描述】进一步分析本题,解题中,涉及到对于有序集的三种操作:插入、求前趋、求后继。而对于这三种操作,伸展树的时间复杂度都非常优秀,于是我们设计了如下算法:开始时,树S为空,总和T为零。每次读入一个数p,执行 Insert(p,S),将p插入伸展树S。这时,p也被调整到伸展树的根节点。这时,求出p点左子树中的最右点和右子树中的最左点,这两个点分别是有序集中p的前趋和后继。然后求得最小差值,加入最后结果T。
【解题小结】由于对于伸展树的基本操作的平摊复杂度都是 O(logn)的,所以整个算法的时间复杂度是 O(nlogn),可以在时限内出解。而空间上,可以用数组模拟指针存储树状结构,这样所用内存不超过 400K,在 TP 中使用动态内存就可以了。编程复杂度方面,伸展树算法非常简单, 程序并不复杂。虽然伸展树算法并不是本题唯一的算法, 但它与其他常用的数据结构相比还是有很多优势的。下面的表格就反映了在解决这一题时各个算法的复杂度。 从中可以看出伸展树在各方面都是优秀的,这样的算法很适合在竞赛中使用。
顺序查找 线段树 AVL 树 伸展树
时间复杂度 O(n^2) O(nl og a) O(nl og n) O(nl og n)
空间复杂度 O(n) O(a) O(n) O(n)
编程复杂度 很简单 较简单 较复杂 较简单
这道题的分析看了好长时间才看懂。这个算法真是灵活!解释一下,前驱指的是所有<x的数中最大的,而后继指的是所有>x的数中最小的。我们要求最小的差,就是比较这两个数与x的差。因为splay是二叉查找树,所以它满足左节点小于根节点小于右节点,很容易证明前驱、后继选择的正确性。
【总结】由上面的分析介绍,我们可以发现伸展树有以下几个优点:
(1)时间复杂度低,伸展树的各种基本操作的平摊复杂度都是 O(logn)的。在树状数据结构中,无疑是非常优秀的。(2)空间要求不高。与红黑树需要记录每个节点的颜色、AVL树需要记录平衡因子不同,伸展树不需要记录任何信息以保持树的平衡。
(3)算法简单 编程容易。伸展树的基本操作都是以Splay操作为基础的,而Splay操作中只需根据当前节点的位置进行旋转操作即可。
虽然伸展树算法与AVL树在时间复杂度上相差不多,甚至有时候会比AVL树慢一些,但伸展树的编程复杂度大大低于 A VL树。在竞赛中,使用伸展树在编程和调试中都更有优势。在信息学竞赛中,不能只一味的追求算法有很高的时间效率, 而需要在时间复杂度、空间复杂度、编程复杂度三者之间找到一个“平衡点”,合理的选择算法。 这也需要我们在平时对各种算法反复琢磨,深入研究,在竞赛中才能够游刃有余的应用。
看完之后,对splay有了一个较深的了解,但是涉及到代码问题的,还是不太会。
以下是百度百科中伸展树的代码:
const int SPLAYmaxn = 200005;
const int SPLAYinf = 100000000;
struct Splay_Node
{
int l, r, fa, v, sum;
};
struct Splay
{
Splay_Node t[SPLAYmaxn];
int root, tot;
void create()
{
root = 1, tot = 2;
t[1].v = -SPLAYinf;
t[2].v = SPLAYinf;
t[1].r = t[1].sum = 2;
t[2].fa = t[2].sum = 1;
}
void update(int now)
{
t[now].sum = t[t[now].l].sum + t[t[now].r].sum + 1;
}
void left(int now)
{
int fa = t[now].fa;
t[now].fa = t[fa].fa;
if (t[t[fa].fa].l == fa) t[t[fa].fa].l = now;
if (t[t[fa].fa].r == fa) t[t[fa].fa].r = now;
t[fa].fa = now;
t[fa].r = t[now].l;
t[t[now].l].fa = fa;
t[now].l = fa;
update(fa);
}
void right(int now)
{
int fa = t[now].fa;
t[now].fa = t[fa].fa;
if (t[t[fa].fa].l == fa) t[t[fa].fa].l = now;
if (t[t[fa].fa].r == fa) t[t[fa].fa].r = now;
t[fa].fa = now;
t[fa].l = t[now].r;
t[t[now].r].fa = fa;
t[now].r = fa;
update(fa);
}
void splay(int now, int FA = 0)
{
while (t[now].fa != FA)
{
int fa = t[now].fa;
if (t[fa].fa == FA)
if (t[fa].l == now) right(now);
else left(now);
else
if (t[t[fa].fa].l == fa)
if (t[fa].l == now) right(fa), right(now);
else left(now), right(now);
else
if (t[fa].l == now) right(now), left(now);
else left(fa), left(now);
}
update(now);
if (!FA) root = now;
}
int lower_bound(int v)
{
int ans = 0, la = 0;
for (int now = root; now;)
{
la = now;
if (t[now].v >= v) ans = now, now = t[now].l;
else now = t[now].r;
}
splay(la);
return ans;
}
void insert(int v)
{
for (int now = root;;)
{
++t[now].sum;
if (t[now].v >= v)
if (t[now].l) now = t[now].l;
else
{
t[now].l = ++tot;
t[tot].sum = 1;
t[tot].fa = now;
t[tot].v = v;
splay(tot);
return;
}
else
if (t[now].r) now = t[now].r;
else
{
t[now].r = ++tot;
t[tot].sum = 1;
t[tot].fa = now;
t[tot].v = v;
splay(tot);
return;
}
}
}
int get_lower(int a)
{
splay(a);
for (a = t[a].l; t[a].r; a = t[a].r);
return a;
}
int get_upper(int a)
{
splay(a);
for (a = t[a].r; t[a].l; a = t[a].l);
return a;
}
int get_rank(int a)
{
splay(a);
return t[t[a].l].sum;
}
void del(int l, int r)
{
l = get_lower(l);
r = get_upper(r);
splay(l);
splay(r, l);
t[r].l = 0;
update(r);
update(l);
}
int get_kth(int k)
{
++k;
for (int now = root;;)
{
if (t[t[now].l].sum == k - 1)
return now;
if (t[t[now].l].sum >= k) now = t[now].l;
else k -= t[t[now].l].sum + 1, now = t[now].r;
}
}
};
【后记】由于PDF的格式太高级,复制文本时老是出现乱码,做得很辛苦。感觉很不过瘾?我也是这么想的,感觉splay还是个很抽象的名次。众大牛路过请多多指导,推荐书啦(算法导论太厚了,吃不消)、网址啦(maxtrix67很不错!)都可以。
最后附一个HHD神牛链接:弱弱的splay树入门级教程