Files
912-notes/thu_dsa/chp8/SplayTree.md
2019-11-04 22:59:37 +08:00

166 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Conclusion on Splay Tree
========================
## 伸展树基本概念
> 伸展树的基本思想。
伸展树完全是基于<局部性原理>(locality)的。
局部性原理是计算机科学中非常重要的原理,很多设计,比如说多级存储器,缓存,都是基于局部性原理。简单说来就是<刚访问过的数据,在一段时间内极有可能再次被访问>。因此,对于物理存储器而言,会将刚刚访问过的数据,转移存储到更高级的存储介质中,比如内存或者缓存,以便下次访问这些数据时可以高效地进行。而对于数据结构而言,则可以把刚访问过的数据移动到更容易被访问到的区域,比如列表可以把每次被访问的数据都交换到列表的头部,这就构成了自调整列表。
伸展树也是同样的思想,即对于一棵二叉搜索树而言,将刚刚被访问的数据移动到树根结点,从而使得后续的访问可以高效地找到该数据。
> 伸展树和AVL树的比较。
伸展树也是一棵平衡二叉搜索树但是可以看到伸展树和AVL树有很大的差异。
前面我们说对于任意一种平衡二叉搜索树都是关注两个方面的问题即平衡条件以及失衡后的调整算法。因此AVL树引入了一个平衡因子来作为它的平衡条件并且失衡调整算法也都是围绕这个平衡因子而展开。但是从前面伸展树的思想可以看出伸展树没有所谓的平衡条件因此它不需要维护任何额外的信息包括树的高度。它只是在每次对某结点的访问后将该结点移动到树根并且同时对树的结构进行调整就这样实现了它的平衡。
因此相较而言AVL树更像是循规蹈矩如履薄冰。而伸展树则更加潇洒不羁小节。
## 伸展策略
那么实现伸展树就比较简单了,只需要将被访问结点移动到根结点就可以了,所以应该怎么移动呢?
> 逐层伸展策略。
一个很简单的思想是找到被访问的结点后,不断地将该结点与它的父结点进行单旋转,直到该结点被旋转到根结点。
这种策略固然是可行的,但是在最坏情况下,它拥有比较差的时间性能,如下图所示:
![one-step splay](images/one-step-splay.png)
对于这种一开始就呈现单链结构的搜索树设它具有n个结点如果每次都访问它深度最深的那个结点那么每次被访问结点的深度为$n-1, n-2, ...., 3, 2, 1, 0$,所以一个周期这样的访问累计需要$\Omega(n^2)$的时间,分摊时间复杂度为$\Omega(n)$。这个结果已经与列表这种线性结构相当了,所以很难可以让人满意。
> 双层伸展策略。
双层伸展策略只是逐层伸展策略的一个推广,它们之间的差别其实非常小,但是对性能却有一个质的提升。据邓公所说,双层伸展策略其实就是逐层伸展这条龙上点出的<睛>,而在这之前,逐层伸展这样一个整体的龙的结构是已经具备了的。
双层伸展策略,顾名思义,就是每次伸展时都以两个单位向上层追溯,而不是逐层伸展的一个单位。所以,这就需要反复考察祖孙三代$g, p ,v$,通过两次旋转将$v$上升两层,使之成为新的树根。关于这个前面我们已经进行过讨论,根据祖孙三代的拓扑结构可以分为`zigzig`, `zigzag`, `zagzig`, `zagzag`四种情况。
+ `zigzag``zagzig`。优先讨论着两种情况是因为,在这两种拓扑结构下,以$v$为新的根结点的树是没有异构情况的,无非是$p, g$分别是$v$的左右孩子结点。所以伸展树的`zigzag`与前面AVL的`zigzag`没有任何区别,可以直接调用前面的`connect34`来进行重构。
+ `zigzig``zagzag`。这两种情况是有重构的,以`zigzig`为例,调整后以$v$为根结点的树有`zagzag``zagzig`两种构型,如果将前面的两次单旋转看做这里的一次旋转的话,逐层伸展策略对应的`zagzig`构型,而我们这里的双层伸展策略将要采用的`zagzag`构型。一会儿我们可以看到这点细微的差距给整棵伸展树带来的不同。
下图展示了单层伸展和双层伸展策略的不同。
![onestep_versus_twostep](images/onestep_versus_twostep.png)
可以看到,双层伸展策略具有某种<折叠>的效果。即一旦访问最坏的结点,双层伸展就会通过调整将整棵树的高度降低到大约原来的一般,从而使得最坏情况不至于持续发生。
这里也可以看出两种策略本质上的区别。在最坏情况下单层伸展策略每次伸展只能将树高降低1而双层伸展策略每次伸展可以将树高降低到原来的一般。所以这也是单层伸展的分摊时间复杂度为$O(n)$而双层伸展的分摊时间复杂度为$O(logn)$的原因,详细的证明可以查看邓公习题集。
最后,由于双层伸展是以两个单位向上层追溯,要是当前结点只有父亲而没有祖父时(即父结点是根结点),可以相应的将当前结点与其父结点做一次单旋转,从而可以将当前结点移动到树根。
## 伸展树的实现
前面提到,伸展树完全是基于局部性原理构造的一种数据结构,因此,其算法的具体实现中也要处处体现局部性原理,才能使伸展树发挥最大的作用。
伸展树的基本操作同样只有搜索,插入,删除三种。对于这三种基本操作,都要应用局部性原理。
> 搜索操作。
首先调用BST常规的搜索操作。每次需要将被查找的结点移动到树根或者没有找到目标关键码则将失败处的父结点移动到树根这是因为失败处的父结点是和目标关键码差距最小的结点根据局部性原理该结点在后续的访问中也极有可能出现。无论如何都返回树根结点。
搜索操作的代码如下:
```cpp
template <typename T>
BinNodePosi(T) SplayTree<T>::search(T const &key){
BinNodePosi(T) x = searchIn(__root, key, __hot = nullptr);
if(x) splay(x);
else if(__hot) splay(__hot);
return __root;
}
```
> 插入操作。
一种思路是首先调用BST的常规插入算法然后为了保证局部性将被插入结点移动到根结点。这个方法的确是简明可行的。
另一种思路是,直接调用上面伸展树的搜索算法,这样一次搜索必然会失败,但是此时`__hot`会被移动到根结点。我们可以直接比较待插入关键码与`__hot`关键码的大小,从而直接将被插入结点插入到根结点。整个过程如下图所示:
![SplayTreeInsert](images/SplayTreeInsert.png)
具体的插入算法如下:
```cpp
template <typename T>
bool SplayTree<T>::insert(T const &key){
BinNodePosi(T) x = search(key);
if(x && x->data == key) return false;
//else
__root = new BinNode<T>(key);
++__size;
if (!x) return true;
if(key < x->data){
__root->leftChild = x->leftChild;
__root->rightChild = x;
x->leftChild = nullptr;
x->parent = __root;
}else{
__root->leftChild = x;
__root->rightChild = x->rightChild;
x->rightChild = nullptr;
x->parent = __root;
}
updateHeight(x);
updateHeight(__root);
return true;
}
```
> 删除操作。
删除操作会比较复杂。一种简明的方法也是首先调用BST的删除算法然后将`__hot`伸展到根结点。这个方法同样是可行的。
另一种思路是,首先调用伸展树的搜索算法,这样被删除结点就已经被移动到了根结点,因此可以直接将根结点删除。接下来的问题是,应该由哪个结点来作为新的根结点。
在根结点没有左子树或者没有右子树时,可以直接用那棵唯一的子树来替代根结点,从而完成了根结点的删除。但在根结点同时具有左右子树时,根据伸展树的局部性原理,仍可选取被删除结点的直接后继来作为新的根结点,这样数据的局部性仍然可以得到应用。
为了将根结点的直接后继提升为新的根结点,可以在$T_R$中再次查找根结点的关键码,尽管这次查找必然失败,却可以将$T_R$中的最小结点提升为根节点。并且由于它是最小结点根据BST的局部有序性新的根节点必然没有左子树从而可以将$T_L$作为左子树与新的根结点进行连接。这样,就得到了一棵完整的二叉搜索树。整个过程如下图所示:
![SplayTreeRemove](images/SplayTreeRemove.png)
具体代码如下:
```cpp
template <typename T>
bool SplayTree<T>::remove(T const &key){
BinNodePosi(T) x = search(key);
if (!x || x->data.key != key) return false;
//else
if(!__root->leftChild){
__root = __root->rightChild;
if(__root) __root->parent = nullptr;
search(key);
}
else if(!__root->rightChild){
__root = __root->leftChild;
__root->parent = nullptr;
search(key);
}
else {
__root = x->rightChild;
__root->parent = nullptr;
search(key); //move x's succ to root, and __root has no left child(for succ has the smallest key
__root->leftChild = x->leftChild;
x->leftChild->parent = __root;
updateHeight(__root);
}
--__size;
delete x;
return true;
}
```
## 伸展树的评价
可以证明,伸展树单次操作的分摊时间复杂度为$O(logn)$与AVL树相当并且还不需要维护额外的平衡因子或者高度等信息编程也比较简单(也不是很简单...)。关于证明看邓公的习题集。
并且,在一些场合下,伸展树还可以达到更高的性能。例如在局部性强的场合,缓存命中率将极高。假设树中一共有$n$个结点,其中$k$个结点是经常访问的结点,并且有$k << n$。这样在经过多次访问后这k个结点将被提升到树的根部此时访问的时间复杂度甚至可以达到$O(logk)$。
但是伸展树并不能保证单次最坏情况的发生,所以不能适用于对效率非常敏感的场合,例如导弹卫星发射这种。