伸展树
宽松平衡、局部性
- AVL树的平衡准则过于苛刻。
- 刚被访问过的数据,极有可能很快地再次被访问,推广:下一将要访问的节点极有可能在刚被访问的节点的附近。
- 连续的m次查找(b>>b = |BBST|),采用AVL供需O(mlogn)的时间,那么我们仿效自适应链表,利用局部性,可以使的访问速度更快吗?
自适应调整
类比链表结构,链表中元素的访问效率取决于元素的位置,即元素越靠前,访问效率越高,那么我们根据局部性设置一条策略。每访问一个元素,便把该元素提到链表最前方。
这样经过一段时间的访问,链表靠前的元素将具有较高的访问频率,这在一定程度上提高了链表的整体访问效率。
对比上述链表,BST顶部元素的访问效率也高于底部的访问效率,我们不妨将BST中访问到元素通过某种方式将它们移动到更靠近树根的位置。
逐层伸展
设置一条策略,节点v一旦被访问,随即转移至树根,达成这一目的的方法无非是反复进行zig和zag旋转,直到v抵达树根。这个过程在视觉山更像是“节点v左右摇摆不断伸展”,因此将这种调整成为伸展。
概括的说,就是使几点v逐层单旋,直至到达树根。
最坏情况:BST退化为链表,每次访问最深的节点。旋转次数呈周期性的算术级数演变,每一周期累计O(n^2),分摊到每一步为O(n),这个效率伸展远低于AVL树的O(logn)。
双层伸展
向上追溯两层,而非一层。
反复考察祖孙三代,g=parent§, p=parent(z),根据他们的相对位置经两次旋转,使v上升两层成为子树根。
- 子孙异侧
旋转方式同AVL树双旋再平衡相同,对之字形节点做zigzag或者zagzig操作。
进一步观察,这种调整方式与逐层伸展等效。 - 子孙同侧
但在子孙三代成一字型排列时,即zigzig或者zagzag时,在AVL树中的做法:
先旋转父节点,再旋转祖父节点。
而在双层伸展的伸展树中:
先旋转祖父节点,再旋转父节点。
这种伸展方式,看似与AVL树的伸展方式没有区别,但对于最坏情况(即树退化为链表),双层伸展会有效的减少树高(接近原树的一半),而对于不断地恶意访问(每次访问最深的叶子节点),树高将持续的降低。
伸展树的这种调整方法具有折叠效果,一旦访问坏节点,对应路径的长度便会减半,使得我们本需要避免的访问最深节点的最坏情况不会持续发生,树退化成链表的情况也不会持续发生。
单趟伸展操作,分摊O(logn)的时间。
若v的深度导致他只有父节点,而没有祖父节点呢,即v的父亲是树根的情况,且每轮调整中最多出现一次。
此时便视具体形态,做单次旋转。
算法实现
接口
#define BinNodePosi(T) BinNode<T>* //节点位置
template<typename T>
class Splay: public BST<T> //由BST派生
{
protected:
//实现伸展操作
BinNodePosi(T) splay( BinNodePosi(T) v);
public:
//与其他BST不同,查找接口也需要重写
//因为其他BST的查找操作不会改变树的拓扑结构,而伸展树需要将查找到的节点提前
BinNodePosi(T) & search( const T &e);
//重写插入、删除接口
BinNodePosi(T) insert(const T &e);
bool remove(const T &e);
}
算法实现 伸展操作
template<typename T> BinNodePosi(T) Splay<t>::splay( BinNodePosi(T) v)
{
if( !v)
return NULL;
BinNodePosi(T) p; //父节点
BinNodePosi(T) g; //祖父节点
while( (p = v->parent) && (g = p->parent) ) //自下而上,反复做双层伸展
{
BinNodePosi(T) gg = g->parent; //每轮之后,v都将以原曾祖父节点为父节点
if( IsLchild(*v) )
if(isLChild(*p))
{/*zig-zig*/}
else
{/*zig-zag*/}
else
if(isRChild(*p)
{/*zag-zag*/}
else
{/*zag-zig*/}
if( !gg) v->parent = NULL; //若无曾祖父节点,则v为树根,否则,gg应以v为左或右孩子
else (g==gg->lc)? attachAsLChild(gg, v) : attachAsRChild(gg, v);
updateHeight(g);
updateHeight(p);
updateHeight(v);
} //双层伸展结束,必有g==NULL,但p可能非空
if( p = v->parent)
{/*若p是根,则额外单旋一次*/}
v->parent = NULL;
return v; //伸展完成,v抵达树根
}
zig-zig情况,类比于3+4重构,我们使用拼接的方式代替旋转。
if(IsLChild(*v))
if(IsLChild(*p)) //zig-zig
{
attachAsLChild(g, p->rc);
attachAsLChild(p, v->rc);
attachAsRChild(p, g);
attachAsRChild(v, p);
}
else
{/*zig-zag*/}
else
{/*...*/}
算法实现 查找算法
伸展树的查找操作,与常规的BST::search()不同,很可能会改变树的拓扑结构,因此不再属于静态操作。
template<typename T> BinNodePosi(T) & Splay<T>::search(const T &e)
{
//调用标准BST查找接口定位目标节点
BinNodePosi(T) p=searchIn(_root, e, _hot=NULL);
//无论成功与否,最后被访问的节点都将伸展至根
_root=splay( p ? p : _hot);
//总是返回根节点,无论成功或者失败,我们都会在树根得到一个等于或者接近目标值的节点
return _root;
}
算法实现 插入算法
直观方法:调用BST标准插入算法,再将新节点伸展至根,其中首先调用BST::search()。
但我们重写的Splay::search()已经集成了splay()操作,在查找失败后,_hot即是根节点,那我们便可以将搜索后的树做一个拆分,再将待插入节点与拆分的两部分组合。
算法实现 删除算法
直观方法:调用BST标准删除算法,再将_hot伸展至根。
同样的,Splay::search() 查找成功之后,目标节点即是树根,我们不妨就在树根附近完成目标节点的摘除。
具体来说,即是现将根节点释放,然后选择一种方法,拼接两棵子树,可以从右子树中取最小的节点作为新的树根,也可以在左子树中取最大的节点作为新的树根。
综合评价
- 无需记录节点高度或平衡因子;编程实现简单易行----优于AVL树
分摊复杂度O(logn)----与AVL树相当 - 局部性强,缓存命中率极高时(即 k<<n<<m),效率甚至可以更高----自适应的O(logk),任何连续的m次查找,都可在O(mlogk + nlogn)时间内完成,那么在命中率极高时,我们甚至可以将数据集大小只看做k。
- 仍不能保证单次最坏情况的出现,不适用于效率敏感的场合。
- 复杂度分析稍显复杂。