BST-Splay学习笔记

        Splay是一种BST树,它的查找、插入、删除、分割、合并等操作复杂度都是O(log2n)。它的主要特点是可以把某个结点旋转到指定位置(常常是根节点)。

        在Treap篇中我们提到,Treap利用随机优先级剔除输入顺序对树的形状的影响,把树的平衡交给数学期望,在新结点加入时,Treap用动态调整的方法维护树满足规则,但Treap的操作实际上都不会对树的形状产生太大的变化,这使得Treap树的平衡性几乎完全依赖与一开始随机给定的优先级。但在Splay里,我们不再用优先级去维护树的平衡,而是更随意(动态)一点:根据实际操作修改树的形状,使它起码不会总停留在很差的状态,这就是Splay树的核心逻辑。

       除了与Treap和普通二叉树没有太大区别的查找、删除、添加结点的功能外,Splay有一个核心的、足以对整棵树的形状产生颠覆效果的操作——提根(将某一个结点旋转到根,也可以到别的指定位置),提根操作是Splay足以有意义的基础。

        我们将以提根操作为核心,从结点创建到常见功能实现,提供简要的思想逻辑和代码实例。

Splay的结点

        想要实现提根以及所有二叉树基本操作,我们的Splay结点相对于Treap结点有些许不同,由于我们希望能够特别地直接定位结点的位置,所以用数组来实现Splay变得更简单,因此Splay结点不再是一个简单的类或是结构体,而是一系列的数组和函数。

        同时需要额外注意的是,由于我们希望Splay的初始状态最好是平衡的,因此对于Splay结点,其具有键(key)和值(value)两个参数共标记了一个结点,key是这个结点在树中的位置依据(二叉树规则),而value是这个结点的真值(根据题目输入),二者的数值没有任何的联系。对于二者的作用我们会在下一节解释。首先放出结点参数:

(1)一个整数记录根的位置

(2)维护i的父节点的数组pre[i]

(3)记录i的子树的结点个数的数组size[i]

(4)记录数的数组tree[i][2],代表i的左右儿子的位置

(5)记录i的键key和值value和重载了<用于sort排序的结构体Node数组

(6)更新x的size的函数update()

const int maxn = 10000;
int root;        //记录根
int pre[maxn], size[maxn];        //父结点、size
int tree[maxn][2];    //记录树
struct Node{
    int key, value;
    bool operator<(const Node &A)const{
        if(value==A.value) return key<A.key;      
        return value<A.value;
    }
}node[maxn];
void update(int x){        //计算更新x的size
    size[x] = size[tree[x][0]] + size[tree[x][1]] + 1;
}

Splay结点的插入

        我们先介绍更普遍的、更常用的操作——结点插入。如果你想单纯用结点插入来建造Splay树,或者为已经存在的树补充新的结点,我们的思路和普通二叉树几乎是一样的:

(1)找到这个结点的value应该放的位置,连接它

(2)回溯,同时更新路径上的size

        首先我们专门用一个Newnode函数处理新的叶子结点,这里可以和二叉树笔记中的add函数中创建结点的部分相对比,你会发现原理是一致的。

void Newnode(int &x,int fa, int value){
    x = value;
    pre[x] = fa;
    size[x] = 1;
    tree[x][0] = tree[x][1] = 0;
}

         接下来是插入结点函数:

void add(int &x, int fa, int val){//在主函数中调用时,x=root,fa=0,val想插入的值
    if(x==0)
        Newnode(x,fa,x);
    else{
        if(val>node[x].val)    add(tree[x][1],x,val);
        if(val<node[x].val)    add(tree[x][0],x,val);
    }
    update(x);    //回溯,更新size
}

        事实上你会发现在朴素的结点插入中,key并没有被用到,这是因为我们直接用value代替了本应该由key起到的作用,key什么时候应该和value区分,起到独立的作用?这将是一种特殊的情况——Splay树的预处理建树。 

Splay树的创建——预处理

        如果你的Splay树的规模相对稳定,并不会经常需要新结点的插入,而是在一切其余操作开始以前便先输入了整棵树的结点,那通过预处理创建一棵树无疑是更平衡的,预处理的思路如下:

(1)在输入每个结点的value的同时,按照1-n的顺序赋予每个结点的key。

(2)按照key而不是value建树,由于此时key的数量是固定的,我们可以建出最平衡的树。

ps.笔者并不认为这是一种实用性很强的技巧,它的价值仅在一棵一开始结点就确定的树上能够体现。但这就像我们一开始说的,Splay树由于其提根操作的特殊性,其形状是动态的,这使得预处理建树在Splay上简直显得有些自作聪明,事实上它反而可以用于形态稳定的Treap树或者普通二叉树上,因此让我们不要花太多时间,只是放下代码模板以备不时之需:

void buildtree(int &x, int l, int r, int fa){
    if(l>r) return;
    int mid = (l+r)>>1;
    Newnode(x,fa,mid);
    buildtree(tree[x][0],l,mid-1,x);
    buildtree(tree[x][1],mid+1,r,x);
    update(x);
}
void Init(int n){
    root = 0;
    tree[root][0] = tree[root][1] = pre[root] = size[root] = 0;
    buildtree(root,1,n,0);
}
int main(){
    int n;   
    cin>>n;
    Init(n);
    for(int i=1;i<=n;i++){
        cin>>node[i].val;
        node[i].key = i;
    }
}

Splay的提根

        无论如何,你已经有一棵树了,接下来让我们把它变成一棵真正的Splay——进行提根操作的实现。首先来理解提根的实现逻辑:

        所谓的提根,实现核心是我们在Treap篇中提到的旋转,如果先不去纠结旋转的左右区别,我们可以把提根操作分为3种情况:

1、x的父结点就是根——一次旋转

        如果x的父节点就是根结点,显然只需要对x所在的子位进行一次反方向的旋转(x是左二子,则右旋,x是右儿子,则左旋)。 

2、x、x的父节点、x的父节点的父节点共线——两次同向旋转

         所谓三点共线可以由图观之,在代码逻辑上的体现是:x所在的子位和x的父节点所在的子位相同,则进行两次与子位相反方向的旋转。

3、x、x的父节点、x的父节点的父节点不共线——两次异向旋转

         当三点不共线,则自然需要两次异向旋转,看到这里你应该明白,提根操作的最核心部分是反向旋转——即与节点所在的子位相反的方向旋转。

        需要注意的是:提根或旋转都遵守二叉树的根本规则,因此先序排序的顺序是不会改变的。 

        关于旋转的实现步骤,我们在Treap篇笔记中已经简要描述,此处不再重复,直接放出旋转代码:

void Rotate(int x, int c){    //c=0 左旋,c=1 右旋
    int fa = pre[x];
    tree[fa][!c] = tree[x][c];
    pre[tree[x][c]] = fa;
    if(pre[fa])    tree[pre[fa]][tree[pre[fa]][1] == fa] = x;
    pre[x] = pre[fa];
    tree[x][c] = fa;
    pre[fa] = x;
    update(fa);
}

        给出提升结点Splay函数实现:

void Splay(int x,int goal){//将x提升为goal的儿子,如果goal为0,则旋转到根
    while(pre[x]!=goal){
        if(pre[pre[x]]==goal)           //情况(1)
            Rotate(x,tree[pre[x]][0]==x);
        else{
            int fa = pre[x];
            int c = (tree[pre[fa]][0]==y);
            if(tree[y][c]==x){           //情况(2)
                Rotate(x,!c);
                Rotate(x,c);
            }
            else{                       //情况(3)
                Rotate(fa,!c);
                Rotate(x,c);
            }
        }
    update(x);
    if(goal==0)    root = x;    //更新根结点
}

基于Splay提根的操作——删除、分裂和合并

        由于我们已经可以把x提到根结点,相对于Treap或者普通二叉树,Splay实现删除和分裂的操作非常简单快捷,至于合并,那将是一个很苛刻的情况。

1、删除

        删除x结点的逻辑是:将x提升到根节点,然后删除根结点。虽然说起来简单,但二叉树终究不是链表,一个根节点有多种情况:

(1)其左子树为空,说明根结点是最小数,则直接把根结点删除,右儿子做新的根。

(2)其左子树不为空,说明根结点不是最小数,则先找到根节点右子树中的最大结点,把这个结点提升到根节点的右儿子,将根结点本来的右儿子作为根节点新的右儿子的右儿子,切断根结点与此结点的连接即可。

 

        当然,如果你想的话,也可以找到左树的最大结点进行相同的操作。 

        为了应付第二种情况,给出找到右子树最大结点的代码:

int get_max(int x){
    while(tree[x][1]){
        x = tree[x][1];
        update(x);
    }
    return x;
}

        给出删除根结点的代码:

void del_root{
    if(tree[root][0]==0){    //如果左儿子为空
        root = tree[root][1];
        pre[root] = 0;
    }
    else{
        int m = get_max(tree[root][0]);
        Splay(m,root);
        tree[m][1] = tree[root][1];
        pre[tree[root][1]] = m;
        root = m;
        pre[root] = 0;
        update(root);
    }
}

2、分裂

        Splay树的分裂更简单:将根分界点x提根,然后将x的左或者右儿子设为0即可。

3、合并

        Splay树的合并有两种:

1、Splay树特色的合并

        如果A树结点的最大值比B树结点的最小值小,则可以直接合并,分别将最值点提根然后合并。

2、普通树的合并,将一棵树分裂,一个个结点融合,和Treap树的合并原理一样。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值