平衡树——Splay及其维护的相关信息

本文介绍了Splay树,一种由Tarjan提出的自平衡二叉查找树。Splay树通过旋转操作保持树的平衡,以提高查询和插入的效率。内容包括Splay树的基本操作如旋转、Splay操作、查找、插入、删除,以及如何将其应用于序列维护和Link-Cut Tree。Splay树不仅可以解决平衡树问题,还能处理动态序列和树结构的维护。
摘要由CSDN通过智能技术生成

无限Orz Tarjan大神。
先扯一会,你知道Tarjan有多强吗?
当然,除了能求强连通分量、割点、点双、桥、边双的tarjan,以及离线O(1)求lca的tarjan,还有很多不叫tarjan但却是Tarjan提出的算法/数据结构。
你在用并查集的时候,你可能不知道:并查集复杂度的正确性是Tarjan证明的。
在你知道之前,你可能无法想象:下面要说的Splay和LCT甚至是进化版Top Tree都是Tarjan发明的。
斐波那契堆也是Tarjan发明的。
然而,在你学习求强连通分量的tarjan算法之前,你根本不知道还有这么个人;
在你学完了之后,你也不会知道他还有如此多的贡献。
所以,请跟我一起再来一次——
无限Orz Tarjan大神。

Splay是一个神奇的数据结构,它可以维护一些数的权值集合、一个序列甚至是一棵树(一片森林)。

权值

其实权值线段树也可以处理平衡树的问题。在权值线段树外面套一个树状数组(树状数组套主席树)可以解决更复杂的带修改和区间查询权值相关的问题,那不要那个树状数组不就能解决一个平衡树的问题啦?
不过今天不讨论权值线段树,我们只讲Splay。

二叉查找树

学Splay之前,你需要知道一个简单的东西——二叉查找树(Binary Search Tree, BST)。
其实这玩意还叫二叉排序树,但这不重要,你只要知道说的都是一个东西就好了。
二叉查找树(在权值问题上)的定义:
二叉查找树要么为空,要么是一颗满足以下条件的二叉树:
1、任何一个节点的左右子树均为二叉查找树。
2、如果左子树不为空,则左子树内所有点的权值均小于当前节点的权值。
3、如果右子树不为空,则右子树内所有点的权值均大于当前节点的权值。
什么意思呢?
看一张图你就理解了:
这里写图片描述
如果要插入一个新值,那就在二叉查找树中找一个合适的位置,建立一个新节点,并和原树中的某个节点建立父子关系就好了。
比如上面这张图,我们要新加入一个权值为5的节点,那么就依次进行一下步骤:
发现5<8,进入8的左子树3;
发现5>3,进入3的右子树6;
发现5<6,进入6的左子树4;
发现5>4,因为4的右子树为空,那么就把5作为4的右儿子,4作为5的父亲即可。
查询和插入的步骤基本一样,就不细讲了。
看上去这个玩意好像非常棒,可以完成平衡树的各种操作,但有一个问题:
假如操作是这样的:
插入1;
插入2;
插入3;
……
插入n。
你会发现,树变成了一条链,每个点都是上一个点的右儿子。
换句话说,复杂度有问题了。
所以——这个时候平衡树就来了。各种各样不同的平衡树其实就是二叉搜索树的不同改进版本。
平衡树是什么:
字面意思,左右子树差不多大,看上去像个天平一样平衡:
这里写图片描述
显然,如果二叉查找树完全平衡的话,那么树高最多是logn的,操作复杂度就有了保证。

Splay

作为众多平衡树的一种,Splay是怎么做的呢?
首先介绍一下Splay的基本操作:

rotate(旋转):

我们发现,一颗平衡树(或二叉查找树)含有的信息只与它的中序遍历有关,这就暗示我们,在中序遍历不变的前提下,可以随意改变树的形态。
比如左旋:
这里写图片描述
右旋就是反过来:
这里写图片描述
当然啦,只有作为左儿子才能右旋,右儿子才能左旋。
可以发现,合理的旋转不会影响树的中序遍历,Splay就是利用这一点来降低树高的。

//以下,ch[x][0]表示x的左儿子,ch[x][1]表示x的右儿子,fa[x]表示x的父亲
//sz即size,表示子树大小;num表示这个数出现的次数
inline void pushup(int rt)
{
    
    sz[rt] = sz[ch[rt][0]] + sz[ch[rt][1]] + num[rt];
}
inline void rotate(int x) //将一个节点旋转至其父亲位置
{
    
    int y = fa[x], z = fa[y], d = ch[y][1] == x; //d表示x是左儿子还是右儿子
    fa[ch[y][d] = ch[x][d ^ 1]] = y;
    fa[ch[x][d ^ 1] = y] = x;
    fa[ch[z][ch[z][1] == y] = x] = z; //三句话建立新的父子关系
    pushup(y);
    pushup(x); //因为改变了树的形态,所以信息要及时更新
}

旋转还分单旋和双旋:

假如现在树长这样,我们想把2转到上面去:
这里写图片描述
先左旋(作为右儿子当然是左旋啊):
这里写图片描述
再右旋(这个时候是左儿子):
这里写图片描述
看上去,树高变矮了,说明旋转是有用的。

但是,单旋有时候和没转一样:
这里写图片描述
我们要把1转通过单旋转到根,也就是每次右旋:
这里写图片描述
这里写图片描述
然后直到:
这里写图片描述
转完了还是一条链,跟没转一样。

于是就有了双旋的操作:
这里写图片描述
先转2:
这里写图片描述
再转1:
这里写图片描述
先转4:
这里写图片描述
再转1:
这里写图片描述
先转6:
这里写图片描述
再转1:
这里写图片描述
是不是看上去好了很多?
根据上面的例子,我们可以发现,在旋转一个节点时,如果这个节点和它的父亲同向(均为左儿子或均为右儿子),那么就先旋转父亲在旋转自己;如果这个节点和它的父亲异向(一个朝左一个朝右),那么就连转两下自己就好了。

splay

以下,我会刻意区分splay的大小写问题。大写的Splay代表Splay这个数据结构,而小写的splay代表splay操作。
其实上面已经提到了,就是把一个节点不断双旋到想要的位置(一般是树根)。

inline void splay(int x, int f = 0) //f = 0即表示要转到树根
{
    
    while (fa[x] != f) {
    
        int y = fa[x], z = fa[y];
        if (z != f) rotate((ch[y][0] == x) == (ch[z][0] == y) ? y : x); //同向就先转父亲再转自己,异向就连转两下自己
        rotate(x);
    }
    if (!f) root = x; //如果x要转到树根,原来的树根就要更新
}

Splay就是这样优化二叉查找树的:每次操作过后(不论插入还是查询)都将操作的这个节点splay到树根去,这样树高就会变矮,单次操作的复杂度就是均摊的logn。

find

在树中查找一个数u,并将其splay至树根(接下来的其他操作的基础)

//以下,val[x]代表第x个节点的权值
inline int find(int u)
{
    
    int x = root; //从树根开始
    while (ch[x][u > val[x]] && val[x] != u) x = ch[x][u > val[x]]; //向下查找,u > val[x]就找右子树,否则找左子树
    splay(x); //然后转到根,,,简单吧
    return x;
}

接下来说说具体的操作怎么写。

一道板子题

1、插入数字u
inline void insert(int u)
{
    
    int x = root, f = 0;
    while (x && val[x] != u) f = x, x = ch[x][u > val[x]]; //先找到对应的位置
    if (x) num[x]++; //如果这个数已经有了,那么个数+1就好了
    else {
    
        val[x = ++cnt] = u;
        num[x] = sz[x] = 1;
        fa[x] = f;
        if (f) ch[f][u > val[f]] = x;
        //没有出现过的话,就建立新节点,并建立父子关系
    }
    splay(x); //这句话决定了你写的是Splay而不是二叉查找树
}
2、删除,等下再说。
3、查询u的排名
inline int get_rnk(int u)
{
    
    return sz[ch[find(u)][0]] + 1;
    //看着有点乱,我们分开写看看
    find(u);
    //找到权值为u的节点并把它转到树根
    return sz[ch[root][0]] + 1;
    //此时,所有比u小的数都在左边,比u大的数都在右边
    //所以,u的排名不就是左子树大小(比u小的数的个数)+1吗?
    //这两句话合在一起就是上面那句了(是不是好简单)
}

另外注意,这里不需要splay操作了,因为它已经在find里被splay过了(已经是树根了)。

4、查询排名为k的数(第k小的数)
inline int get_kth(int k)
{
    
    int x = root; //还是从根开始
    while (1) {
    
        if (sz[ch[x][0]] >= k) x = ch[x][0];
        //如果左子树就有超过k个数,那第k小的数肯定在左边
        else if (sz[ch[x][0]] + num[x] >= k) {
     splay(x); return val[x]; }
        //如果左子树不够k个,而加上根节点数字的数量就够了,那第k小的就是树根啊
        else k -= sz[ch[x][0]] + num[x], x = ch[x][1];
        //如果左边加上树根都不够,就说明在右边了。
    }
}

就这么简单,记得splay就好了。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值