文章目录
-
- 前言
- 伸展树
- 自底向上旋转
-
- 更进一步:展开
-
- 情况一:之字型(zig-zag)
- 情况二:一字型(zig-zig)
- 示例
- 伸展树的节点删除
- 自顶向下伸展树
-
- zig(单旋转)
- zig-zig(一字型旋转)
- zig-zag(之字型旋转)
- 合并树
- 我一直没看懂的示例
- 自顶向下伸展树代码手写
前言
之前也写过两篇关于伸展树的,一篇是概念,一篇是实现。
今天重温一下。
回顾往昔,光阴似箭,日月如梭啊。
伸展树
现在我们来介绍一种相对与AVL树更简单的数据结构,它叫伸展树,它保证从空树开始连续任意M次操作最多花费O(MlogN)时间。虽然这种保证并不排除任一次操作花费的时间为O(N),但是我们关注的是最终的结果。
伸展树是基于这样的事实:对于二叉查找树来说,每次操作的最坏时间为O(N),并不坏,只要它不时常发生就行。
伸展树的基本想法是:考虑到局部性原理(刚被访问的内容下次可能仍会被访问,查找次数多的内容可能下一次会被访问),为了使整个查找时间更小,被查频率高的那些节点应当经常处于靠近树根的位置。这样,很容易得想到以下这个方案:每次查找节点之后对树进行重构,把被查找的节点搬移到树根,这种自调整形式的二叉查找树就是伸展树。每次对伸展树进行操作后,它均会通过旋转的方法把被访问节点旋转到树根的位置。
自底向上旋转
旋转方式参见为实习准备的数据结构(5)-- 图解AVL树(平衡二叉搜索树)
实施上面描述的重新构造的一种方法是执行单旋转,这意味着我们将在访问路径上的每一个节点和它们的父节点进行旋转。
作为栗子,考虑在下面的树中对k1进行一次访问之后所发生的情况:
一次旋转之后:
k1还没到根,继续转:
转,再转:
好,转完了。
可以看到,本来一棵长树变成了近乎平衡的树。
这些旋转的效果是将k1不断推向树根,使得k1的进一步访问很容易(没被再推走之前)。
不过缺点也很明显,原先的带头大哥k3,百分之九十九也是刚刚坐上头把交椅,屁股还没坐热就让k1给踢到小角落去了。
更进一步:展开
展开的思路类似于前面介绍的旋转的想法,不过在旋转如何实施上我们稍微有一点选择的余地。我们仍然从底部向上沿着访问路径旋转。
令X是在访问路径上的一个非根节点,我们将在这个路径上实施旋转操作。如果X的父节点是根节点,那么我们只需要旋转X和树根。否则,X就有父亲§和祖父(G),存在以上两种情况以及对称的情形要考虑(跟AVL树差不多)。
情况一:之字型(zig-zag)
也就是AVL树里那俩要双旋的。
情况二:一字型(zig-zig)
也就是AVL树里那俩只需要单旋的。
注意甄别这次旋转和之前旋转的不同,更要看清楚和标准AVL单旋的差别。
这一次一字型旋转,其中包含了两次的AVL单旋。
代码语言:javascript复制首先,对P做一次单旋
然后,对X做一次单旋
示例
来看个栗子,还是上面那个,k1
展开的第一步是在k1,显然是一个之字型,因此我们用k1,k2,k3执行一次标准的AVL双旋转,得到如下的树:
注意和上面的旋转进行比对。
这一步转完之后,迎接k1的是一个一字型,因此我们用k1,k4,k5来做一次一字型旋转,注意看:
虽然从一些小栗子上很难看出来,但是展开操作不仅将访问节点移动到根处,而且还把访问路径上的大部分节点深度大致减少一半的效果(某些浅的节点最多向后推两个层次)。
伸展树的节点删除
这个删除略微抽象了一点,简而言之,就是:
- 访问被删除节点,从而使得它被提到根节点。
- 删除该节点,整棵二叉树被一分为二(一般是,除非你删除的节点比较特殊,比如最大节点或最小节点)
- 两棵树记为TL和TR
- 方法一:找到TL中的最大元素m,得益于二叉搜索树的顺序性,此时节点m的左子树必然为空,这时只需要将TR接在m的右侧即可。
- 方法二:方法一的镜像。
自顶向下伸展树
上面的操作,需要一次自顶向下的一次遍历,而后自底向上的一次遍历。这可以通过备忘录模式来实现(自底向上需要沿途保存节点),不过这需要大量的开销,而且也需要处理许多特殊情况。那么,接下来我们来讲一下如何在初始访问路径上施行一些旋转,结果得到在实践中更快的过程,只用到O(1)的额外空间,但却保持了O(logN)的摊还时间界。
为了叙述的方便,上图的右旋叫做X绕Y右旋,左旋叫做Y绕X左旋。
当我们沿着树向下搜索某个节点X的时候,我们将搜索路径上的节点及其子树移走。我们构建两棵临时的树──左树和右树。没有被移走的节点构成的树称作中树。在伸展操作的过程中:
1、当前节点X是中树的根。
2、左树L保存小于X的节点。
3、右树R保存大于X的节点。
开始时候,X是树T的根,左右树L和R都是空的。
和自底向上一样,自顶向下也分了三种情况。
zig(单旋转)
如上图,在搜索到X的时候,所查找的节点比X小,将Y旋转到中树的树根。旋转之后,X及其右子树被移动到右树上。很显然,右树上的节点都大于所要查找的节点。注意X被放置在右树的最小的位置,也就是X及其子树比原先的右树中所有的节点都要小。这是由于越是在路径前面被移动到右树的节点,其值越大。读者可以分析一下树的结构,原因很简单。(就这句,给我点醒了)
通了一点之后,后面就好办了。
zig-zig(一字型旋转)
在这种情况下,所查找的节点在Z的子树中,也就是,所查找的节点比X和Y都小。所以要将X,Y及其右子树都移动到右树中。首先是Y绕X右旋,然后Z绕Y右旋,最后将Z的右子树(此时Z的右子节点为Y)移动到右树中。注意右树中挂载点的位置。
zig-zag(之字型旋转)
私以为,可以拆分看。
在这种情况中,首先将Y右旋到根。这和Zig的情况是一样的。然后变成上图右边所示的形状。接着,对Z进行左旋,将Y及其左子树移动到左树上。这样,这种情况就被分成了两个Zig情况。这样,在编程的时候就会简化,但是操作的数目增加(相当于两次Zig情况)。
合并树
将中树的左右子树分别连接到左树的右子树和右树的左子树上。将左右树作为X的左右子树。重新最成了一所查找的节点为根的树。
我一直没看懂的示例
下面是一个查找节点19的例子:
在例子中,树中并没有节点19,最后,距离节点最近的节点18被旋转到了根作为新的根。节点20也是距离节点19最近的节点,但是节点20没有成为新根,这和节点20在原来树中的位置有关系。
而一直困扰我的,就是第二步到第三步的转化,为什么要把20提上去,现在明白了。
自顶向下伸展树代码手写
代码语言:javascript复制#include<iostream>
using namespace std;
class TreeNode {
public:
//这几个数据放做公有的,方便操作
int depth; //深度,这里计算每个结点的深度,通过深度的比较可得出是否平衡
TreeNode* parent; //该结点的父节点,方便操作
int val;
TreeNode* left;
TreeNode* right;
TreeNode(int x) : val(x), depth(0), left(NULL), right(NULL) {}
TreeNode() : val(0), depth(0), left(NULL), right(NULL) {}
};
class splay_tree {
public:
TreeNode* left_head;
TreeNode* right_head;
TreeNode* head;
TreeNode* tail_right;
TreeNode* tail_left;
public:
splay_tree(TreeNode* head) :left_head(NULL), right_head(NULL), head(head) , tail_right(NULL), tail_left(NULL){}
TreeNode* zig_left(TreeNode* node) {
/*
左单旋
A A
/ /
A2 B -> A2 B
/ /
B2 C B2 C
*/
TreeNode* res = node->right;
node->right = NULL;
//左子树还是空的
if (NULL == tail_left) {
left_head = node;
tail_left = node;
}
//接在左子树的右侧
else {
tail_left->right = node;
}
//调整左子树的尾结点
while (tail_left->right) {
tail_left = tail_left->right;
}
return res;
}
TreeNode* zig_right(TreeNode* node) {
/*
右单旋
A A
/
B A2 -> B A2
/ /
B2 C B2 C
*/
TreeNode* res = node->left;
node->left = NULL;
if (NULL == tail_right) {
right_head = node;
tail_right = node;
}
else {
tail_right->left = node;
}
while (tail_left->right) {
tail_left = tail_left->right;
}
return res;
}
//一字向左
TreeNode* zig_zig_left(TreeNode* node) {
//先来一个普通右转,再来一个拆分右转
TreeNode* temp = node->left;
node->left = temp->right;
temp->right = node;
return zig_right(temp);
}
//一字向右
TreeNode* zig_zig_right(TreeNode* node) {
TreeNode* temp = node->right;
node->right = temp->left;
temp->left = node;
return zig_left(temp);
}
//合并
void combine_trees() {
TreeNode* temp_left = head->left;
TreeNode* temp_right = head->right;
head->left = left_head;
head->right = right_head;
tail_left->right = temp_left;
tail_right->left = temp_right;
left_head = NULL;
right_head = NULL;
tail_right = NULL;
tail_left = NULL;
}
//自顶向下,不用管这个头了,反正遍历的过程中会一路往上翻
bool search_node(int val) {
//一次看三个值
while (1) {
if (val == head->val) {
return true; //一遍过自然是最好的了
}
else if (val > head->val) { //右
if (head->right) { //如果右边存在
if (val == head->right->val) { //如果是两遍过,那要简单的旋转一下了
head = zig_left(head);
combine_trees(); //我也觉得简单转一下就好了,但是,想了想还是按规矩来
return true;
}
else if (val > head->right->val) { //右右
if (head->right->right) { //不管它的值了,直接转
head = zig_zig_right(head);
}
else{
return false;
}
}
else { //右左
//处理一个节点即可
head = zig_left(head);
}
}
else{
return false;
}
}
else { //左
if (head->left) { //如果左边存在
if (val == head->left->val) { //如果是两遍过,那要简单的旋转一下了
head = rotate_left(head);
return true;
}
else if (val > head->left->val) { //左左
if (head->left->right) { //不管它的值了,直接转
head = zig_zig_left(head);
}
else{
return false;
}
}
else { //左右
//处理一个节点即可
head = zig_right(head);
}
}
else{
return false;
}
}
}
}
};