0%

平衡树总结

二叉搜索树(Binary Search Tree)是一种二叉树数据结构,其维护了一个支持快速检索的集合。 BST 有很多,如 Treap,Splay,红黑树,Size-Balanced Tree,但是此处我们只讨论 Treap。

Treap

Treap 的节点具有一个 \(val\) 值和 \(key\) 值,\(val\) 值维护这个集合,\(key\) 值维护了 Treap 的平衡。光看 \(val\) 值,Treap 的中序遍历构成一个递增序列;光看 \(key\) 值,Treap 是一个(方便起见,我们默认为小根堆)。其通过旋转来维护形态,影响其形态的因素之一是 \(key\)。因此 \(key\) 一般用随机值,可以证明这样的 Treap 期望高度为 \(\mathrm{O}( \log n )\)

基本操作

Treap 支持 5 种基本操作:

  • 插入 \(\mathrm{O}( \log n )\)
  • 删除 \(\mathrm{O}( \log n )\)
  • 询问某数排名 \(\mathrm{O}( \log n )\)
  • 询问排名为 k 的数 \(\mathrm{O}( \log n )\)
  • 查找某数及其前驱后继 \(\mathrm{O}( \log n )\)

为了方便基本操作,我们定义 Treap 节点如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Node{
int v,r,s,n;
Node *ch[2];

Node(int _v,int _r=rand()){
v=_v;r=_r;n=1;
ch[0]=ch[1]=NULL;
}

inline int Cmp(int x){
if(x==v)return -1;
else return x>v;
}

inline int Size(){
return this==NULL?0:s;
}

inline void Maintain(){
s=n+ch[0]->Size()+ch[1]->Size();
}
};

定义 Treap 如下:

1
2
3
Treap{
Node *rt=NULL;
};

旋转

画个图就不难想象这个过程。o 节点必须用引用而不是指针,因为最后的过程需要把 o 指向 t 所在节点,而非单纯复制 t 节点的信息。

1
2
3
4
5
6
7
void Rotate(Node *&o,int d){
Node *t=o->ch[d^1];
o->ch[d^1]=t->ch[d];
t->ch[d]=o;
o->Maintain();t->Maintain();
o=t;
}

插入

根据 Treap 的中序遍历性质,递归节点找到对应位置插入即可。新节点可能会破坏 Treap 的堆性质,因此需要旋转。如果该节点已被创建,则增加数量。注意需要维护

1
2
3
4
5
6
7
8
9
10
11
12
void Insert(Node *&o,int v){
if(o==NULL)o=new Node(v);
else{
int d=o->Cmp(v);
if(d==-1)(o->n)++;
else{
Insert(o->ch[d],v);
if(o->ch[d]->r < o->r)Rotate(o,d^1);
}
}
o->Maintain();
}

删除

根据 Treap 的中序遍历性质,递归节点找到对应位置删除即可。同样为了维护堆性质,如果删除之后会破坏,则把要删除的节点旋转到儿子节点(此时两个儿子较小的一个被旋转到当前位置)再递归删除即可。如果非空则需要维护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void Delete(Node *&o,int v){
int d=o->Cmp(v);
if(d==-1){
if(o->n>1)(o->n)--;
else{
if(o->ch[0]==NULL)o=o->ch[1];
else if(o->ch[1]==NULL)o=o->ch[0];
else{
int d2=o->ch[0]->r < o->ch[1]->r;
Rotate(o,d2);
Delete(o->ch[d2],v);
}
}
}else Delete(o->ch[d],v);
if(o!=NULL)o->Maintain();
}

询问某数排名

根据中序遍历性质,这是一个很容易思考的递归过程。

1
2
3
4
5
6
7
int Rank(Node *o,int v){
if(o==NULL)return 0;
int d=o->Cmp(v),l=o->ch[1]->Size();
if(d==0)return Rank(o->ch[0],v);
else if(d==-1)return l;
else return l+o->n+Rank(o->ch[1],v);
}

注意上面的代码返回值是小于某数的个数而非该数的排名,这是为了方便查找不存在于集合的数。如果要变成集合中的数需要对排名 \(+ 1\)

询问排名为 k 的数

同样是递归过程。

1
2
3
4
5
6
7
int Kth(Node *o,int k){
if(k>o->s)return -1;
int l=o->ch[0]->Size(),n=o->n;
if(k<=l)return Kth(o->ch[0],k);
else if(k<=l+n)return o->v;
else return Kth(o->ch[1],k-l-n);
}

查找

不难实现。$d = 0 $ 时寻找前驱,否则寻找后继。注意写的时候要特别注意寻找前驱时是向大数移动更新,寻找后继的时候是向小数移动更新。

1
2
3
4
5
6
7
8
int Find(int v,int d){
Node *o=rt;
int ret=d?INF:-INF;
while(o!=NULL)
if(d?v<o->v:v>o->v)ret=o->v,o=o->ch[d^1];
else o=o->ch[d];
return ret;
}

扩展操作

  • 启发式合并两棵 Treap\(\mathrm{O}( n \log ( m / n ) )\)

所谓启发式合并,就是把较小的 Treap 节点一个个暴力拆掉插入到较大的 Treap 中。如果较小的有 \(n\) 个节点,较大的有 \(m\) 个节点,则可以证明时间复杂度为 \(\mathrm{O}( n \log ( m / n ) )\)。如果是相对有序,则可以用非旋转式 Treap 维护。相对有序是指较小 Treap 的最大值小于较大 Treap 的最小值,这样只需要首尾相接。

非旋转式 Treap

非旋转 Treap 拥有 Treap 的中序遍历相对有序特性与堆特性,但是不能旋转。由于非旋转式 Treap 不需要旋转,因此能较好地维护节点的父子关系,也可以做到可持久化

基本操作

  • 建树 \(\mathrm{O}( n )\)
  • 合并 \(\mathrm{O}( \log n )\)
  • 分裂 \(\mathrm{O}( \log n )\)

虽然基本操作少,但是一起用却可以做很多事情。

构建

Treap 与笛卡尔树是一样的,因此我们可以以笛卡尔树的线性建树方法建 Treap。

考虑一个已经相对有序的序列(可以是一个递增 / 递减数列,也可以是一段固定的字符串,Treap 的中序遍历维护的正是这些),对于节点 $i (val , key) $,有:

  1. 倒序寻找第一个 \(j < i\) 使得 \(key_j < key_i\)
  2. \(i\) 的左子树设为 \(j\),将 \(j - 1\) 的右子树设为 \(i\)

这可以用栈维护。每个节点只会进出栈一次,因此时间复杂度是线性的。可以这么考虑:栈维护了当前的树的最右链,我们正打算插入节点 \(i\)。但是插入节点 \(i\) 之后可能会破坏堆性质,需要手动 \(key\) 值大于 \(i\) \(key\) 值的树挂在 \(i\) 的左子树来维持小根堆形态,同时又不破坏中序遍历。最后需要把摘下来的子树的父亲节点重设右子树为 \(i\)。实际中为了方便,会添加一个 \(val\) \(key\) 都为 \(\infty\) 的虚拟节点来方便建树。

注意每个节点在出栈之后都不会再被修改儿子,因此我们需要在出栈之后维护该节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void Build(int n){
stack<Node*> s;
rt=new Node(-INF,-INF);
s.push(rt);

for(int i=1;i<=n;i++){
Node *o=new Node(i);
while(s.top()->r > o->r){
o->ch[0]=s.top();
s.top()->Maintain();
s.pop();
}
s.top()->ch[1]=o;
s.push(o);
}
while(!s.empty()){
s.top()->Maintain();
s.pop();
}
rt=rt->ch[1];
}

合并

类似于斜堆,非旋转式 Treap 的合并是一个递归过程。合并的时候需要保证两个 Treap 已经相对有序,只需要中序遍历首尾相接。但是由于不可旋转,因此它不能像斜堆合并一样转来转去,需要特判。流程如下:

  1. 两棵树有一棵为空,则当前新根为另一棵非空子树
  2. 否则如果 \(val _a < val _b\),合并 \(a\) 的右子树和 \(b\),并作为 \(a\) 的新右子树,当前新根为 \(a\)
  3. 否则合并 \(a\) \(b\) 的左子树,并作为 \(b\) 的新左子树,当前新根为 \(b\)

注意合并的顺序。如步骤 2 不能把 \(b\) 的左子树与 \(a\) 合并,这样没有保证合并的两个 Treap 是有序的。合并的过程仍要保持相对有序。同时修改了儿子的节点需要维护

1
2
3
4
5
6
7
8
9
10
11
12
13
Node* Merge(Node *a,Node *b){
if(a==NULL)return b;
if(b==NULL)return a;
if(a->r < b->r){
a->ch[1]=Merge(a->ch[1],b);
a->Maintain();
return a;
}else{
b->ch[0]=Merge(a,b->ch[0]);
b->Maintain();
return b;
}
}

分裂

分裂过程仍然是递归,需要返回两个分裂的节点。具体的过程可以画图模拟加深理解。分裂过程让我们把一个区间分成两个,实现了对序列中特定区间的操作,则像是区间翻转之类的操作也可以实现了。算法步骤如下:

  1. 如果该节点为空节点则返回一对空节点
  2. 否则如果分裂的位置在左子树,递归分裂左子树,并将当前根节点左儿子设为分裂的第二个节点,返回这对节点
  3. 否则递归分裂右子树,并将当前根节点右儿子设为分裂的第一个节点,返回这对节点

修改了儿子节点的节点需要维护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pNode Split(Node *o,int k){
pNode ret(NULL,NULL);
if(o==NULL)return ret;

int l=o->ch[0]->Size();
if(k<=l){
ret=Split(o->ch[0],k);
o->ch[0]=ret.second;
o->Maintain();
ret.second=o;
}else{
ret=Split(o->ch[1],k-l-1);
o->ch[1]=ret.first;
o->Maintain();
ret.first=o;
}
return ret;
}

扩展操作

  • 插入 (Split+Merge )
  • 删除 ( Split +Split +Merge)
  • 查询节点 / 区间 ( Split+Split+ Merge )
  • 查询第 k 大 ( Split +Split+Merge)
  • 区间标记 ( Split+Split+Lazy +Merge)
  • ……

所有的操作都可以把操作区间拆除来再做。不过我们重点考虑区间标记这个操作。

我们把 Treap 当成一个二叉搜索树,却忘记它也是一棵树,也可以进行树的操作。联想一下同为二叉树的线段树,我们也可以把 Treap 当线段树使用。不过更多的,Treap 用来维护与区间翻转有关的序列更多,因为线段树不支持这样的操作。另外,查询区间和、查询最大值这些信息只需要存在节点并一同维护即可,必要的时候可以用 Lazy 标记并 Pushdown

可持久化 Treap

来自非旋转 Treap。其 MergeSplit 都是自上而下的递归操作,并且父子关系不会中途改变,因此我们可以实现可持久化。每次 Split 与 Merge 的时候,都对该节点复制一个新节点,则对这些新节点的操作不影响历史节点。

这里有一个空间优化。对于一般的先 SplitMerge 操作,Merge 影响到的节点是 Split 创建的新节点与将要合并的新节点。这两个节点有个特征,就是它们不会影响其历史版本,因此我们可以直接在 Merge 过程覆盖掉它们,而不需要再为新节点创造新节点。

我认为是,Split 创造的新节点是分裂处的节点,而分裂处一定是中序遍历一段连续的位置,Merge 操作是对结尾一段连续的位置进行修改,因此 Merge 操作的节点是 Split 新开的节点。(但是这个说法我也不能说服我自己。)

虽然不算难构造,但还是放上代码参考。对于新建的节点的时机,我的做法是决定了要处理的节点后,立刻对其进行复制并处理新节点,而不是等到下一层递归。这样会容易考虑和维护。

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
Node* Merge(Node *a,Node *b){
if(a==NULL)return new Node(b);
if(b==NULL)return new Node(a);
Node *t=new Node();
if(a->r < b->r){
*t=*a;
t->ch[1]=Merge(t->ch[1],b);
}else{
*t=*b;
t->ch[0]=Merge(a,t->ch[0]);
}
t->Maintain();
return t;
}

pNode Split(Node *o,int k){
pNode ret(NULL,NULL);
if(o==NULL)return ret;

int l=o->ch[0]->Size();
Node *t=new Node();*t=*o;
if(k<=l){
ret=Split(t->ch[0],k);
t->ch[0]=ret.second;
t->Maintain();
ret.second=t;
}else{
ret=Split(t->ch[1],k-l-1);
t->ch[1]=ret.first;
t->Maintain();
ret.first=t;
}
return ret;
}

练习

基础

BZOJ3224 普通平衡树

基础平衡树操作。

NOI2004 郁闷的出纳员

基础平衡树操作。注意一开始没有加入的员工是不计算在内的。

HNOI2002 营业额统计

平衡树维护前驱后继。

HNOI2004 宠物收养所

同一时刻收养所只有人和宠物表示要么人等宠物,要么宠物等人。标记表示现在收养所里是宠物还是人,维护平衡树即可。

进阶

SDOI2008 郁闷的小 J

一个比较直观的在线做法是线段树套平衡树,每个节点放一棵平衡树,区间统计出现次数。当然也有树状数组的离线做法,按照查询书的种类排序后按类处理。如果数据小可以莫队。

ZJOI2007 报表统计

MIN_GAP 就是维护相邻元素差绝对值的最小值,用一个小根堆维护值即可。注意如果添加了新元素,则与前一个元素相关的差值要删除,并添加两对新差值。MIN_SORT_GAP 平衡树维护前驱后继。

HNOI2012 永无乡

Treap 的启发式合并,并查集维护。

BZOJ3223 文艺平衡树

Splay 当然可以,不过非旋转式 Treap 也可以。

BZOJ3196 二逼平衡树

线段树套平衡树。Kth 查询需要二分判定答案,再判定 Rank 是否满足。

NOI2003 文本编辑器

需要区间操作的 Splay 或非旋转式 Treap。注意一次插入的文本可能会很大。

AHOI2006 文本编辑器

NOI2003 文本编辑器,但增加了 Rotate 操作。

BSOJ4531 可持久化平衡树

就是可持久化平衡树。

较难

NOI2005 维护数列

需要很多线段树的操作。注意细节。(但我还没写 Orz)

总结

  • Treap 可以维护集合内部的排名,但是非相对有序时不能高效合并
  • 非旋转 Treap 能在相对有序时高效合并与分裂,可以提取区间单独操作。
  • 非旋转 Treap 每一个子树的中序遍历维护一个相对有序的序列,可以实现区间旋转,也可以像线段树一样维护一段序列的信息
  • 非旋转 Treap 父子形态稳定而不需要旋转,且操作自上而下,可以实现可持久化
  • 多区间查询 Kth 时,通常采用二分答案判定 Rank。