preloader image
平衡树之Splay 学习笔记

平衡树之Splay 学习笔记

平衡树之Splay 学习笔记

chlchl

·

2022-03-19 22:05:19

·

个人记录

前言

Splay 是我认为三种平衡树难度最高的了,涉及到双旋(我连 Treap 单旋都一知半解,搞双旋?做梦)。

前置芝士:BST(二叉搜索树)、Treap 旋转基础。

简介

既然有了 Treap,为什么要 Splay 呢?

Treap 依靠随机数生成优先级,有时要靠脸拿分;

Splay 有独一有二的作用:区间操作(啊其实 fhq_treap 也可以啊,但是啊,有个东西叫做 Link Cut Tree 啊,fhq_treap 维护要多 tm 一个 log 啊,常数还 tm 比 Splay 大啊,讲解还 tm 很少啊,所以没什么人用啊,不过我正在码啊)。

Splay Tree 又被称为伸展树,靠伸展维持 BST 的平衡。

不过,讲双旋之前,还是回顾一下单旋吧(不会的看上面链接)!

放个代码帮助理解:

bool direction(int x, int fa){return spy[fa].son[1] == x;}

void update(int u){spy[u].sz = spy[spy[u].son[0]].sz + spy[spy[u].son[1]].sz + 1;}

void rebuild(int x, int fa, int d){//d 是左边 or 右边的标记

spy[fa].son[d] = x, spy[x].fa = fa;

}

void rotate(int x){

int fa = spy[x].fa, g = spy[fa].fa;

int d = direction(x, fa);//右旋回 0 左旋回 1

rebuild(spy[x].son[d ^ 1], fa, d);//什么旋处理什么儿子

rebuild(x, g, direction(fa, g));

rebuild(fa, x, d ^ 1);//更换 x 和 fa 的父子关系

update(fa), update(x);

}

什么是双旋呢?就是旋两次呗!每次 Splay 要旋转两次才能平衡。这样操作的优势是将 Treap 的单旋再次优化时间,加上其 nbhh 的 Splay 操作,打遍天下无敌手(然鹅 Treap 仍然更快)。

怎么旋呢?

对于一棵有 3 个节点的树,一共有 4 种形态。我们称 zig 为右旋操作,zag 为左旋操作,则四种形态的树分别应该进行以下操作(纯图解说,无讲解文字,但保证能看懂):

大概就是这样啦!放一下双旋代码:

void splay(int x, int v){//把 x 转到 y 的儿子

if(!v) rt = x;//如果 v=0,那x自然就是根节点

while(spy[x].fa != v){

int f = spy[x].fa, g = spy[f].fa;

if(g != v) direction(f, g) ^ direction(x, f) ? rotate(x) : rotate(f);

//原本要分四种情况,但因为 rotate 合二为一,所以有的情况在 rotate 里讨论了

//利用异或,如果两个方向不同(返回1),就两次单旋,否则就是双旋

rotate(x);//最后一次都是旋转 x

}

}

插入、排名就不瞎哔哔了,与 BST 差不多,根据性质递归就行。但是要讲讲删除:

Splay 的删除做法是将 u 转到根节点之后删掉。那么问题来了,u 被删掉之后,谁来继承根节点的位置呢?不妨画图分析:

答案显而易见:选 u 的后继做新的根节点,可以省去很多事情(debug 也容易很多嘛)。

所以就有了下面这份代码:

void delnode(int u){

splay(u, 0);//先将 u 转到 根节点儿子

if(!spy[rt].son[1]) rt = spy[u].son[0], spy[rt].fa = 0;//转完发现没有右儿子,直接把根定为左儿子

else{

int nxt = spy[u].son[1];

while(spy[nxt].son[0]) nxt = spy[nxt].son[0];//找后继

splay(nxt, u);//将后继转到 u 的儿子

rebuild(spy[u].son[0], nxt, 0);//重构左儿子和后继的父子关系

rt = nxt, spy[nxt].fa = 0;//将根节点变成这个后继

update(rt);

}

}

其余的就和 BST、Treap 等差不多了, 唯一要注意的是每次操作都要旋它个天翻地覆,保证树的随机性,降低链的出现概率。

放个 P3369 的代码:

#include

using namespace std;

const int N = 1e5 + 10;

int n, rt, sum;

struct Splay{

int fa, val, sz, son[2];//0 left, 1 right

} spy[N];

bool direction(int x, int fa){return spy[fa].son[1] == x;}

void update(int u){spy[u].sz = spy[spy[u].son[0]].sz + spy[spy[u].son[1]].sz + 1;}

void rebuild(int x, int fa, int d){//d 是左边 or 右边的标记

spy[fa].son[d] = x, spy[x].fa = fa;

}

void rotate(int x){

int fa = spy[x].fa, g = spy[fa].fa;

int d = direction(x, fa);//右旋回 0 左旋回 1

rebuild(spy[x].son[d ^ 1], fa, d);//什么旋处理什么儿子

rebuild(x, g, direction(fa, g));

rebuild(fa, x, d ^ 1);//更换 x 和 fa 的父子关系

update(fa), update(x);

}

void newnode(int &u, int val, int fa){

u = ++sum;

spy[sum].fa = fa, spy[sum].val = val, spy[sum].sz = 1;

}

void splay(int x, int v){//把 x 转到 y 的儿子

if(!v) rt = x;

while(spy[x].fa != v){

int f = spy[x].fa, g = spy[f].fa;

if(g != v) direction(f, g) ^ direction(x, f) ? rotate(x) : rotate(f);

//原本要分四种情况,但因为 rotate 合二为一,所以有的情况在 rotate 里讨论了

rotate(x);//最后一次都是旋转 x

}

}

void insert(int &u, int fa, int val){

if(!u) newnode(u, val, fa), splay(u, 0);

else if(val < spy[u].val) insert(spy[u].son[0], u, val);

else insert(spy[u].son[1], u, val);

}

void delnode(int u){

splay(u, 0);

if(!spy[rt].son[1]) rt = spy[u].son[0], spy[rt].fa = 0;//转完发现没有右儿子,直接把根定为左儿子

else{

int nxt = spy[u].son[1];

while(spy[nxt].son[0]) nxt = spy[nxt].son[0];//找后继

splay(nxt, u);//将后继转到 u 的儿子

rebuild(spy[u].son[0], nxt, 0);

//重构 u 左儿子和后继的父子的关系,因为 u 已经删掉

rt = nxt, spy[nxt].fa = 0;//将根节点变成这个后继

update(rt);

}

}

void del(int val, int u = rt){

if(spy[u].val == val) delnode(u);

else if(spy[u].val > val) del(val, spy[u].son[0]);

else del(val, spy[u].son[1]);

}

int rnk(int x){

int u = rt, rk = 1, v = 0;

while(u){

if(x <= spy[u].val) v = u, u = spy[u].son[0];

else rk += spy[spy[u].son[0]].sz + 1, u = spy[u].son[1];

}

if(v) splay(v, 0);

return rk;

}

int query(int rk){

int u = rt;

while(u){

if(spy[spy[u].son[0]].sz + 1 == rk){

splay(u, 0);

break;

}else if(spy[spy[u].son[0]].sz >= rk) u = spy[u].son[0];

else rk -= spy[spy[u].son[0]].sz + 1, u = spy[u].son[1];

}

return spy[u].val;

}

int main(){

scanf("%d", &n);

int op, x;

while(n--){

scanf("%d%d", &op, &x);

if(op == 1) insert(rt, 0, x);

else if(op == 2) del(x);

else if(op == 3) printf("%d\n", rnk(x));

else if(op == 4) printf("%d\n", query(x));

else if(op == 5) printf("%d\n", query(rnk(x) - 1));

else if(op == 6) printf("%d\n", query(rnk(x + 1)));

}

return 0;

}

下面,进入重头戏。

独一无二——Splay 区间操作

Splay 独特之处在于它可以支持区间操作,不仅能像线段树一样维护区间和、区间最值,还能维护区间第 k 小、插入、删除节点等逆天操作。配合上 LCT,Splay 绝对是 OI 界中的大 Boss(虽然 NOIp 根本不会考到这个难度)。

我们利用二叉搜索树的性质用 Splay 储存整个数列,并且中序遍历就是整个数列。

当要处理 \lbrack l,r\rbrack 区间时,只要将 l-1 旋到根节点,r+1 旋到 l-1 的右儿子,那么 l-1 右儿子的左子树就是要操作的区间。为什么呢?看图:

看图懂的原理 +1。

然后就跟普通的 Splay 差不多了。放一个 P3372 线段树 1 的Splay:

#include

#define int long long

using namespace std;

const int N = 1e5 + 10;

int rt, num;

int n, q, a[N], s[N], cntemp;

struct Splay_tree{

int val, sum, tag, sz, fa, son[2];

} spy[N];

void newnode(int &u, int p, int k){

if(cntemp) u = cntemp--;//动态开点

else u = ++num;

spy[u].fa = p;

spy[u].sz = 1;

spy[u].tag = spy[u].son[0] = spy[u].son[1] = 0;

spy[u].val = spy[u].sum = k;

}

bool direction(int u, int fa){return spy[fa].son[1] == u;}

//给 r 为根的子树增加值,把当前结点更新,加懒标记

void update_val(int u, int v){

if(u == 0) return;

spy[u].tag += v, spy[u].val += v, spy[u].sum += v * spy[u].sz;//更新区间值

}

void pushup(int u){//更新当前节点的区间和、子树大小

spy[u].sz = spy[spy[u].son[0]].sz + spy[spy[u].son[1]].sz + 1;

spy[u].sum = spy[spy[u].son[0]].sum + spy[spy[u].son[1]].sum + spy[u].val;

}

void pushdown(int u){

if(spy[u].tag){//与线段树相似

update_val(spy[u].son[0], spy[u].tag);

update_val(spy[u].son[1], spy[u].tag);

spy[u].tag = 0;

}

}

void rebuild(int x, int fa, int d){

spy[fa].son[d] = x, spy[x].fa = fa;

}

void rotate(int x){

int fa = spy[x].fa, g = spy[fa].fa;

int d = direction(x, fa);//右旋回 0 左旋回 1

pushdown(fa);

pushdown(x);//任何操作前下放节点,不然就连父亲儿子都找不到了

rebuild(spy[x].son[d ^ 1], fa, d);//什么旋处理什么儿子

rebuild(x, g, direction(fa, g));

rebuild(fa, x, d ^ 1);//更换 x 和 fa 的父子关系

pushup(fa), pushup(x);

}

void Splay(int x, int v){//把 x 转到 y 的儿子

pushdown(x);

if(!v) rt = x;

while(spy[x].fa != v){

int f = spy[x].fa, g = spy[f].fa;

if(g != v) direction(f, g) ^ direction(x, f) ? rotate(x) : rotate(f);

rotate(x);

}

}

int rnk(int u, int k){//查询排名为 k 的数

pushdown(u);//先下放,与线段树一样

int t = spy[spy[u].son[0]].sz + 1;//左子树sz+自身

if(t == k) return u;

if(t > k) return rnk(spy[u].son[0], k);//在左子树中,往下递归

else return rnk(spy[u].son[1], k - t);//在右子树中,往右子树递归,查询排名为 k - t 的数

}

void build(int &o, int l, int r, int p){//与线段树非常相似

if(l > r) return;

int mid = (l + r) >> 1;

newnode(o, p, a[mid]);//建树,新建当前节点

build(spy[o].son[0], l, mid - 1, o);//往左子树递归

build(spy[o].son[1], mid + 1, r, o);//往右子树递归

pushup(o);//更新 o 节点的 val 和 sum

}

void update(int l, int r, int v){//区间增加一个值

Splay(rnk(rt, l), 0);//将第l个点旋转到根结点

Splay(rnk(rt, r + 2), rt);//因为加了个空结点,第 r + 2 个点旋转到根结点的右孩子

update_val(spy[spy[rt].son[1]].son[0], v);//将区间更新

pushup(spy[rt].son[1]);//更新右子树(val更新之后右子树也会改变)

pushup(rt);//更新节点 (根节点就更不用说了)

}

int query(int l, int r){//查询区间的和

Splay(rnk(rt, l), 0);//同 update

Splay(rnk(rt, r + 2), rt);//同 update

return spy[spy[spy[rt].son[1]].son[0]].sum;//那么 spy[spy[rt].son[1]].son[0] 就是需要修改的区间[l, r]

}

main(){

scanf("%lld%lld", &n, &q);

rt = num = cntemp = 0;//cntemp 方便动态开点

spy[rt].son[0] = spy[rt].son[1] = spy[rt].fa = spy[rt].sz = spy[rt].tag = spy[rt].sum = spy[rt].val = 0;//初始化

newnode(rt, 0, -1);//新建两个空节点

newnode(spy[rt].son[1], rt, -1);//新建两个空节点,防止区间操作时旋了个寂寞

for(int i=1;i<=n;i++) scanf("%lld", &a[i]);

build(spy[spy[rt].son[1]].son[0], 1, n, spy[rt].son[1]);//建树(区间建树,所以这么传参)

pushup(spy[rt].son[1]);//更新右子树

pushup(rt);//更新根节点

int op, x, y, z;

while(q--){

scanf("%lld", &op);

if(op == 2){

scanf("%lld%lld", &x, &y);

printf("%lld\n", query(x, y));

}else{

scanf("%lld%lld%lld", &x, &y, &z);

update(x, y, z);

}

}

return 0;

}

重点:花样操作。

花样操作太多了,没办法一一讲解,只能挑几个经典的说说。

区间翻转:P3391 【模板】文艺平衡树

这道题虽然 fhq_treap 做起来好调,但是 Splay 作为常数较小的平衡树,自然是更高效的。

平衡树这东西,不能急。多看图,多看代码注释,代码多写注释,就可以慢慢理解了。

Copyright © 2088 下一次世界杯_世界杯巴 - xbpifu.com All Rights Reserved.
友情链接