每一种平衡树会分 P 讲。
0.定义
平衡树,说白了它还是个 BST,只不过朴素 BST 的所有操作复杂度是 的,可以构造数据卡成 。
这个时候,就可以用各种神奇方法来维护这个 BST 的平衡,使其树高为 。
Treap 这种平衡树用的是数学期望堆的性质,一个点,除了自己的值外,我们再维护一个随机权值,然后让值满足 BST 的性质,权值满足堆的性质。
一般平衡树维护平衡的方式是通过旋转(替罪羊树除外,那玩意是暴力美学),而 FHQ Treap 就不用,它用分裂 + 合并。
但是,能不写平衡树的题尽量不要写平衡树,因为常数会大的飞起来。
权值树状数组跑 260ms 的,平衡树能跑 672ms。
1.实现
我们维护以下信息:
struct Node{ int ls,rs, // 左右儿子 val, // 值 rank, // 随机权值 size; // 子树大小}tree[MAXN];int root,cnt; // 根结点,结点个数(指针式不需要)#define lson(rt) tree[rt].ls#define rson(rt) tree[rt].rs新建结点
我觉得没什么好讲的。
random_device seed;mt19937 rnd(seed());// 上面是随机数生成器inline int newNode(int val) // 新建结点,并返回其编号{ ++cnt; lson(cnt) = rson(cnt) = 0, tree[cnt].val = val, tree[cnt].rank = rnd(), tree[cnt].size = 1; // 自己大小为 1 return cnt;}统计子树大小
也没什么好讲的,就是注意一下自己这个结点也算进该子树。
inline void pushup(int rt){ tree[rt].size = tree[lson(rt)].size + tree[rson(rt)].size + 1;}合并
我们传入两个值:L,R,表示待合并的左右子树的根结点,返回一个值:合并完成后的根结点。
因为这两个树都是已经平衡的 FHQ Treap,所以我们只用考虑谁在上,谁在下。
如果某一个结点为 ,说明已经合并完成了,直接返回另一个结点。
否则,我们比较两个点的随机权值(不用特别区分大小,因为随机权值没有利用价值),然后递归向下合并。
若左子树在上,就合并根结点的右儿子和右根结点,否则合并右根结点的左儿子和左根结点。
合并完成后,更新子树大小。
只要看懂了就很好写出代码。
inline int merge(int L,int R){ if(!L || !R) return L | R; if(tree[L].rank < tree[R].rank) { rson(L) = merge(rson(L),R); pushup(L); return L; } else { lson(R) = merge(L,lson(R)); pushup(R); return R; }}分裂
这个分两种:按值分裂和按排名分裂(序列平衡树里面用)
按值分裂
将一棵 FHQ Treap 按值分裂,小于等于这个值的分到左边,大于的分到右边
回顾一下 FHQ Treap 的形态,值满足 BST,权值满足堆。
也就是说,一个结点的左儿子的值小于自己的值,右儿子的值大于自己的值。
因此,我们对于每一个结点,与目标值比较,如果小于,更新左树的根为此结点,前往右子树接着分裂;反之,就前往左子树。
代码如下:
inline void splitVal(int rt,int val,int &L,int &R) // 注意,要更新,所以是引用{ if(!rt) { L = R = 0; return ; } if(tree[rt].val <= val) { L = rt; splitVal(rson(rt),val,rson(rt),R); } else { R = rt; splitVal(lson(rt),val,L,lson(rt)); } pushup(rt); //记得更新这个结点的信息}按排名分裂
还是类似,不过这次比较的是目标排名和左子树的大小加一。
如果左子树的大小小于排名,则应该前往右子树,同时排名也需要更新(可以理解为已经考虑了自己和左子树)。反之,就前往左子树。
代码如下:
inline void splitRank(int rt,int rk,int &L,int &R){ if(!rt) { L = R = 0; return ; } if(tree[lson(rt)].size < rk) { L = rt; splitRank(rson(rt),rk - tree[lson(rt)].size - 1,rson(rt),R); } else { R = rt; splitRank(lson(rt),rk,L,lson(rt)); } pushup(rt);}最重要的两个操作就讲完了。
接下来我们分普通平衡树(值域树)和文艺平衡树(序列树)
普通平衡树
插入
由于 merge 只是合并起来,不能自动排序,所以我们需要在 处按值分裂,然后新建一个结点,与分裂出的两棵树合并。
代码如下:
inline void insert(int val){ int x,y; splitVal(root,val - 1,x,y); root = merge(merge(x,newNode(val)),y);}删除
我们可以按照值,把树分裂为三段:,然后把 和 合并。
如果只删一个,就合并中间那段的根结点的左右儿子,然后处理掉根结点。
代码如下:
inline void remove(int val){ int x,y,z; splitVal(root,val - 1,x,y); splitVal(y,val,y,z); pool[++top] = y; // 扔进垃圾桶 y = merge(lson(y),rson(y)) root = merge(merge(x,y),z);}求 K 小值
类似按排名分裂,如果左子树大小加一与 相等,则直接输出该结点的值;如果大于,则前往左儿子,否则前往右儿子。
可以写迭代,会快一点:
inline int queryKth(int k){ int rt = root; while(rt) { if(tree[lson(rt)].size + 1 == k) break; else if(tree[lson(rt)].size >= k) rt = lson(rt); else { k -= tree[lson(rt)].size + 1; rt = rson(rt); } } return tree[rt].val;}求排名
可以直接分裂,左树大小加一就是排名:
inline int queryRank(int val){ int x,y; splitVal(root,val - 1,x,y); int res = tree[x].size + 1; root = merge(x,y); return res;}求前驱
我们按照值分出两棵树,然后找到左树的右链底,就是该值的前驱。
解释一下,由于 BST 的性质,一个结点的右儿子一定大于该结点的值,所以右链链底一定是该树中最大的值。
为什么不用查排名配合 K 小值呢?因为这样常数会小点。
inline int queryPrev(int val){ int x,y,rt; splitVal(root,val - 1,x,y); rt = x; while(rson(rt)) rt = rson(rt); root = merge(x,y); return tree[rt].val;}如何判断无解?没有左树就无解。
求后继
类似,分裂以后右树左链链底就是答案。
inline int queryNext(int val){ int x,y,rt; splitVal(root,val,x,y); rt = y; while(lson(rt)) rt = lson(rt); root = merge(x,y); return tree[rt].val;}快速建树
我们把每一个值直接插入树的建树方法有的时候还是太慢了。这个时候我们就可以用笛卡尔树的建树法。先排序,然后从中点处分开,对左右区间分别建树后合并就好了。
这个是通用的,只不过权值树需要排序。
inline int build(int l,int r){ if(l == r) return newNode(a[l]); int mid = (l + r) >> 1,rt = newNode(a[mid]); lson(rt) = build(l,mid - 1),rson(rt) = build(mid + 1,r); pushup(rt); return rt;}文艺平衡树
前面说了,FHQ Treap 的值满足 BST 的性质,于是我们可以将下标看作值,值额外记录。这样我们就可以让 FHQ Treap 支持区间操作。
这里是需要用按排名分裂的。
当我们想操作区间 时,我们可以将整个 FHQ Treap 分裂成三段: ,然后对中间那段进行操作,操作完了直接合并回去就好了。
但是,中间那个区间可能奇长无比,可以卡到你 T 飞,所以,我们可以运用线段树的懒标记思想。
其实就是每次操作以后,在这个结点处打个标记,然后分裂和合并这种需要访问子结点的操作,就把标记下放。
类似下面:
inline void pushdown(int rt){ if(tree[rt].haveTag) { if(lson(rt)) //防止给空结点赋一些奇奇怪怪的值 update(lson(rt)); if(rson(rt)) update(rson(rt)); tree[rt].clearTag(); }}其实这种懒标记思想是通用的,值域树也可以打懒标记。
但是要注意的是,FHQ Treap 是 Nody Tree,一定要注意这个结点也需要更新。
所以,你可以把 Seg Beats 那一堆全部搬过来让你的代码长度更上一层楼。
2.常用优化技巧
因为平衡树的时空常数都大的离谱,所以卡常就很重要。
空间优化
对于删除的结点,我们可以重复利用空间。
就是在删除结点的时候,我们记录该结点的编号,压到一个栈里。在 newNode 的时候,优先使用栈里面的结点,栈空了才申请额外空间。
时间优化
最常用的就是上面的快速建树法。
由于 FHQ Treap 和 Splay 的常数都大的飞起,所以只用值域树的题可以用 STL/PBDS 封装好的红黑树,区间树可以换成 WBLT。
3.可持久化
由于 FHQ Treap 的分裂与合并都不会调整祖先关系,所以可以很方便的可持久化。
分裂的时候,把涉及到的结点全部复制一遍,合并的时候也是,也就比不可持久化的多了两行。
inline int copyNode(int o){ ++cnt; lson(cnt) = lson(o),rson(cnt) = rson(o); tree[cnt].rank = rnd(); tree[cnt].val = tree[o].val; tree[cnt].size = tree[o].size; return cnt;}inline int merge(int L,int R){ if(!L || !R) return L | R; if(tree[L].rank < tree[R].rank) { int rt = copyNode(L); rson(rt) = merge(rson(rt),R); pushup(rt); return rt; } else { int rt = copyNode(R); lson(rt) = merge(L,lson(rt)); pushup(rt); return rt; }}inline void splitVal(int rt,int val,int &L,int &R){ if(!rt) { L = R = 0; return ; } if(tree[rt].val <= val) { L = copyNode(rt); splitVal(rson(L),val,rson(L),R); pushup(L); } else { R = copyNode(rt); splitVal(lson(R),val,L,lson(R)); pushup(R); }}如果空间实在吃紧,可以考虑直接重构整棵树。