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)
伸展操作Splay(x,S)是在保持伸展树有序性的前提下,通过一系列旋转将伸展树S中的元素x调整至树的根部。在调整的过程中,要分以下三种情况分别处理:
情况一:节点x的父节点y是根节点。这时,如果x是y的左孩子,我们进行一次Zig(右旋)操作;如果x 是y 的右孩子,则我们进行一次Zag(左旋)操作。经过旋转,x成为二叉查找树S的根节点,调整结束。即:如果当前结点父结点即为根结点,那么我们只需要进行一次简单旋转即可完成任务,我们称这种旋转为单旋转。如图1所示
(图1)
情况二:节点x 的父节点y 不是根节点,y 的父节点为z,且x 与y 同时是各自父节点的左孩子或者同时是各自父节点的右孩子。这时,我们进行一次Zig-Zig操作或者Zag-Zag操作。即:设当前结点为X , X 的父结点为Y ,Y 的父结点为Z ,如果Y 和X 同为其父亲的左孩子或右孩子,那么我们先旋转Y ,再旋转X 。我们称这种旋转为一字形旋转。如图2所示
(图2)
情况三:节点x的父节点y不是根节点,y的父节点为z,x与y中一个是其父节点的左孩子而另一个是其父节点的右孩子。这时,我们进行一次Zig-Zag操作或者Zag-Zig 操作。即:这时我们连续旋转两次X 。我们称这种旋转为之字形旋转。如图3所示
图3)
如图4所示,执行Splay(1,S),我们将元素1 调整到了伸展树S 的根部。再执行Splay(2,S),如图5 所示,我们从直观上可以看出在经过调整后,伸展树比原来“平衡”了许多。而伸展操作的过程并不复杂,只需要根据情况进行旋转就可以了,而三种旋转都是由基本得左旋和右旋组成的,实现较为简单。
(图4)
(图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所示
(图6)
(5)Split(x,S):以x 为界,将伸展树S 分离为两棵伸展树S1 和S2,其中S1中所有元素都小于x,S2中的所有元素都大于x。首先执行Find(x,S),将元素x 调整为伸展树的根节点,则x 的左子树就是S1,而右子树为S2。如图7所示
(图7)
除了上面介绍的五种基本操作,伸展树还支持求最大值、求最小值、求前趋、求后继等多种操作,这些基本操作也都是建立在伸展操作的基础上的。
Splay 操作,这样可以保证每次操作的平摊时间复杂度是O(log n)。关于证明可以参见相关书籍和论文。
Null 的下面)。下面的利用伸展树维护数列就要用到将一个结点转到某个结点下面。
最后附上Splay 操作的代码:
1. // node 为结点类型,其中ch[0]表示左结点指针,ch[1]表示右结点指针
2. // pre 表示指向父亲的指针
3. void Rotate(node *x, int c) // 旋转操作,c=0 表示左旋,c=1 表示右旋
4. {
5. node *y = x->pre;
6. y->ch[! c] = x->ch[c];
7. if (x->ch[c] != Null) x->ch[c]->pre = y;
8. x->pre = y->pre;
9. if (y->pre != Null)
10. if (y->pre->ch[0] == y) y->pre->ch[0] = x;
11. else y->pre->ch[1] = x;
12. x->ch[c] = y, y->pre = x;
13. if (y == root) root = x; // root 表示整棵树的根结点
14. }
15. void Splay(node *x, node *f) // Splay 操作,表示把结点x 转到结点f 的下面
16. {
17. for ( ; x->pre != f; )
18. if (x->pre->pre == f) // 父结点的父亲即为f,执行单旋转
19. if (x->pre->ch[0] == x) Rotate(x, 1);
20. else Rotate(x, 0);
21. else
22. {
23. node *y = x->pre, *z = y->pre;
24. if (z->ch[0] == y)
25. if (y->ch[0] == x)
26. // 一字形旋转
27. else
28. // 之字形旋转
29. else if (y->ch[1] == x)
30. // 一字形旋转
31. else
32. // 之字形旋转
33. }
34. }
【伸展树的区间操作】
首先我们认为伸展树的中序遍历即为我们维护的数列,那么很重要的一个操作就是怎么在伸展树中表示任意一个区间。比如我们要提取区间a,b],那么我们将a前面一个数对应的结点转到树根,将b 后面一个结点对应的结点转到树根的右边,那么根右边的左子树就对应了区间[a,b]。其中的道理也是很简单的,将a 前面一个数对应的结点转到树根后, a 及a 后面的数就在根的右子树上,然后又将b后面一个结点对应的结点转到树根的右边,那么[a,b]这个区间就是图8中*所示的子树。
利用这个,我们就可以实现线段树的一些功能,比如回答对区间的询问。我们在每个结点上记录关于以这个结点为根的子树的信息,然后询问时先提取区间,再直接读取子树的相关信息。还可以对区间进行整体修改,这也要用到和线段树类似的延迟标记技术,就是对于每个结点,再额外记录一个或多个标记,表示以这个结点为根的子树是否被进行了某种操作,并且这种操作影响其子结点的信息值。当然,既然记录了标记,那么旋转和其他一些操作中也就要相应地将标记向下传递。
(图8)
到目前为止,伸展树只是实现了线段树能够实现的功能,下面两个功能将是线段树无法办到的。如果我们要在a 后面插入一些数,那么我们先把这些插入的数建成一棵伸展树,我们可以利用分治法建立一棵完全平衡的二叉树,就是说每次把最中间的作为当前区间的根,然后左右递归处理,返回的时候进行维护。接着将a 转到根,将a 后面一个数对应的结点转到根结点的右边,最后将这棵新的子树挂到根右子结点的左子结点上。还有一个操作就是删除一个区间[a,b]内的数,像上面一样,我们先提取区间,然后直接删除那棵子树,即可达到目的。最后还需注意的就是,每当进行一个对数列进行修改的操作后,都要维护伸展树,一种方法就是对影响到的结点从下往上执行Update 操作。但还有一种方法,就是将修改的结点旋转到根,因为Splay 操作在旋转的同时也会维护每个结点的值,因此可以达到对整个伸展树维护的目的。最后还有一个小问题,因为数列中第一个数前面没有数字了,并且最后一个数后面也没有数字了,这样提取区间时就会出一些问题。为了不进行过多的特殊判断,我们在原数列最前面和最后面分别加上一个数,在伸展树中就体现为结点,这样提取区间的时候原来的第k个数就是现在的第k +1个数。并且我们还要注意,这两个结点维护的信息不能影响到正确的结果。下面看一下新的Splay 操作的程序(能对结点信息进行维护):
1. // node 为结点类型,其中ch[0]表示左结点指针,ch[1]表示右结点指针
2. // pre 表示指向父亲的指针
3. void Rotate(node *x, int c) // 旋转操作,c=0 表示左旋,c=1 表示右旋
4. {
5. node *y = x->pre;
6. Push_Down(y), Push_Down(x);
7. // 先将Y 结点的标记向下传递(因为Y 在上面),再把X 的标记向下传递
8. y->ch[! c] = x->ch[c];
9. if (x->ch[c] != Null) x->ch[c]->pre = y;
10. x->pre = y->pre;
11. if (y->pre != Null)
12. if (y->pre->ch[0] == y) y->pre->ch[0] = x;
13. else y->pre->ch[1] = x;
14. // 维护Y 结点
15. if (y == root) root = x; // root 表示整棵树的根结点
16. }
17. void Splay(node *x, node *f) // Splay 操作,表示把结点x 转到结点f 的下面
18. {
19. for (Push_Down(x) ; x->pre != f; ) // 一开始就将X 的标记下传
20. if (x->pre->pre == f) // 父结点的父亲即为f,执行单旋转
21. if (x->pre->ch[0] == x) Rotate(x, 1);
22. else Rotate(x, 0);
23. else
24. {
25. node *y = x->pre, *z = y->pre;
26. if (z->ch[0] == y)
27. if (y->ch[0] == x)
28. // 一字形旋转
29. else
30. // 之字形旋转
31. else if (y->ch[1] == x)
32. // 一字形旋转
33. else
34. // 之字形旋转
35. }
36. // 最后再维护X 结点
37. }
可能有人会问,为什么在旋转的时候只对X 结点的父亲进行维护,而不对X结点进行维护,但是Splay 操作的最后却又维护了X 结点?原因很简单。因为除了一字形旋转,在Splay 操作里我们进行的旋转都只对X 结点进行,因此过早地维护是多余的;而在一字形旋转中,好像在旋转中没有对X 的父亲进行维护,但后面紧接着就是旋转X 结点,又会对X 的父亲进行维护,也是没问题的。这样可以节省不少冗余的Update 操作,能减小程序隐含的常数。
k 个数对应的结点转到想要的位置。对于这个操作,我们要记录每个以结点为根子树的大小,即包含结点的个数,然后从根开始,每次决定是向左走,还是向右走,具体见下面的代码:
1. // 找到处在中序遍历第k 个结点,并将其旋转到结点f 的下面
2. void Select(int k, node *f)
3. {
4. int tmp;
5. node *t;
6. for (t = root; ; ) // 从根结点开始
7. {
8. // 由于要访问t 的子结点,将标记下传
9. // 得到t 左子树的大小
10. if (k == tmp + 1) break; // 得出t 即为查找结点,退出循环
11. if (k <= tmp) // 第k 个结点在t 左边,向左走
12. t = t->ch[0];
13. else // 否则在右边,而且在右子树中,这个结点不再是第k 个
14. k -= tmp + 1, t = t->ch[1];
15. }
16. // 执行旋转
17. }
参考:(1) 杨思雨《伸展树的基本操作与应用》
(2) Crash《运用伸展树解决数列维护问题》
作者:Vamei 出处: 欢迎转载,也请保留这段声明。谢谢!
我们讨论过,树的搜索效率与树的深度有关。二叉搜索树的深度可能为n,这种情况下,每次搜索的复杂度为n的量级。AVL树通过动态平衡树的深度,单次搜索的复杂度为log(n) (以上参考纸上谈兵 AVL树)。我们下面看伸展树(splay tree),它对于m次连续搜索操作有很好的效率。
伸展树会在一次搜索后,对树进行一些特殊的操作。这些操作的理念与AVL树有些类似,即通过旋转,来改变树节点的分布,并减小树的深度。但伸展树并没有AVL的平衡要求,任意节点的左右子树可以相差任意深度。与二叉搜索树类似,伸展树的单次搜索也可能需要n次操作。但伸展树可以保证,m次的连续搜索操作的复杂度为mlog(n)的量级,而不是mn量级。
具体来说,在查询到目标节点后,伸展树会不断进行下面三种操作中的一个,直到目标节点成为根节点 (注意,祖父节点是指父节点的父节点)
1. zig: 当目标节点是根节点的左子节点或右子节点时,进行一次单旋转,将目标节点调整到根节点的位置。
zig
2. zig-zag: 当目标节点、父节点和祖父节点成"zig-zag"构型时,进行一次双旋转,将目标节点调整到祖父节点的位置。
zig-zag
3. zig-zig:当目标节点、父节点和祖父节点成"zig-zig"构型时,进行一次zig-zig操作,将目标节点调整到祖父节点的位置。
zig-zig
单旋转操作和双旋转操作见AVL树。下面是zig-zig操作的示意图:
zig-zig operation
在伸展树中,zig-zig操作(基本上)取代了AVL树中的单旋转。通常来说,如果上面的树是失衡的,那么A、B子树很可能深度比较大。相对于单旋转(想一下单旋转的效果),zig-zig可以将A、B子树放在比较高的位置,从而减小树总的深度。
下面我们用一个具体的例子示范。我们将从树中搜索节点2:
Original
zig-zag (double rotation)
zig-zig
zig (single rotation at root)
上面的第一次查询需要n次操作。然而经过一次查询后,2节点成为了根节点,树的深度大减小。整体上看,树的大部分节点深度都减小。此后对各个节点的查询将更有效率。
伸展树的另一个好处是将最近搜索的节点放在最容易搜索的根节点的位置。在许多应用环境中,比如网络应用中,某些固定内容会被大量重复访问(比如江南style的MV)。伸展树可以让这种重复搜索以很高的效率完成。
1、 概述
二叉查找树(Binary Search Tree,也叫二叉排序树,即Binary Sort Tree)能够支持多种动态集合操作,它可以用来表示有序集合、建立索引等,因而在实际应用中,二叉排序树是一种非常重要的数据结构。
从算法复杂度角度考虑,我们知道,作用于二叉查找树上的基本操作(如查找,插入等)的时间复杂度与树的高度成正比。对一个含n个节点的完全二叉树,这些操作的最坏情况运行时间为O(log n)。但如果因为频繁的删除和插入操作,导致树退化成一个n个节点的线性链(此时即为一个单链表),则这些操作的最坏情况运行时间为O(n)。为了克服以上缺点,很多二叉查找树的变形出现了,如红黑树、AVL树,Treap树等。
本文介绍了二叉查找树的一种改进数据结构–伸展树(Splay Tree)。它的主要特点是不会保证树一直是平衡的,但各种操作的平摊时间复杂度是O(log n),因而,从平摊复杂度上看,二叉查找树也是一种平衡二叉树。另外,相比于其他树状数据结构(如红黑树,AVL树等),伸展树的空间要求与编程复杂度要小得多。
2、 基本操作
伸展树的出发点是这样的:考虑到局部性原理(刚被访问的内容下次可能仍会被访问,查找次数多的内容可能下一次会被访问),为了使整个查找时间更小,被查频率高的那些节点应当经常处于靠近树根的位置。这样,很容易得想到以下这个方案:每次查找节点之后对树进行重构,把被查找的节点搬移到树根,这种自调整形式的二叉查找树就是伸展树。每次对伸展树进行操作后,它均会通过旋转的方法把被访问节点旋转到树根的位置。
为了将当前被访问节点旋转到树根,我们通常将节点自底向上旋转,直至该节点成为树根为止。“旋转”的巧妙之处就是在不打乱数列中数据大小关系(指中序遍历结果是全序的)情况下,所有基本操作的平摊复杂度仍为O(log n)。
伸展树主要有三种旋转操作,分别为单旋转,一字形旋转和之字形旋转。为了便于解释,我们假设当前被访问节点为X,X的父亲节点为Y(如果X的父亲节点存在),X的祖父节点为Z(如果X的祖父节点存在)。
(1) 单旋转
节点X的父节点Y是根节点。这时,如果X是Y的左孩子,我们进行一次右旋操作;如果X 是Y 的右孩子,则我们进行一次左旋操作。经过旋转,X成为二叉查找树T的根节点,调整结束。
(2) 一字型旋转
节点X 的父节点Y不是根节点,Y 的父节点为Z,且X与Y同时是各自父节点的左孩子或者同时是各自父节点的右孩子。这时,我们进行一次左左旋转操作或者右右旋转操作。
(3) 之字形旋转
节点X的父节点Y不是根节点,Y的父节点为Z,X与Y中一个是其父节点的左孩子而另一个是其父节点的右孩子。这时,我们进行一次左右旋转操作或者右左旋转操作。
3、伸展树区间操作
在实际应用中,伸展树的中序遍历即为我们维护的数列,这就引出一个问题,怎么在伸展树中表示某个区间?比如我们要提取区间[a,b],那么我们将a前面一个数对应的结点转到树根,将b 后面一个结点对应的结点转到树根的右边,那么根右边的左子树就对应了区间[a,b]。原因很简单,将a 前面一个数对应的结点转到树根后, a 及a 后面的数就在根的右子树上,然后又将b后面一个结点对应的结点转到树根的右边,那么[a,b]这个区间就是下图中B所示的子树。
利用区间操作我们可以实现线段树的一些功能,比如回答对区间的询问(最大值,最小值等)。具体可以这样实现,在每个结点记录关于以这个结点为根的子树的信息,然后询问时先提取区间,再直接读取子树的相关信息。还可以对区间进行整体修改,这也要用到与线段树类似的延迟标记技术,即对于每个结点,额外记录一个或多个标记,表示以这个结点为根的子树是否被进行了某种操作,并且这种操作影响其子结点的信息值,当进行旋转和其他一些操作时相应地将标记向下传递。
与线段树相比,伸展树功能更强大,它能解决以下两个线段树不能解决的问题:
(1) 在a后面插入一些数。方法是:首先利用要插入的数构造一棵伸展树,接着,将a 转到根,并将a 后面一个数对应的结点转到根结点的右边,最后将这棵新的子树挂到根右子结点的左子结点上。
(2) 删除区间[a,b]内的数。首先提取[a,b]区间,直接删除即可。
本人代码:
void new_node(int &r, int fa, int k)
{
r = ++num;
pre[r] = fa, key[r] = k;
son[r][0] = son[r][1] = 0;
}
/*旋转,kind为1为右旋,kind为0为左旋*/
void _rotate(int r, int kind)
{
int y = pre[r];
/*把其中一个分支给父亲节点,若左分支变成父亲的右子树,右分支变成父亲的左子树*/
son[y][!kind] = son[r][kind];
pre[son[r][kind]] = y;
/*如果父节点不是根节点,把当前节点和祖父节点连接*/
if(pre[y]) son[pre[y]][son[pre[y]][1]==y] = r;
pre[r] = pre[y];
son[r][kind] = y; /*若左旋父节点变为当前点的左儿子,右旋变为右儿子*/
pre[y] = r;
}
/*splay调整,将根为r的子树调整为goal*/
void splay(int r, int goal)
{
while(pre[r] != goal)
{
/*父节点为根节点*/
if(pre[pre[r]] == goal)
_rotate(r, son[pre[r]][0] == r); /*判断r是父节点的左右儿子选择相应旋转*/
else
{
int y = pre[r];
int kind = son[pre[y]][0] == y; /*判断父节点是祖父节点的左右儿子,左儿子为1,右儿子为0*/
if(son[y][kind] == r)
{
/*r和父节点、父节点和祖父节点间边的方向不同,左右旋转或者右左旋转*/
_rotate(r, ! kind);
_rotate(r, kind);
}
else
{
/*方向相同,相同方向旋转两次,先旋转父节点*/
_rotate(y, kind);
_rotate(r, kind);
}
}
}
if(goal == 0) root = r; /*更新根节点*/
}
/*只有单旋转的splay,代码短但效率不高,此题中用上面的splay函数时间为200ms以下,用这个splay函数为900ms左右
void splay(int r, int goal)
{
while(pre[r] != goal)
_rotate(r, son[pre[r]][0] == r);
if(goal == 0) root = r;
}
*/
bool _insert(int k)
{
int r = root;
/*key[r]<k成立为1,应当进入右子树,否则为左子树,判断相应的子树是否为空*/
while(son[r][key[r]<k])
{
if(key[r] == k)
{
splay(r, 0);
return false;
}
r = son[r][key[r]<k]; /*进入相应的子树*/
}
new_node(son[r][key[r]<k], r, k);
splay(son[r][key[r]<k], 0);
return true;
}