本文收录于2019年3月的洛谷日报,原文初稿于 2019-01-20.


WBLT 全称 \(\text{Weight Balanced Leafy Tree}\). 是一种常数较小,代码较简单的平衡树实现方式。

在看本文之前,推荐您先学习 treap 等平衡树 这篇文章对于有平衡树基础的人较为友好

定义和引入

WBLT是二叉搜索树的一种。不同的是,他同时是一个大根堆(也可以是小根堆),每个非叶节点都有两个儿子,且每个节点的权值与其右儿子的权值相同,且左儿子的权值小于右儿子的权值,左子树的所有节点的权值小于右子树任意节点的权值。

也就是说他大概长这样:

这种设计有一个明显的缺点 就是如果要储存n个数据,普通的平衡树需要开n个节点,而WBLT需要开2n-1个

也就是说 储存上图的数据的treap长这样:

那么 相比之下它有什么好处呢?

旋转

WBLT= Weight Balanced Tree(加权平衡树) + Leafy,其中Leafy已经在定义和引入中体现了,平衡是指一个节点的左子树和右子树大小近似相同,这样在查询/修改的时候才能做到近似log,旋转便是维护平衡的方便手段

因为其结构特殊 不需要像treap一样引入一个rand,只要旋转就可以维护其平衡。

先给出旋转的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
inline void merge(int l,int r){
size[++cnt]=size[l]+size[r];
val[cnt]=val[r];
ls[cnt]=l,rs[cnt]=r;
}

inline void rotate(int cur,bool flag){
if(flag){
merge(ls[cur],ls[rs[cur]]);
ls[cur]=cnt;
rs[cur]=rs[rs[cur]];
}//左旋
else{
merge(rs[ls[cur]],rs[cur]);
rs[cur]=cnt;
ls[cur]=ls[ls[cur]];
}//右旋
}
再给出图例(节点上的数字为编号,数字颜色为红色则为新节点): 可以发现,旋转后的WBLT仍然保持原来的性质。而且明显偏重的左子树转到了右边,左右子树相对平衡了。

查询排名为x的数

我们记录每个节点的size,这个size不是子树的大小,而是子树储存的有效信息的大小。

因为有性质_储存n个数据要开2n-1个节点_,所以如果一个子树的大小为\(2x-1\),那它储存的数据量就有\(x\)个。

接下来的操作就简单了,令find(cur,x)为寻找cur所在的子树下排名为x的数,那么当x比左子树的size小

\(find(cur,x)=find(lson_{cur},x)\)

当x比左子树的size大

$find(cur,x)=find(rson_{cur},x-size_{lson_{cur}})

如果相等,那显然

\(find(cur,x)=val_{cur}\)

因为当前要找的是第x大,那我无需遍历下面,这点也与treap不同

1
2
3
4
5
6
7
int find(int cur,int x){
if(size[cur]==x)
return val[cur];
if(x>size[ls[cur]])
return find(rs[cur],x-size[ls[cur]]);
return find(ls[cur],x);
}

查询x的排名

同理,设rnk(cur,x)为寻找cur子树下x的排名

当x小于cur的左儿子的权值

\(rnk(cur,x)=rnk(lson_{cur},x)\)

否则

\(rnk(cur,x)=size_{lson_{cur}}+rnk(rson_{cur},x)\)

1
2
3
4
5
6
7
int rnk(int cur,int x){
if(size[cur]==1)
return 1;
if(x>val[ls[cur]])
return rnk(rs[cur],x)+size[ls[cur]];
return rnk(ls[cur],x);
}

插入

WBLT其他的操作都与treap类似,在每一步时:

根据要添加的权值和当前搜索到的节点选择左右子树进行递归(如果比左儿子的权值大就去右子树,否则去左子树)

递归到最后一步到一个叶子节点时,根据其权值大小建立新节点,确定是该节点的左儿子还是右儿子

建立它的兄弟节点。

向上pushup(类似于线段树,儿子会影响父亲,这点于与treap不同)。

1
2
3
4
5
6
7
8
9
10
void insert(int cur,int x){
if(size[cur]==1){
newnode(ls[cur],minn(x,val[cur]));
newnode(rs[cur],maxx(x,val[cur]));
pushup(cur);
return ;
}
insert(x>val[ls[cur]]?rs[cur]:ls[cur],x);
pushup(cur);
}

删除

在每一步时:

根据要删除权值和当前搜索到的节点选择左右子树进行递归(如果比左儿子的权值大就去右子树,否则去左子树)

递归到最后一步到一个叶子节点时,判断该节点是不是要删除的,如果不是则选择其兄弟节点,进行删除,将两个节点中保留的与其父亲节点进行替换

向上pushup。

1
2
3
4
5
6
7
8
9
10
void erase(int cur,int x){
if(size[cur]==1){
cur= ls[fa]==cur?rs[fa]:ls[fa];
copynode(fa,cur);
return ;
}
fa=cur;
erase(x>val[ls[cur]]?rs[cur]:ls[cur],x);
pushup(cur);
}

旋转,和P3369

等等 旋转呢?

我们在上文所有的操作中,似乎没有使用旋转,那旋转放在哪呢?

我们将其放在树的结构有改变的地方,也就是插入 删除这些操作中,每当一个子树过大,就进行相应的旋转,在插入和删除操作中加入以下函数即可

1
2
3
4
5
6
7
8
const int ratio=5;

inline void maintain(int cur){
if(size[ls[cur]]>size[rs[cur]]*ratio)
rotate(cur,0);
else if(size[rs[cur]]>size[ls[cur]]*ratio)
rotate(cur,1);
}

那么普通平衡树的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
#include<algorithm>
#include<iostream>
#include<cstring>
#include<cstdio>

using namespace std;

const int maxn=400010;
const int ratio=5;

int n,cnt,fa,root;
int size[maxn],ls[maxn],rs[maxn],val[maxn];

inline void newnode(int &cur,int v){
cur=++cnt;
size[cur]=1;
val[cur]=v;
}

inline void copynode(int x,int y){
size[x]=size[y];
ls[x]=ls[y],rs[x]=rs[y];
val[x]=val[y];
}

inline void merge(int l,int r){
size[++cnt]=size[l]+size[r];
val[cnt]=val[r];
ls[cnt]=l,rs[cnt]=r;
}

inline void rotate(int cur,bool flag){
if(flag){
merge(ls[cur],ls[rs[cur]]);
ls[cur]=cnt;
rs[cur]=rs[rs[cur]];
}
else{
merge(rs[ls[cur]],rs[cur]);
rs[cur]=cnt;
ls[cur]=ls[ls[cur]];
}
}

inline void maintain(int cur){
if(size[ls[cur]]>size[rs[cur]]*ratio)
rotate(cur,0);
else if(size[rs[cur]]>size[ls[cur]]*ratio)
rotate(cur,1);
}

inline void pushup(int cur){
if(!size[ls[cur]])return ;
size[cur]=size[ls[cur]]+size[rs[cur]];
val[cur]=val[rs[cur]];
}

inline int minn(int a,int b){
return a<b?a:b;
}
inline int maxx(int a,int b){
return a>b?a:b;
}

inline void insert(int cur,int x){
if(size[cur]==1){
newnode(ls[cur],minn(x,val[cur]));
newnode(rs[cur],maxx(x,val[cur]));
pushup(cur);
return ;
}
maintain(cur);
insert(x>val[ls[cur]]?rs[cur]:ls[cur],x);
pushup(cur);
}

inline void erase(int cur,int x){
if(size[cur]==1){
cur= ls[fa]==cur?rs[fa]:ls[fa];
copynode(fa,cur);
return ;
}
maintain(cur);
fa=cur;
erase(x>val[ls[cur]]?rs[cur]:ls[cur],x);
pushup(cur);
}

inline int find(int cur,int x){
if(size[cur]==x)
return val[cur];
maintain(cur);
if(x>size[ls[cur]])
return find(rs[cur],x-size[ls[cur]]);
return find(ls[cur],x);
}

inline int rnk(int cur,int x){
if(size[cur]==1)
return 1;
maintain(cur);
if(x>val[ls[cur]])
return rnk(rs[cur],x)+size[ls[cur]];
return rnk(ls[cur],x);
}

int main(){
scanf("%d",&n);
newnode(root,(1<<30));
while(n--){
int s,x;
scanf("%d%d",&s,&x);
if(s==1)insert(root,x);
if(s==2)erase(root,x);
if(s==3)printf("%d\n",rnk(root,x));
if(s==4)printf("%d\n",find(root,x));
if(s==5)printf("%d\n",find(root,rnk(root,x)-1));
if(s==6)printf("%d\n",find(root,rnk(root,x+1)));
}
return 0;
}

以下是评测记录

上面的是treap,下面的是WBLT

我们发现,WBLT只比treap慢一点点,所以WBLT和treap几乎是一样块的~

例题

P1503 鬼子进村

平衡树部分是个裸题,其他倒还得想想

先把0和n+1插入,作为边界

摧毁节点就插入该点

删除上一个就维护个栈,删除栈顶即可

询问操作就查找前驱和后继,一减就行了,记得特判是否已经被摧毁(记个vis数组即可)

代码

P2596 [ZJOI2006]书架

平衡树部分还是个裸题

定义优先级越小,那本书就放越上面

\(a_i\)为编号为\(i\)的书本的优先级,\(mapp_i\)为优先级为i的节点编号。

Top S:将优先级变为最小再插入

Bottom S:将优先级变为最大再插入

Insert S T:找到对应两本书 交换优先级

Ask S:查询优先级排名

Query S:查询第k小的优先级对应的编号

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#include<algorithm>
#include<iostream>
#include<cstring>
#include<cstdio>
#include<cmath>
#include<map>

using namespace std;

const int maxn=400010;
const int ratio=5;

inline int read(){
register int num=0,flag=1;char ch;
while((ch=getchar())<'0'||ch>'9') if(ch=='-') break;
if(ch=='-') flag=-1;
else num=ch-'0';
while((ch=getchar())>='0'&&ch<='9') num=num*10+ch-'0';
return num*flag;
}
void out(int x){
if(x>=10) out(x/10);
putchar(x%10+'0');
}

int n,m,cnt,fa,root;
int size[maxn],ls[maxn],rs[maxn],val[maxn],a[maxn];
int mapp[maxn];

/*此处有省略,平衡树部分请参照上文*/

char opt[20];
int k,l,r,i;

int main() {
n=read();
m=read();
newnode(root,(1<<30));
l=233333,r=n+233333;
for(i=1; i<=n; i++) {
int qaq;
qaq=read();
insert(root,i+233333);
a[qaq]=i+233333;
mapp[i+233333]=qaq;
}
while(m--) {
scanf("%s",opt);
if(opt[0]=='Q') {
k=read();
out(mapp[find(root,k)]);
putchar('\n');
} else if(opt[0]=='A') {
k=read();
out(rnk(root,a[k])-1);
putchar('\n');
} else if(opt[0]=='T') {
k=read();
erase(root,a[k]);
insert(root,--l);
a[k]=l;
mapp[l]=k;
} else if(opt[0]=='B') {
k=read();
erase(root,a[k]);
insert(root,++r);
a[k]=r;
mapp[r]=k;
} else if(opt[0]=='I') {
register int s=read(),t=read();
if(t==1) {
int rnk2=rnk(root,a[s]),rnk1=rnk2+1;
int s2=find(root,rnk1);
s2=mapp[s2];
erase(root,a[s]);
erase(root,a[s2]);
swap(a[s],a[s2]);
mapp[a[s]]=s;
mapp[a[s2]]=s2;
insert(root,a[s]);
insert(root,a[s2]);
} else if(t==-1) {
int rnk2=rnk(root,a[s]),rnk1=rnk2-1;
int s2=find(root,rnk1);
s2=mapp[s2];
erase(root,a[s]);
erase(root,a[s2]);
swap(a[s],a[s2]);
mapp[a[s]]=s;
mapp[a[s2]]=s2;
insert(root,a[s]);
insert(root,a[s2]);
}
}
}
return 0;
}

结果这个吊打了splay ,和fhq-treap差不了多少

而且似乎比同种思路的treap快了300ms左右

总结

WBLT有着显著的优缺点

优点是快( \(\text{O}(n\log n)\) 常数较小) 好记 码量小 且能实现很多功能

缺点是内存空间大,尽管可以用垃圾回收补偿,但是仍然需要两倍的空间

备注:本文实现方法是单旋,没法证复杂度同时也难以卡掉。2018集训队论文里介绍了双旋的实现,是复杂度正确的。