平衡树

算法简介

平衡树,一种数据结构。是一种特殊的二叉搜索树,能够支持许多操作。

算法思想

典中典:
您需要写一种数据结构(可参考题目标题),来维护一些数,其中需要提供以下操作:

  1. 插入一个数 x
  2. 删除一个数 x(若有多个相同的数,应只删除一个)。
  3. 定义排名为比当前数小的数的个数 +1。查询 x 的排名。
  4. 查询数据结构中排名为 x 的数。
  5. x 的前驱(前驱定义为小于 x,且最大的数)。
  6. x 的后继(后继定义为大于 x,且最小的数)。

二叉搜索树

在学习平衡树之前,我们要搞定二叉搜索树。

二叉搜索树(简称 BST),是一种二叉树。和堆一样,具有独特的性质(简称 BST 性质)。

性质:假定每个节点都有一个 val 值,则对于任何一个节点 p,其左子树任意节点val 值小于 pval 值;其右子树任意节点val 值大于 pval 值。

根据 BST 性质,二叉搜索树可以在 O(nlogn) 的时间复杂度内解决经典例题,我们对每个操作依次讲解。


准备:节点

BST 中的每个节点都储存五个元素:其左儿子,右儿子,val 值,子树大小,副本数(子树大小为该子树内所有节点副本数之和)。

操作一:插入

运行分两种情况

如果当前 BST 中有 val 值,直接另其副本数加一,更新子树大小,如果当前 BST 中无 val 值,新建节点,左右子节点为空,子树大小和副本数为 1

操作二:删除

如果删除节点副本数大于 1,直接另其副本数减一,更新子树大小如果删除节点副本数等于 1,则删除这个节点,并用其后继节点代替它原本的位置(求后继操作后面会讲)

对于操作一二,如果要在以 p 为根找到某个节点,比较 pVal 和给定 val。如果 Val>val,就递归其左子树找 val ,反之递归右子树。操作均基于 BST 的性质。

操作三:根据 valrank

定义函数 get_rank(p,val) 为在以 p 为根的子树中,val 的排名。我们定义 pvalVal,要查找的 valkey

  1. Val>key

直接返回 get_rank(lson,key)。因为 Val 和节点 p 右子树的 val 均大于 key

  1. Val=key

返回 p 左子树大小加一,因为 p 左子树 val 均小于 key

  1. Val<key

返回 get_rank(rson,key) 加上左子树大小和 p 节点的副本数。这是将答案分为三部分, p 左子树 val 均小于 keyVal 也小于 k。加上右子树中小于它的部分。

  1. p 为空

返回 1。根据 get_rank() 定义可知

操作四:根据 rankval

跟上述基本思想一致

// a[p].l,a[p].r,a[p].size,a[p].cnt 分别是左右子节点,子树大小,副本数
int get_val(int p,int rank){
    if(!p)return inf;
    else if(rank<=a[a[p].l].size)return get_val(a[p].l,rank);
    else if(rank<=a[a[p].l].size+a[p].cnt)return a[p].val;
    else return get_val(a[p].r,rank-a[p].cnt-a[a[p].l].size);
}

操作五:找前倾

初始化答案 ans=。从 root 出发往下遍历,情况分两种讨论(我们定义 pvalVal,要查找的 valkey

  1. Val>val

更新答案 ans=Val,继续遍历 p 的右子树。

  1. Valval

遍历 p 的左子树。

核心思想:我们在遍历过程中不断接近 val。因为和 Valval 的差距必然会越来越小。我们只要找到合法的,可能为前倾的 Val,就直接更新答案。正是基于这里

操作六:找后继

同操作五。


现在我们实现了上述操作,因为我们每当决定遍历某个节点的左/右子树时,我们都会舍弃另一个子树。相当于减小一半范围。因此每个操作时间复杂度在理想情况下(数据随机)为 O(logn)

但现在远远没有结束,因为现实中总会有出题人用构造的数据卡掉我们的程序

平衡树定义:

我们会发现,BST 中绝大多数操作的时间复杂度和树的深度有关,但是因为插入方式,如果我们按照顺序插入一个长度为 n 的有序序列 a。树的深度会达到惊人的 n。那么这颗 BST 的时间复杂度就会退化到 O(n2)

平衡树的特点在于,虽然其仍然是一棵 BST。但会在适当时间调整树的结构,且不影响 BST 性质。大部分的平衡树时间复杂度为 O(nlogn)。因为代码长度问题。竞赛中的平衡树主要使用 TreapSplay。但是 Splay 比较难懂,所以主要学习 Treap

传统平衡树-带旋 Treap

带旋 Treap 主要进行两种操作,分别是 zig(右旋)和 zag(左旋)。这里放一张示意图:

左旋-右旋

A 操作为右旋,B 操作反之。

可以看到左右旋改变了树的结构,但是没有改变 BST 性质,对于一些平衡性破坏的情况,左右旋可以维护平衡性。

维护平衡性

那我们应该在什么时候进行左右旋,众所周知,在随机数据中,朴素 BST 本身具有较高平衡性。所以我们可以给每个点随机一个权值 dat。并在使 val 具有 BST 性质时,使 dat 具有堆性质。如果一个 datp<datplson 就进行右旋, datp<datprson 就进行左旋。我们就可以随机的进行左右旋来维护平衡了。

右旋的意义是将一个节点的左节点旋转为父节点,左旋的意义是将一个节点的有节点旋转为父节点。这是有旋 Treap 的基本操作。

对于普通 BST 的删除操作,我们可以改进,如果删除的节点不是叶节点,我们不断将其左旋或有旋旋转到叶节点,然后直接删除,免除了 BST 性质和堆性质的更新。

在代码中,会存在大量的传址调用。一定要分辨清楚。

点击查看代码
#include <iostream>
#include <cstdio>
#include <cstdlib>
using namespace std;
const int N=1e5+10,inf=1e9+10;;
int tot,root,n,op,x;
struct node{
    int l,r,cnt,size,dat,val;
}a[N];
int New(int val){
    a[++tot].val=val;
    a[tot].dat=rand();
    a[tot].size=a[tot].cnt=1;
    return tot;
}
void update(int p){
    a[p].size=a[a[p].l].size+a[a[p].r].size+a[p].cnt;
}
void build(){
    root=New(-inf),a[root].r=New(inf);
    update(root);
}
void zig(int &p){//右旋
    int q=a[p].l;
    a[p].l=a[q].r,a[q].r=p;
    p=q,update(p),update(a[p].r);
}
void zag(int &p){//左旋
    int q=a[p].r;
    a[p].r=a[q].l,a[q].l=p;
    p=q;update(p),update(a[p].l);
}
void insert(int &p,int val){// 插入
    if(!p){
        p=New(val);
        return;
    }else if(a[p].val==val){
        a[p].cnt++;
        update(p);
        return;
    }
    else if(a[p].val>val){
        insert(a[p].l,val);
        if(a[p].dat<a[a[p].l].dat)zig(p);

    }else if(a[p].val<val){
        insert(a[p].r,val);
        if(a[p].dat<a[a[p].r].dat)zag(p);
    }
    update(p);
}
void Delete(int &p,int val){// 删除
    if(p==0)return;
    if(a[p].val==val){
        if(a[p].cnt>1){
            a[p].cnt--,update(p);
            return;
        }
        if(a[p].l||a[p].r){
            if(a[p].r==0||a[a[p].l].dat>a[a[p].r].dat)
                zig(p),Delete(a[p].r,val);
            else
                zag(p),Delete(a[p].l,val);
            update(p);
        }else p=0;
        return;
    }
    a[p].val>val?Delete(a[p].l,val):Delete(a[p].r,val);
    update(p);
}
int get_rank(int p,int val,int f){//查排名
    if(!p)return 1;
    else if(a[p].val==val)return a[a[p].l].size+1;
    else if(val<a[p].val)return get_rank(a[p].l,val,1);
    else return get_rank(a[p].r,val,2)+a[a[p].l].size+a[p].cnt;
}
int get_val(int p,int rank){// 查数
    if(!p)return inf;
    else if(rank<=a[a[p].l].size)return get_val(a[p].l,rank);
    else if(rank<=a[a[p].l].size+a[p].cnt)return a[p].val;
    else return get_val(a[p].r,rank-a[p].cnt-a[a[p].l].size);
}
int get_pre(int val){//前倾
    int ans=-inf,p=root;
    while(p){
        if(a[p].val<val)ans=a[p].val,p=a[p].r;
        else p=a[p].l;
    }
    return ans;
}
int get_next(int val){//后继
    int ans=inf,p=root;
    while(p){
        if(a[p].val>val)ans=a[p].val,p=a[p].l;
        else p=a[p].r;
    }
    return ans;
}
int main(){
    build();
    scanf("%d",&n);
    while(n--){
        scanf("%d %d",&op,&x);
        if(op==1)insert(root,x);
        if(op==2)Delete(root,x);
        if(op==3)printf("%d\n",get_rank(root,x,0)-1);
        if(op==4)printf("%d\n",get_val(root,x+1));
        if(op==5)printf("%d\n",get_pre(x));
        if(op==6)printf("%d\n",get_next(x));
    }
    return 0;
}

非旋 Treap

非旋 Treap,由 fhq 发明,又名 FHQ-treap。在学习它之前,首选要探讨一个问题。

为什么说,FHQ-treap 是神?

这个问题很简单,我们将可以通过平衡树板子的所有算法一一对比即可。

首先是犯下傲慢之罪的vector

数据水就认为自己可以代替神,取代平衡树。相比之下,神无疑是谦虚的。错误的时间复杂度 O(n2) 使得它无法通过加强版。这无疑是神对它的惩罚

其次是犯下愤怒之罪的权值线段树。

仗着神的常数大,没他跑的快就大放厥词“线段树的常数是优于平衡树的常数的”(zcysky 的博客)。和他相比,神更加宽和。因为算法必须离线,导致其也无法通过加强版

然后是犯下懒惰之罪的分块

认为自己相比于神能够处理更多的问题,便在大部分的平衡树题解中出现,让人们不思进取,只知道分块。故被神降下神罚,使得 O(nn) 的时间复杂度无法通过加强版 106 的数据。

紧接着是犯下贪婪之罪的 01-trie

因为自己自己能通过加强版的数据,就贪婪的希望取代神。无法进行更多复杂的操作使得其成为冷门算法,这甚至不是神的惩罚!神对于区间操作的掌握也一直了然于心,这样的能力岂是凡人所能比拟的。

紧接着是犯下暴食之罪的带旋 Treap

虽然贵为神父,但以更小的常数为借口放纵自己。代码长度的问题一直没有解决。因而被神降下惩罚。使其 100 多行的代码不被蒟蒻所接受,且无法可持久化,好在神念及旧情,再次允许它活跃于神犇的讨论之间

最后是其他做法。例如 AVL,替罪羊树,跳表,红黑树 等等

虽然能通过加强版,但是因为码长问题,而被蒟蒻摈弃。但是宽容的神仍然给予其新生,让他们在神犇的题解中出现

平衡树不能失去 FHQ-Treap,就像西方不能失去耶路撒冷。

整活完毕

算法讲解

FHQ-Treap 的大部分操作都给予两种操作,分别是 split(分裂) 和 merge(合并) 。

前置:

更新函数和结构体:

struct node{
    int l,r,size,dat,val;
}a[N];
void update(int p){
    a[p].size=a[a[p].l].size+a[a[p].r].size+1;
}

split(按 val 分裂)

定义函数 split(p,Val,x,y) 为,将以 p 为根的平衡树,分为以 x,y 为根的两颗平衡树,且对于任何 ason(x),bson(y) 都有 valxval<valy。我们根据定义进行分类讨论

  1. p 为空。

是个空树,则 x=y=0.

  1. valpVal

根据 BST 性质,任意 ulson(p) 均有 valuVal,则 p 的左子树属于 xp 也属于 xx 的根就是 p。 (Valvalp)。且 p 的右子树中也有一部分可能存在于 x 中。我们继续分裂,调用函数 split(rsonp,val,rsonx,y)

  1. valp>Val

反之,调用 split(lsonp,val,x,lsony)

我们在分裂完成后,还要更新节点的子树大小。

void split(int p,int v,int &x,int &y){
	if(!p)return x=y=0,void();//一定要return,不然会update(p)
	if(a[p].val<=v)split(a[p].r,v,a[x=p].r,y);//x的根是p
	else split(a[p].l,v,x,a[y=p].l);//y的根是p
	update(p);
}

split(按 rank 分裂)

定义函数 split_rk(p,k,x,y) 为,将以 p 为根的平衡树,分为以 x,y 为根的两颗平衡树,且对于任何 ason(x),bson(y) 都有 rankxk<ranky。分裂的过程是 split 和普通平衡树中 get_rank() 的结合

void split(int p,int k,int &x,int &y){
	if(!p)return x=y=0,void();//一定要return,不然会update(p)
	if(a[a[p].l].size+1<=k)split(a[p].r,k,a[x=p].r,y);//x的根是p
	else split(a[p].l,k,x,a[y=p].l);//y的根是p
	update(p);
}

merge

打乱 FHQ-Treap 的核心操作。merge(x,y) 表示合并 x,y 两颗平衡树,并返回合并产生新的根 root。这个函数的前提必须保证任意 ason(x),bson(y),都有 valxvalyrankxranky。才不会影响到 BST 性质。

还是分情况讨论

  1. x 为空或 y 为空。
    我们只需返回非空的哪一个,如果两个都为空,返回 0

C++:return x+y

  1. 其他情况
    此时,因为保证 valxvalyrankxranky。所以只会有 x=lsonyy=rsonx 两种情况。这是我们就要比较二者的随机权值,如果 datx>daty,那么 x 就是 y 的父亲,反之则是其儿子。
int merge(int x,int y){
    if(!x||!y)return x+y;
    if(a[x].dat>a[y].dat)return a[x].r=merge(a[x].r,y),update(x),x;
    else return a[y].l=merge(x,a[y].l),update(y),y;
}

注意,因为合并后的新根可能不是原来的节点,所以一定要赋值。

新建,插入和删除

FHQ-Treap 的插入和删除简单到了极点。我们只需要保证好 merge 函数的前提就可以。

int New(int val){//新建节点
    a[++tot].val=val;
    a[tot].dat=rand();
    a[tot].size=1;
    return tot;
}
void Insert(int val){//插入
    int x=0,y=0;
    split(root,val-1,x,y),root=merge(merge(x,New(val)),y);
    //先将根节点分裂,然后将新节点插入其中合并
}
void Delete(int val){//删除
    int x=0,y=0,z=0;
    split(root,val,x,z),split(x,val-1,x,y);
    root=merge(merge(x,y=merge(a[y].l,a[y].r)),z);
    //这个程序没写副本数,用了一种更巧妙的方法
    //先二次分裂出平衡树y,此时y中的所有节点的val 都是给定val
    //我们扔掉y的根节点,并用它左右儿子合并产生的新节点代替
}

其他功能

此时的平衡树已经具有平衡性,直接按照原有的 BST 函数就好了。但我们可以用 mergesplit 更方便的操作。

根据 Valrank

将平衡树按照 Val1 分裂成两颗平衡树 x,y。此时任意 uson(x) 都有 valu<Val。则 sizex+1 即为所求。

根据 rankval

如果分裂函数写的是按值分裂,建议写普通 BST 版本的该函数。如果写的是按排名分裂,则先按 kroot 分为 x,z。在按 k1root 分为 x,yvaly 即为所求。

前倾/后继
重点将前倾。先按 val1 分裂成 x,y。此时任意 uson(x) 都有 valu<val。我们只需找其中最大的即可,也就是get_kth_val(x,sizex)

点击查看代码
#include <iostream>
#include <cstdio>
#include <cstdlib>
using namespace std;
const int N=1e5+10,inf=1e9+10;
int tot,root,n,op,x;
struct node{
    int l,r,size,dat,val;
}a[N];
int New(int val){
    a[++tot].val=val;
    a[tot].dat=rand();
    a[tot].size=1;
    return tot;
}
void update(int p){
    a[p].size=a[a[p].l].size+a[a[p].r].size+1;
}
void split(int p,int v,int &x,int &y){
	if(!p)return x=y=0,void();
	if(a[p].val<=v)split(a[p].r,v,a[x=p].r,y);
	else split(a[p].l,v,x,a[y=p].l);
	update(p);
}
int merge(int x,int y){
	if(!x||!y)return x+y;
	if(a[x].dat<a[y].dat)return a[y].l=merge(x,a[y].l),update(y),y;
	return a[x].r=merge(a[x].r,y),update(x),x;
}
void Insert(int val){
    int x=0,y=0;
    split(root,val-1,x,y),root=merge(merge(x,New(val)),y);
}
void Delete(int val){
    int x=0,y=0,z=0;
    split(root,val,x,z),split(x,val-1,x,y);
    root=merge(merge(x,y=merge(a[y].l,a[y].r)),z);
}
int get_rank(int val){
    int x=0,y=0,ans;
    split(root,val-1,x,y);
    ans=a[x].size+1;
    return root=merge(x,y),ans;
}
int get_val(int k){
    int p=root;
	while(1){
		if(k<=a[a[p].l].size)p=a[p].l;
		else if(k==a[a[p].l].size+1)return a[p].val;
		else k-=a[a[p].l].size+1,p=a[p].r;
	}
}
int get_pre(int val){
    int ans=0,p=root;
    while(p){
        if(a[p].val>=val)p=a[p].l;
        else ans=a[p].val,p=a[p].r;
    }
    return ans;
}
int get_next(int val){
    int ans=0,p=root;
    while(p){
        if(a[p].val>val)ans=a[p].val,p=a[p].l;
        else p=a[p].r;
    }
    return ans;
}
int main(){
    scanf("%d",&n);
    while(n--){
        scanf("%d %d",&op,&x);
        if(op==1)Insert(x);
        if(op==2)Delete(x);
        if(op==3)cout<<get_rank(x)<<endl;
        if(op==4)cout<<get_val(x)<<endl;
        if(op==5)cout<<get_pre(x)<<endl;
        if(op==6)cout<<get_next(x)<<endl;
    }
    return 0;
}

FHQ-treap 只有 80 行代码,FHQ-treap 为什么是神?

例题

1:【模板】文艺平衡树

众所周知,如果一棵平衡树代表了 [l,r] 这个区间,对一个区间进行翻转操作。就相当于将这可平衡树内的每一个节点的左右儿子翻转(可以手动模拟)

于是我们就可以用 split_rk 分裂两次分裂出代表了 [l,r] 这个区间的平衡树。

但如果我们暴力旋转,时间复杂度会退化到 O(n2),仔细思考,如果对一个节点翻转两次,相当于没变,所以我们借助懒标记。如果要遍历其左右节点就标记下放,否则直接懒标修改。

点击查看代码
#include <iostream>
#include <cstdio>
using namespace std;
const int N=1e5+10;
int tot,root,n,m,l,r;
struct node{
    int l,r,val,dat,size,tag=0;
}a[N];
int New(int val){
    a[++tot].val=val;
    a[tot].dat=rand();
    a[tot].size=1;
    return tot;
}
void update(int p){
    a[p].size=a[a[p].l].size+a[a[p].r].size+1;
}
void push_down(int p){
    if(a[p].tag)a[a[p].l].tag^=1,a[a[p].r].tag^=1,swap(a[p].l,a[p].r),a[p].tag=0;
}
void split(int p,int k,int &x,int &y){
    if(!p)return x=y=0,void();
    push_down(p);
    if(a[a[p].l].size+1<=k)split(a[p].r,k-a[a[p].l].size-1,a[x=p].r,y);
    else split(a[p].l,k,x,a[y=p].l);
    update(p);
}
int merge(int x,int y){
    if(!x||!y)return x+y;
    push_down(x),push_down(y);
    if(a[x].dat>a[y].dat)return a[x].r=merge(a[x].r,y),update(x),x;
    else return a[y].l=merge(x,a[y].l),update(y),y;
}
void fan(int l,int r){
    int x=0,y=0,z=0;
    split(root,l-1,x,z),split(z,r-l+1,y,z);
    a[y].tag^=1,root=merge(x,merge(y,z));
}
void print(int p){
    if(!p)return;
    push_down(p);
    print(a[p].l);
    printf("%d ",a[p].val);
    print(a[p].r);
}
int main(){
    scanf("%d %d",&n,&m);
    for(int i=1;i<=n;i++)root=merge(root,New(i));
    while(m--){
        scanf("%d %d",&l,&r);
        fan(l,r);
    }
    print(root);
    return 0;
}

不是 Splay 写不起,而是FHQ-treap更有性价比;FHQ-treap 只有 30 行代码,FHQ-treap 为什么是神?

2:[NOI2004] 郁闷的出纳员

这题有两个写法

  • 对于修改工资的操作,因为次数太少,直接暴力修改

  • 记录一个工资幅度变化值 sum。每次加减都直接修改 sum。查询就查询小于 minsum。因为会有新插入的员工,我们假定员工的初始工资为 k。再次之前它错过了工资增加 sum 的机会,所以插入平衡树要插入 ksum.

  • 应为可能要删除所有低于 minsum 的人,我们直接从根节点分裂出来,然后直接丢掉,在这里就可以感受到 FHQ-treap 暴力的优雅

FHQ-treap 为什么是神?

3:[TJOI2007] 书架

我们直接按排名分裂,然后与新节点合并。查询可以直接分裂两次得到第 k 本书。

点击查看代码
#include <iostream>
#include <cstdio>
#include <cstdlib>
using namespace std;
const int N=1.5e5;
int tot,root,n,q,m,x;
string str;
struct node{
    int l,r,size,dat;
    string val;
}a[N];
int New(string s){
    a[++tot].val=s;
    a[tot].size=1;
    a[tot].dat=rand();
    return tot;
}
void update(int p){
    a[p].size=a[a[p].l].size+a[a[p].r].size+1;
}
void split(int p,int k,int &x,int &y){//T_x<=k,T_u>k;
    if(!p)return x=y=0,void();
    else if(a[a[p].l].size+1<=k)split(a[p].r,k-a[a[p].l].size-1,a[x=p].r,y);
    else split(a[p].l,k,x,a[y=p].l);
    update(p);
}
int merge(int x,int y){
    if(!x||!y)return x+y;
    if(a[x].dat>a[y].dat)return a[x].r=merge(a[x].r,y),update(x),x;
    else return a[y].l=merge(x,a[y].l),update(y),y;
}
void insert(string s,int k){
    int x=0,y=0;
    split(root,k,x,y);
    root=merge(merge(x,New(s)),y);
}
string kth_val(int k){
    int x=0,y=0,z=0;string ans;
    split(root,k,x,z),split(x,k-1,x,y);
    ans=a[y].val;
    root=merge(merge(x,y),z);
    return ans;
}
int main(){
    scanf("%d",&n);
    for(int i=1;i<=n;i++){
        cin>>str;
        insert(str,i-1);
    }
    scanf("%d",&m);
    for(int i=1;i<=m;i++){
        cin>>str>>x;
        insert(str,x);
    }
    scanf("%d",&q);
    for(int i=1;i<=q;i++){
        scanf("%d",&x);
        cout<<kth_val(x+1)<<'\n';
    }
    return 0;
}

FHQ-treap 为什么是神?

4: [NOI2003] 文本编辑器

定义一个指针 it。遇到 1,5,6 直接赋值。

对于操作 2。我们把字符串拆开,按 it 分裂为 x,y。然后将 x 与字符一个一个合并,最后将 x,y 合并。不能每次插入字符都分裂,合并,很可能 TLE。

对于操作 3。我们按 it 分裂出 x,z,在按 nz 分裂出 y,z,丢掉 y。合并 x,z 即可

对于操作 4。我们分裂出表示区间 [it,it+n1] 的平衡树,输出其中序遍历即可

这道题中的分裂均指按照排名分裂。

FHQ-treap 为什么是神?

The End

FHQ-treap 为什么是神?

FHQ-treap 真的好用

平衡树给我学吐了,最近几个月不学数据结构了。

posted @   zuoqingyuan111  阅读(15)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 25岁的心里话
点击右上角即可分享
微信分享提示