教你把Splay刻进DNA里

先看一道例题(洛谷P3369):

为了满足快速查询某个节点,并保证这些节点有序的操作, 我们可以想到二叉搜索树(BST),对于BST的某个节点,它的左孩子一定比它小,右孩子一定比它大,所以它的中序遍历一定是有序的。但是问题很明显:二叉搜索树的搜索时间取决于树的高度,例如下图中的两棵树,它们的中序遍历都是一样的,但是很明显左边那棵平均搜索时间要低很多。

这时就可以引入我们今天要讲的概念:Splay树(伸展树),它首先是一颗平衡二叉树。Splay树通过不断将需要的节点旋转到根节点,并在旋转过程中优化树的结构,从而达到快速搜索和修改的操作。在正常情况下,对一棵Splay进行若干次操作,它的增加、删除、修改的复杂度总能保持在O(logn)以内。

Splay的优势在于它可以快速处理频繁操作或翻转的线段问题,相较于线段树,Splay显得更加灵活和方便。

Splay树有以下几个基本操作:

1、旋转(左旋和右旋)

 

 以这棵树为例,对于某个点的旋转操作即是把该点作为根,保留所有向左和向右延伸的两条链

左旋即为逆时针,右旋即为顺时针,(如图为右旋),把所有的节点向旋转方向移动一位

 最后再补上原树中的其他节点即可。

(左旋和右旋互为反操作,上图的逆向操作即为左旋)

对于splay来说,我们要把某一个节点x旋转到根节点,就会有以下几种情况:

1、x已经是根节点了,此时不需要操作

2、x的父节点是根节点,此时只需要对x的父节点进行一次左旋或者右旋即可

3、x的父节点不是根节点,我们可以把x前往根节点的这一段路分成四种情况:

前两种情况x、y、z都在一条直线上,所以翻转时需要一起走,后两种情况是折线,翻转时需要隐藏一个节点再翻转。

具体如下:

(1)、连续以最高点右旋两次

(2)、与(1)操作相反,连续以最高点左旋两次

(3)、由于x并不在根节点的左右两条链上,随意翻转会将x隐藏,所以我们要先将x翻转到最外面一层上,再向根节点移动

(4)、与(3)一致但操作相反

通过0和1的异或关系,我们可以将孩子节点指针放进数组s里,用s[0]表示左儿子,s[1]表示右儿子,那么左旋和右旋就可以放在一起写了。

这里我建议每次写的时候都画一张x、y、z的关系图,放在一起写的核心原理就是根据x、y、z相互之间的边的方向,更新旋转后的方向。

void pushup(int x)
{
    tr[x].size = tr[tr[x].s[0]].size + tr[tr[x].s[1]].size + 1;
}

void rotate(int x)
{
    int y = tr[x].p, z = tr[y].p;
    int k = tr[y].s[1] == x;  // k=0表示x是y的左儿子;k=1表示x是y的右儿子
    tr[z].s[tr[z].s[1] == y] = x, tr[x].p = z;
    tr[y].s[k] = tr[x].s[k ^ 1], tr[tr[x].s[k ^ 1]].p = y;
    tr[x].s[k ^ 1] = y, tr[y].p = x;
    pushup(y), pushup(x);
}

有了旋转函数,我们就可以做splay的核心操作:将某个节点x旋转到k下面:splay(x, k)

void splay(int x, int k)
{
    while (tr[x].p != k)
    {
        int y = tr[x].p, z = tr[y].p;
        if (z != k)
            if ((tr[y].s[1] == x) ^ (tr[z].s[1] == y)) rotate(x);
            else rotate(y);
        rotate(x);
    }
    if (!k) root = x;
}

2、查询序列第k个数

我们可以将每个节点所在子树的节点个数存在子树的根节点上,那么在查找第k个数的时候,我们只需要将k与左子树的孩子进行比较,就可以判断第k个数在左子树还是右子树。

int get_k(int k)
{
    int u = root;
    while (true)
    {
        pushdown(u); //pushdown将splay维护的节点内容向下传递
        if (tr[tr[u].s[0]].size >= k) u = tr[u].s[0];
        else if (tr[tr[u].s[0]].size + 1 == k) return u;
        else k -= tr[tr[u].s[0]].size + 1, u = tr[u].s[1];
    }
    return -1;
}

3、插入新节点

由二叉搜索树的性质可以很容易知道,当x为根节点时,x的前驱即x的左子树的最右节点;同理,后继为x的右子树的最左节点。

利用这个特点,我们可以找到新节点的前驱y,并将它旋转到根,再找到新节点的后继z,并将它旋转到y的下面,这样以来,z的左孩子一定是空的,因为z是y的后继。那么我们就可以将新节点插在z的左边。

void insert(int v)
{
    int u = root, p = 0;
    while (u) p = u, u = tr[u].s[v > tr[u].v];
    u = ++ idx;
    if (p) tr[p].s[v > tr[p].v] = u;
    tr[u].init(v, p);
    splay(u, 0);
}

4、删除节点

删除操作和插入操作刚好相反,假设我们要删除的区间为[l, r],那么我们可以将r + 1旋转到根节点,l - 1旋转到根节点下面,那么区间[l, r]一定在l - 1的右边边,直接清空即可。

最后放上完整代码:

参考文献:

bilibili-董晓算法,《普通平衡树》

OIwiki,Splay

洛谷P3369【模板】普通平衡树

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值