Splay学习笔记(保姆级)

记录我对于Splay的学习和理解。

首先介绍Splay

伸展树(Splay Tree),也叫分裂树,是一种二叉排序树,它能在O(log n)内完成插入、查找和删除操作。它由丹尼尔·斯立特Daniel Sleator 和 罗伯特·恩卓·塔扬Robert Endre Tarjan 在1985年发明的。 

在伸展树上的一般操作都基于伸展操作:假设想要对一个二叉查找树执行一系列的查找操作,为了使整个查找时间更小,被查频率高的那些条目就应当经常处于靠近树根的位置。于是想到设计一个简单方法, 在每次查找之后对树进行重构,把被查找的条目搬移到离树根近一些的地方。伸展树应运而生。伸展树是一种自调整形式的二叉查找树,它会沿着从某个节点到树根之间的路径,通过一系列的旋转把这个节点搬移到树根去。

它的优势在于不需要记录用于平衡树的冗余信息。(源自百度百科)

好了,最好的理解就是先画一幅图来表示

 

 这就是一个简单的平衡树,满足左儿子比父亲小,右儿子比父亲大,并且任何一个子树也都是平衡树。但这个平衡树可能会出现比较极端的情况变成一条链,即2 3 4 5 6,为了解决这个问题我们可以用到Splay。具体原理即我们可以用修改结点的顺序来避免这种情况,比如上面这幅图可以旋转结点2变为:

这就是将2旋转(rotate)。我们可以发现旋转之后它还是一个平衡树,那么我们再旋转一下结点2

 

 

通过这三幅图我们可以发现旋转的一些规律了:

1.旋转的结点位置变到它的父结点的位置,父结点到该结点原来相对的位置(即原来是左儿子则父结点到右儿子的位置,反之亦然)。

2.父结点的另一个儿子不变,原旋转的结点的位置变为旋转的结点的相对的儿子(即原来是左儿子,则它的右儿子变为父结点的左儿子)。

用代码表示会更加直观。(root记录根结点,sum记录结点的数量)

 

struct node
{
    int ch[2], ff, val, size, cnt;//左右儿子 父结点 数值大小 树的大小 val的数量 
}tree[N];
int root, sum;
void pushup(int x) { tree[x].size = tree[tree[x].ch[0]].size + tree[tree[x].ch[1]].size + tree[x].cnt;//size为左儿子的size加右儿子的size加cnt } inline void rotate(int x)//x为要旋转的结点 { int y = tree[x].ff, z = tree[y].ff;//y为x的父结点 z为x的祖父结点 int k = tree[y].ch[1] == x;//k代表x是y的哪个儿子 0为左儿子 1为右儿子 tree[y].ff = x;//y的父亲变为x tree[x].ff = z;//x的父亲变为z tree[tree[x].ch[!k]].ff = y; //x的原来的与x的位置相对的那个儿子的父亲变为y tree[z].ch[tree[z].ch[1] == y] = x;//z的原来的y的位置变为x tree[y].ch[k] = tree[x].ch[!k];//y的原来x的位置变为x的与x相对的那个儿子变为y的儿子(我在说什么) tree[x].ch[!k] = y;//x的原来的与x的位置相对的那个儿子变为y pushup(x), pushup(y);//更新 }

 

 

这就是一个基本的旋转操作,是不是非常的简单(?)。

接下来就可以看看关于旋转的一些问题了了,假如我们只旋转一个点的话会发现一条长链始终都会存在,如何解决呢?我们可以旋转Y,但如何旋转,这个时候需要讨论的情况比较多,但是在看了一个大佬的讲解之后有一种更加简单的写法。

 

void Splay(int x, int goal)//将x旋转为goal的儿子,goal为0的时候即将x旋转为根结点
{
    int y, z;
    while (tree[x].ff != goal)
    {
        y = tree[x].ff;//父结点
        z = tree[y].ff;//祖父结点
        if (z != goal)
        {
            (tree[z].ch[0] == y) ^ (tree[y].ch[0] == x) ? rotate(x) : rotate(y);//假如x和y分别是y和z的同一个儿子则旋转y否则旋转x
        }
        rotate(x);//最后必须旋转x
    }
    if (goal == 0)//即x为根结点
    {
        root = x;
    }
}

 

这样写就十分简洁(感谢大佬)

然后是Find操作,如果理解了平衡树的话应该能独立写出find,即利用左儿子小右儿子大的性质,做到类似于二分查找。我们可以将要Find的数字旋转到根结点,这样操作会更加方便,也可以直接return找到的位置。为了方便看懂下面代码会写更复杂一点(顺带一提,要查出x的排行即可在find之后利用子树的size求出)

inline void find(int x)//x为要寻找的数值
{
    int u = root;//u为根结点
    while (true)
    {
        if (tree[u].ch[x > tree[u].val])//避免树里面不存在x
        {
            break;
        }
        if (tree[u].val > x)//假如val大于x则x在u的左儿子
        {
            u = tree[u].ch[0];
        }
        if (tree[u].val < x)//假如val小于x则x在u的右儿子
        {
            u = tree[u].ch[1];
        }
        if (tree[u].val == x)//找到x
        {
            break;
        }
    }
    Splay(u, 0);//把查找到的位置旋转到根结点
}

接下来是insert,类似于find操作,但与find不同的是假如找到其父结点之后不存在儿子的话可以生成一个新儿子。

inline void insert(int x)//插入x
{
    int u = root, ff = 0;
    while (tree[u].val != x && u)//若u为0则代表不存在
    {
        ff = u;//记录父结点
        u = tree[u].ch[tree[u].val < x];
    }
    if (u != 0)//即本就存在u
    {
        tree[u].cnt++;//数量加一
    }
    else
    {
        sum++;
        if (ff)//假如ff不为0
        {
            tree[ff].ch[tree[ff].val < x] = sum;
        }
        tree[sum].ff = ff;
        tree[sum].cnt = 1;
        tree[sum].size = 1;
        tree[sum].val = x;
    }
    Splay(sum, 0);//把该点旋转为根结点,同时能pushup
}

 写完insert之后我们可以再了解一下前驱和后继,利用find将x移动到根节点上,前驱即在x的左子树,后继在x的右子树,还需考虑到x不存在的情况。具体可见代码注释(最后的if else可以简化在一起,为方便理解分开写)

inline int Next(int x, int k)//k=0代表前驱,k=1代表后继
{
    find(x);
    if (tree[root].val > x&& k == 1)//假如x不存在且要找的是后继
    {
        return root;//此时根结点即满足要求
    }
    if (tree[root].val < x && k == 0)//假如x不存在且要找的是前驱
    {
        return root;//此时根结点即满足要求
    }
    if (k)//找后继
    {
        int u = tree[root].ch[1];//令u为x的右子树,即大于x
        while (tree[u].ch[0])//要找的大于x的最小的数即找出左子树的值
        {
            u = tree[u].ch[0];//左子树一定小于右子树
        }
        return u;
    }
    else//找前驱
    {
        int u = tree[root].ch[0];//同理先令u为x的左子树
        while (tree[u].ch[1])//找最大值在右子树找
        {
            u = tree[u].ch[1];//右子树一定大于左子树
        }
        return u;
    }
}

在写出Next之后我们可以拓展一下Next的应用,假如我们要删除x,find(x)再删除的话是很麻烦的,因为还会牵连到x的子树,但我们有没有办法将x的子树全都去掉呢?利用Next和平衡树的性质是可以的,假若我们将x的后继变为根结点,再将x的前驱变为x的后继的左儿子的话,此时x就为x的前驱的右儿子且绝对没有儿子。此时我们就可以直接将其删除(同理也可以将x的前驱变为根结点,可以自己画一下)。注意考虑到x的数量,具体也可以根据问题来修改。具体见代码:

inline void Delete(int x)
{
    int last = Next(x, 0);//找到x的前驱
    int next = Next(x, 1);//找到x的后继
    Splay(next, 0);//将x的后继变为根结点
    Splay(last, next);//将x的前驱变为x的后继的左儿子
    int del = tree[last].ch[1];//del为x的位置
    if (tree[del].cnt > 1)//如果x的数量大于一
    {
        tree[del].cnt--;
        Splay(del, 0);//将del移动到根结点同时可以更新树
    }
    else
    {
        tree[last].ch[1] = 0;//直接删除
        Splay(last, 0);//将last移动到根结点同时更新树
    }
}

最后我们可以尝试求出kth,注意是求出第k小的而不是第k大= =,kth可以利用size来求出,根据排名和size可以判断x是在左子树还是右子树或者结点上,一路循环即可

inline int kth(int x)
{
    int u = root;
    if (tree[u].size < x)//即x大于树的大小此时不存在
    {
        return 0;
    }
    while (true)
    {
        if (tree[tree[u].ch[0]].size + tree[u].cnt >= x)//假如x在左子树或根结点上
        {
            if (tree[tree[u].ch[0]].size >= x)//假如左子树的大小大于x
            {
                u = tree[u].ch[0];//即x在左子树里面找
            }
            else//即x在根结点上
            {
                return tree[u].val;
            }
        }
        else//x在右子树上
        {
            x -= tree[tree[u].ch[0]].size + tree[u].cnt;
            u = tree[u].ch[1];
        }
    }
}

这些是根据洛谷P3369学习的一些基本操作,还有一些操作后续填坑。

posted @ 2020-04-13 00:07  绝军师  阅读(214)  评论(0编辑  收藏  举报