平衡树(7.10讲课)

浅谈平衡树相关

前言

先来看一些操作:

P3369 【模板】普通平衡树

您需要动态地维护一个可重集合 \(M\),并且提供以下操作:

  1. \(M\) 中插入一个数 \(x\)
  2. \(M\) 中删除一个数 \(x\)。(若有多个相同的数,应只删除一个)
  3. 查询 \(M\) 中有多少个数比 \(x\) 小,并且将得到的答案加 \(1\)
  4. 查询如果将 \(M\) 从小到大排列后,排名位于第 \(x\) 位的数。
  5. 查询 \(M\)\(x\) 的前驱(定义为 \(M\) 中小于 \(x\),且最大的数)。
  6. 查询 \(M\)\(x\) 的后继(定义为 \(M\) 中大于 \(x\),且最小的数)。

怎么做?鉴于大家学过线段树和分块,有一些简单方法:

  1. 权值线段树/树状数组:优点是好写并且是单 \(\log\),但是如果遇到值域很大的则需要动态开点(或者离线)。
  2. 值域分块:优点是好写,但是值域过大则需要离线,复杂度是单根的,通常配合莫队使用。
  3. 01trie:没什么人写,在维护前缀异或最大值非常的有用。

这时候有的人要问了,那要平衡树有什么好的?

确实上面的操作有更好写的方法,但是显然他们有局限性,例如 P3391 【模板】文艺平衡树

一般平衡树有这么几种:

主流的几个:

  1. 有旋 Treap:优点就是代码量小,速度较快,可以可持久化,但是不能分裂合并,导致操作有局限性。

  2. 无旋 Treap aka FHQ-Treap:优点代码量小,速度比较慢,可以分裂合并可持久化,功能相当完备。

  3. Splay:代码量不小,速度也比较慢,可以分裂合并不能可持久化。

  4. WBLT aka RRFGT:效率较高,代码量最小,可以分裂合并,可持久化。

剩下的 AVL,B树,RBT,替罪羊树好像出现的都比较少。但是替罪羊树比较好写而且可以维护 KDT 虽然复杂度是假的。AVL 除非你写 ynoi 可能需要用,但是联赛不涉及。其实块状链表也可以实现上面的这些东西不过是单根的。

我们今天讲的主要是 FHQ-Treap 和 Splay,关于为什么讲他们俩呢?因为 FHQ-Treap 功能齐全,可以分裂合并可持久化,Splay 的话后面学 Link-Cut-Tree 需要用到。其他的可以自学。

说句题外话 FHQ-Treap 可以维护 Link-Cut-Tree 但是是二老哥的(常数较小),Splay 是一老哥的(常数较大)。如果说你动态树写 SATT 你也需要 Splay。

好的进入正题:

前置知识:

BST(二叉搜索树):满足左儿子的值小于根小于右儿子的值的二叉数。

Heap(堆):这个大家应该都知道比如我们平常用的 priority_queue,以及斐波那契堆等等。在这里指的是二叉堆。大根堆就是根节点的 \(val\) \(>\) 左子树的任意 \(val\) 且根节点的 \(val\) \(>\) 右子树的任意 \(val\) ,小根堆反之。

FHQ-Treap

Treap(笛卡尔树) = BST + Heap

接下来介绍一下 Treap 的结构:

Treap 的结点除了被维护的 权值 之外,还附加了一个随机的 优先级。其中,权值满足二叉搜索树性质,优先级满足堆性质(小根堆或大根堆)。

如图:

这样一看你的二叉搜索树加上优先级权值是不是让他变得“平衡”一些了?由于有 BST 的性质限制,又有了 Heap 的性质限制后我们就发现这棵树的结构是一定的,这个大家可以自己想想其实不是很难。

由于优先级是随机的,所以可以证得最终的树高是 \(O(\log n)\) 级别的。由于证明比较困难,大家可以感性理解一下。

有了 \(O(\log n)\) 的树高,我们就可以肆意妄为的在树上做操作了。

FHQ 的最重要的两个操作就是分裂和合并了,这里重点讲一下。

首先我们需要弄清楚一件事,对于大部分平衡树都是依靠旋转来维护自己的深度平衡,但是 FHQ 就不一样了,由于前面提到的他的结构一定的性质,所以只要我们每次保证他的结构符合 BST + Heap 的结构那么他的复杂度就是对的。

观察 treap 的结构,我们可以注意到一颗 treap 相当于一个区间,那么比如说一个 \(val\) 序列为 \((l,r)\),你想要将 \(val\) 小于等于 queryval 的所有节点和大于 \(queryval\) 的所有节点分开,就相当于将 \(val\) 的区间 \((l,r)\) 拆成两个区间 \((l,queryval)\)\((queryval+1,r)\) ,就等同于将一棵大 Treap 按照 val 分成两棵小Treap,这就是分裂操作。

好的看一个例子:

我们将 \((l,queryval)\) 称为“左区间树”,将 \((queryval+1,r)\) 称为“右区间树”。

\(queryval=5\) 时,分裂的过程如图,图中的蓝色是已经归入左区间树的点,橙色是已经归入右区间树的点,左右区间树中的实圆是已经确定的节点,虚圆是尚未确定的节点。

我们首先建立两个虚拟节点,就是图中的虚圆,如果当前访问的节点 \(now\)\(val \leq queryval\) ,那么 \(now\)\(now\) 的左子树,都应归在左区间树里,就是将左区间树上一个虚拟节点赋为 \(now\) ,此节点便由虚变实,然后对 \(now\) 建立虚拟的右儿子。否则,\(now\)\(now\) 的右子树应该归在右区间树里,就是将上一个右区间树的虚拟节点赋为 \(now\) ,此节点便由虚变实,然后对 \(now\) 建立虚拟的左儿子。如果 \(now\) 是空节点,则将左右区间树的虚拟节点赋为 \(0\)

为什么这样分裂是正确的?当我们对左区间树不断建立虚拟的右儿子时,而 \(now\)\(val\) 都是单调不减的,所以左区间树满足二叉搜索树的性质,当我们对右区间树不断建立虚拟的左儿子时,而 \(now\)\(val\) 都是单调不增的,所以右区间树满足二叉搜索树的性质。而这样建树又不会将祖先与后代的关系交换,即便祖父节点变成了父亲节点,它仍是祖先节点,所以左右区间树满足堆的性质。

友情声明:由于准备时间紧迫,我直接应用了原文的这一段,这一段讲的非常详细,希望大家谅解qwq。

此时我们可以写出 Split 的代码:

void Split(int now , int v, int &x, int &y){
	if(!now) return x = y = 0 , void(); // 如果当前节点为空,我们把虚拟节点设成 0
	if(val[now] <= v) x = now , Split(rs[now], v, rs[now], y); // 当前节点权值小于分裂的权值,左子树全归到左区间树,左区间树建立右边的虚拟节点,然后往右子树递归
	else y = now, Split(ls[now], v, x, ls[now]); // 与上面相同
	PushUp(now);
}

类似的,我们是不是可以思考一下如何 Merge

首先在这里提醒,我们做分裂的时候是将区间 \((l, r)\) 分为 \((l, queryval)\)\((queryval + 1, r)\) 的两个子区间树,仍然满足值域不交,且左子树的权值是要小于右子树的权值的。鉴于大家刚刚接触,我最后再讲平衡树有交并。

由 Treap 的性质可以得,对于左区间树 \(x\) 和右区间树 \(y\) 只有可能 \(x\)\(y\) 的左儿子或者 \(x\)\(y\) 的右儿子,究竟是哪一种我们就要看他的的优先值了。

int Merge(int u, int v){
  if(!u or !v)return u | v; 
  if(rnd[u] < rnd[v]){ // 左边的优先级高,拿左边的右儿子和 $v$ 合并
    rs[u] = Merge(rs[u], v);
    PushUp(u);
    return u;
  }
  else{ // 同理,拿右边的左儿子和 $u$ 合并
    ls[v] = Merge(u, ls[v]); 
    PushUp(v);
    return v;
  }
}

学会了 SplitMerge 操作我们就完成一半了呼呼。

我们可以用 PushUp 维护子树 siz,从而进行后面的操作,这里的 PushUp 和线段树的类似,但是线段树是 leafy,但FHQ 不是,所以注意更新本节点的答案。

来讲几个基本操作:

  1. 新建一个节点:
int New(int v){
	tot++ , val[tot] = v , rnd[tot] = rd() , siz[tot] = 1;
	return tot ;
}
  1. 插入一个节点:

由于前面提到的要维护 BST 的性质,我们将树拆分成小于当前值 \(v\) 的和大于他的,新建一个节点,然后直接把他们合并即可。

void Insert(int v){
	int x , y;
	Split(root, v, x, y);
	root = Merge(Merge(x, New(v)), y);
}
  1. 删除一个节点:

和上面差不多意思,不再赘述。

void Delete(int v){
	int x , y , z ; 
	Split(root , v , x , z);
	Split(x , v - 1 , x , y);
	y = Merge(ls[y] , rs[y]);
	root = Merge(Merge(x , y) , z);
}
  1. 查询第 \(k\) 大:

我们在树上搜索,如果左儿子的个数比 \(k\) 小,令 \(k \gets k - siz_{ls_u} - 1\),递归进入右子树。否则递归进入左子树。当 \(k=siz_{ls_u}+1\) 的时候就找到这个值了。比较显然。

int Find_Kth(int u, int k){
  int cnt = 0; 
  while(1){
    if(k <= siz[ls[u]]) u = ls[u];
    else if(k == siz[ls[u]] + 1) return u;
    else k -= (siz[ls[u]] + 1), u = rs[u];
  }
}
  1. 查询前驱后继:

这里以前驱为例,我们将树分成小于 \(v\) 和大于等于 \(v\) 的,左半部分树中最大的就是前驱了,后继也是一样,不再叙述。

int Find_Prev(int v){
  int x, y ; 
  Split(root, v - 1, x, y);
  int tmp = val[Find_Kth(x, siz[x])];
  root = Merge(x, y);
  return tmp;
}
int Find_Next(int v){
  int x, y;
  Split(root, v, x, y);
  int tmp = val[Find_Kth(y, 1)];
  root = Merge(x, y);
  return tmp;
}

这里给出 FHQ 的完整代码:

namespace FHQ{
	mt19937 rd(123213);
	int val[N] , siz[N] , rnd[N] , tot , ls[N] ,rs[N];
	void PushUp(int id){
		siz[id] = siz[ls[id]] + siz[rs[id]] + 1;
	}
	void Split(int now , int v, int &x, int &y){
		if(!now)return x = y = 0 , void();
		if(val[now] <= v)x = now , Split(rs[now] , v, rs[now] , y);
		else y = now, Split(ls[now] , v , x , ls[now]); 
		PushUp(now);
	}
	int Merge(int u, int v){
		if(!u or !v)return u | v;
		if(rnd[u] < rnd[v]){
			rs[u] = Merge(rs[u] , v);
			PushUp(u);
			return u;
		}
		else{
			ls[v] = Merge(u , ls[v]);
			PushUp(v);
			return v;
		}
	}
	int New(int v){
		tot++ , val[tot] = v , rnd[tot] = rd() , siz[tot] = 1;
		return tot ;
	}
	void Insert(int a){
		int x , y ;
		Split(root , a , x , y);
		root = Merge(Merge(x , New(a)) , y);
	}
	int Find_Kth(int u , int k){
		int cnt = 0 ; 
		while(1){
			// ++cnt ; 
			if(k <= siz[ls[u]])u = ls[u];
			else if(k == siz[ls[u]] + 1)return u;
			else k -= (siz[ls[u]] + 1) , u = rs[u];
		}
	}
	int Find_Siz(int v){
		int x , y;
		Split(root , v - 1 , x , y);
		int k = siz[x] + 1 ;
		root = Merge(x , y);
		return k ;
	}
	int Find_Prev(int v){
		int x , y ; 
		Split(root , v - 1 , x , y);
		int tmp = val[Find_Kth(x , siz[x])];
		root = Merge(x , y);
		return tmp;
	}
	int Find_Next(int v){
		int x , y;
		Split(root , v , x, y);
		int tmp = val[Find_Kth(y , 1)];
		root = Merge(x , y);
		return tmp;
	}
	void Delete(int v){
		int x , y , z ; 
		Split(root , v , x , z);
		Split(x , v - 1 , x , y);
		y = Merge(ls[y] , rs[y]);
		root = Merge(Merge(x , y) , z);
	}
};

题外话:有喜欢科技的可以了解一下压位trie,可以做到 \(O(log_wn)\) 的插入删除以及查询前驱后继,可以暴力草很多题。如果你有兴趣你也可以看一看 van Emde Boas 树还有小波矩阵一类的东西。

基础的就大概讲完了,接下来说一些进阶的东西,前提是你确定你已经理解了 Treap 的结构。

维护区间修改标记:FHQ 可以像线段树一样维护区间修改的标记,比如区间翻转、区间加等等。需要及时注意标记的下放和标记的顺序:在每次树的结构需要修改的时候都需要下放标记

关于有交并这件事,在此题中已有记载,具体方法如下:

使用平衡树有交合并 \(x\)\(y\),具体如下:

  1. 先找 \(val\) 值较小的为根(设是 \(x\))。

  2. 然后将 \(y\) 按照 \(x\) 的权值裂开为 \(l,r\)

  3. 最后将 \(x\) 的左儿子与 \(l\) 合并,\(x\) 的右儿子与 \(r\) 合并。

  4. 注意若 \(y\) 中存在与 \(x\) 的权值相同的数,故使用并查集将其并在一起,然后在平衡树中删掉,不然无法保证复杂度。

关于最后一步如果删掉了复杂度是 \(O(n\log n)\) 但是如果没删的话会退化成 \(O(n \log^2 n)\),因为无法保证 Treap 的性质,lxl会卡。

区间复制,此题 的弱化版保证数据随机是可以 FHQ 通过的,但是区间复制 FHQ 复杂度无法保证,大家谨慎使用。

垃圾回收:把删除掉的无用的节点在下次新建节点的时候优先使用。具体实现方法为在删除节点的时候将删除的节点一个个放入队列里,在新建节点的时候判断队列是否为空,如果不为空就从队头取一个节点清空原有的所有信息并新建,否则就按正常情况新建节点。此种操作可以节省空间。

接下来说一说习题。

习题

P3391 【模板】文艺平衡树

你会发现如果按照权值分裂翻转的话不会满足 BST 的性质,怎么办呢?维护相对位置,按 siz 分裂即可,然后给打上标记。

P1486 [NOI2004] 郁闷的出纳员

Treap 显然非常好做,这里提一下 __gnu_pbds 这个东西,有的时候可以代替手写 Treap,大家可以学一学。

二逼平衡树

现在叫树套树,写法非常多,可以线段树套平衡树,大家自己发明一下,注意空间。

还有就是上面提到的题目,较难的这里不做讲述,可以去做ynoi。

posted @ 2026-07-08 14:28  Super_lollipop  阅读(102)  评论(9)    收藏  举报