平衡树入门
前置技能
平衡树是二叉搜索树的一种,二叉搜索树的定义如下:
- 空树是二叉搜索树
- 若二叉搜索树的左子树不为空,则其左子树上所有点的权值均小于其根节点的值
- 若二叉搜索树的右子树不为空,则其右子树上所有点的权值均大于其根节点的值
- 二叉搜索树的左右子树均为二叉搜索树
对于有$n$个节点的二叉搜索树,每次基本操作的最优复杂度为$O(logn)$,由于二叉搜索树可能退化成一条链,所以最坏复杂度$O(n)$
而每种平衡树都能通过一些方法让二叉搜索树不退化成一条链,每次操作的时间复杂度保持在$O(logn)$
平衡树主要分为两种:
- 有旋转的树:AVL树,Splay树,红黑树
- 无旋转的树:替罪羊树,fhq-Treap
树旋转也分为两种:
- 右旋:将自己变为左孩子的右孩子,同时将左孩子的右孩子变为自己的左孩子
- 左旋:将自己变为右孩子的左孩子,同时将右孩子的左孩子变为自己的右孩子

替罪羊树
替罪羊树通过重构来维持树的平衡,在插入、删除操作之后,从上到下检测途经的节点,若发现失衡,则将以该节点为根的子树重构
1 | struct node { |
替罪羊树的删除是一种惰性删除,只是打上一个标记表示该节点已经被删除,实际大小就是没有被打上标记的节点数量,这个标记就是cnt,cnt=0表示节点已经被删除,cnt>0是表示节点存在
为了判断节点是否需要重构,我们定义一个平衡因子$\alpha$(取值在0.5到1之间),一般取0.75,当满足以下两个条件时表示该节点需要重构
- 左子树或者右子树的sz大于整个子树的sz * $\alpha$(左子树或者右子树太大导致整个子树不平衡)
- 整个子树被删除的节点数量大于整个子树的sz * 0.3(被删除的节点太多导致整个子树不平衡)
1 | bool imbalance(int &now) |
对于需要重构的节点,重构分为两步:
- 先中序遍历展开存入向量,注意只有当前节点存在时(cnt>0)才能够存入向量
- 分治建二叉树(每次取区间中点作为根,然后递归两边为左右子树建树)

在分治的同时,更新重构后子树每个节点的sz和fsz
1 | void dfs(int &now) |
显然,对一颗重构后的子树,我们还需要从上向下采用头递归的方式(比较好写,自下而上需要多一个fa参数)更新路径上所有节点的sz和fsz
check函数表示检查now到end路径上所有的点,如果有不平衡的节点则重构以该节点为根的子树
注意:check函数是自上向下检查每个节点是否需要重构,因为如果自下而上判断,可能会导致重构很多次,复杂度不能保证,而自上向下判断只需要重构第一个不平衡的节点即可
1 | void update(int &now, int end) |
替罪羊的插入操作和普通二叉搜索树的插入操作差不多,只是在每次插入之后,都需要从根节点root到当前节点now自上向下判断每个节点是否需要重构
1 | void insert(int &now, int val) |
删除操作同理,每次删除后,从根节点root到当前节点now自上向下判断每个节点是否需要重构
注意:删除操作减少的是节点的fsz,不是节点sz,fsz表示的是真实的大小(不包括已经删除的节点)
1 | void del(int &now, int val) |
对于查询某个数的排名qrank(x)和查询某个排名上的数qnum(x),则可以利用二叉搜索树的性质来求解
对于某个数的前驱,只需要查询qnum(qrank(x)-1),即查询(这个数的排名-1)上的数
但对于后继,不能查询qnum(qrank(x)+1),因为可能有重复的数字,比如说1 2 2 4,查询2的后继时,2的排名为2,但4的排名却是4,这样查询的结果依然是2,所以我们应该查询qnum(qrank(x+1))
1 |
|
fhq-Treap
treap是单词tree和heap的组合,表明treap是一种由树和堆组成的数据结构,所以fhq-Treap的每个节点都必须存储val和key两个值,其中val表示节点权值,key是随机的一个值,val满足二叉搜索树的性质,而key满足堆的性质,由于key是随机的,所以treap是期望平衡的
1 | struct node { |
fhq-Treap只有两个基本操作:分裂split和合并merge
分裂分为两种:
- 按值val分裂:把树分裂成两棵树,其中一棵树上所有节点的权值都小于等于val,另一颗树上所有节点的权值都大于val
- 按大小分裂:把树分裂成两棵树,其中一棵树的大小等于给定的大小,其余的节点在另一棵树里
接下来我们以按值分裂为例,假设权值小于等于val的那一棵树的根节点为x,称为第一棵树,另一棵树的根节点为y,称为第二棵树
根据二叉搜索树的性质,如果当前节点now的权值小于等于val,那么当前节点now和now的左子树都属于第一棵树,然而now的右子树上的节点不一定都属于第一棵树,那么我们就继续递归分裂now的右子树,同理,如果当前节点now的权值大于val,那么当前节点now和now的右子树都属于第二棵树,然而now的左子树上的节点不一定都属于第二棵树,继续递归分裂now的左子树

注意:分裂的同时需要更新路径上节点的sz,x和y采用引用传递(比较好写)
1 | inline void update(int now) |
合并时,以维护小根堆为例,有两种情况:
- 第一棵树根节点x的key值比第二棵树根节点y的key值小,由于第一棵树上节点的权值都小于第二棵树上节点的权值,那么根据二叉搜索树和小根堆的性质,第一棵树就在第二棵树的“左上方”,此时就递归合并节点x的右子树和节点y
- 第二棵树根节点x的key值比第二棵树根节点y的key值大,同理,那么第一棵树就在第二棵树的“左下方”,此时递归合并节点x和节点y的左子树

merge(x,y)表示将以x为根的树和以y为根的树合并同时返回合并后的根节点
1 | int merge(int x, int y) |
有了分裂split和合并merge操作之后,二叉搜索树的基本操作就能用这两个操作来表示出来,以插入操作为例,假设需要插入节点的值为val,我们先可以将树按val分裂成两棵树,再将第一棵树,新节点,第二棵树按照顺序依次合并成一棵树
1 | inline void insert(int val) |
1 |
|
fhq-Treap还可以对区间信息进行维护,包括区间反转,区间交换等等,这时就需要按大小进行分裂,fhq-Treap对区间[l,r]操作的核心思想仍然是分裂,将区间[l,r]直接从树中分裂出来
fhq-Treap对区间的操作和线段树类似,需要维护一个懒标记,当进行分裂和合并时需要下传懒标记
此时fhq-Treap仍然用随机的关键值key维护堆的性质,但不再用节点的权值维护二叉树的性质,而是用该点在序列中的位置来维护二叉树的性质,显然对这棵树中序遍历一遍就可以得到原序列
注意:这个“该点在序列中的位置”并不会体现在代码中,在分裂时而是通过当前节点now左子树的大小与需要分裂的大小sz的大小关系来决定是向左子树移动还是向右子树移动
分裂时,如果当前节点now左子树的大小小于还需要分裂的大小sz,那么当前节点now和now的左子树都应该属于第一棵树,同时递归分裂now的右子树,此时还需要分裂的大小sz应该减去刚刚分裂出的树的大小(now左子树的大小+1),同理,如果前节点now左子树的大小大于等于还需要分裂的大小sz,则应该递归分裂now的左子树
由于这棵树是按该点在序列中的位置来维护二叉树性质的,所以分裂后第一棵树表示的区间就是[1,sz],其他节点在第二棵树上
1 | void split(int now, int sz, int &x, int &y) |
因为仍然使用关键值key维护fhq-Treap堆的性质,所以合并merge和按值分裂只多了把懒标记向下传递的过程
1 | int merge(int x, int y) |
有了分裂split和合并merge操作之后,对于区间[l,r],可以先分裂出[1,r]区间,再将区间[1,r]分裂为[1,l-1]和[l,r]两个区间
1 |
|
fhq-Treap最大的缺点就是常数很大,有时候会被卡,Splay常数小,不容易被卡
AVL树
AVL树为了维持树的平衡,引入了平衡因子BF的概念,平衡因子=左子树树高-右子树树高(右子树树高-左子树树高)
1 | struct node { |
AVL树中每个节点的平衡因子只能为-1,0或1,当某个节点的平衡因子过大或者过小时,则通过树旋转的方式来让树恢复平衡,显然AVL树的树高为$O(logn)$,每次操作的时间复杂度为$O(logn)$
向一棵平衡的AVL数中插入或者删除一个节点,在没有调整前,树中每个节点平衡因子的取值只能为-2,-1,0,1或2
那么以下四种情况需要调整:
- LL:在当前节点now左子树的左子树上插入节点导致树不平衡,此时now的BF>1,now左子树的BF>0(插入之前可能为0或者1),右旋当前节点now使树平衡
- LR:在当前节点now左子树的右子树上插入节点导致树不平衡,此时now的BF>1,而now右子树的BF<=0(插入之前可能为1,0,-1),先左旋now的左子树,再右旋当前节点使树平衡
- RL:在当前节点now右子树的左子树上插入节点导致树不平衡,先右旋now的右子树,再左旋当前节点使树平衡
- RR:在当前节点now右子树的右子树上插入节点导致树不平衡,左旋当前节点 now使树平衡
旋转之后需要更新当前节点now和now左右子树的大小、树高
zag表示左旋,zig表示右旋,check检查当前节点now是否平衡
1 | inline int calc(int now) |
AVL的插入操作和二叉搜索树几乎没有区别,只是在插入后,自下而上回溯检查路径上的节点是否平衡,所以采用头递归的方式插入
对于删除操作,如果需要删除的节点只有一个儿子节点或者没有儿子节点,那么直接删除就可以了,对于有双儿子的节点,AVL树有两种删除方法:
- 将需要删除的节点旋转到叶子节点,直接删除即可
- 先找到需要删除节点的后继节点,用后继节点代替自己的位置,由于后继节点一定没有左儿子,所以后继节点直接删除即可
无论哪种方法,同样在删除后,需要自下而上回溯检查路径上的节点是否平衡
查询某个数的排名qrank(x)和查询某个排名上的数qnum(x)和替罪羊树差不多,只是替罪羊树每个节点可能是多个相同的数,而AVL每个节点都只表示一个数
1 |
|
Splay
在Splay上一系列操作都基于伸展操作,伸展操作能够让出现频率(包括查询,插入,删除)较高的元素能够更靠近根节点,从而使查询、插入、删除整体的时间的变少
1 | struct node { |
Splay在每次操作后,一般把节点伸展到根节点处,而伸展操作又是通过左旋和右旋来实现的,分为以下六种情况,每种情况都是将x节点伸展到根节点处

情况1:对y右旋就可以将x节点伸展到根节点处,称为zig
情况2:对y左旋就可以将x节点伸展到根节点处,称为zag
情况3:先对节点z右旋,再对节点y右旋即可,称为zig-zig
情况4:与情况3相反,先对节点左旋,再对节点y左旋即可,称为zag-zag
情况5:先左旋节点y,再右旋节点z即可,称为zag-zig
情况6:与情况5相反,先右旋节点y,再左旋节点z,称为zig-zag

如果需要将x节点伸展到y节点处,我们只需要递归把x节点伸展到z节点处,然后按照情况3处理即可
1 | void splaying(int x, int &y) |
注意:zig、zag函数的参数是引用传递,旋转后节点会发生改变,所以情况3执行的是zig(y),zig(y),splaying函数递归的出口是情况1和情况2
splay的插入操作和二叉树的插入操作差不多,插入后,将新插入的节点旋转到根节点即可
对于删除操作,splay则是先把需要删除的节点now伸展到根节点,找到节点now的后继节点,将后继节点伸展为根节点的右儿子,显然被伸展过的后继节点没有左儿子,此时我们只需要将后继节点的左儿子指向根节点的左儿子,将根节点指向这个后继节点即可,删除后需要更新根节点的sz

查询某个数的排名qrank(x)和查询某个排名上的数qnum(x)和替罪羊树差不多,只是在查询之后,需要将查询到的节点伸展到根节点
1 |
|
splay也能像fhq-Treap一样能够对区间进行操作,同样按照元素在序列中的下标来维护二叉树的性质,当对区间[l,r]操作时,可以先将下标为l-1的节点伸展到根节点,将下标为r+1的节点伸展为根节点的右儿子,根据二叉搜索树的性质,显然根节点右儿子的左子树表示的区间就是[l,r],我们就可以像fhq-Treap、线段树一样用懒标记解决区间问题

当需要在下标为x的元素后面插入一个新的元素时,我们可以先把下标为x的节点伸展到根节点,再将下标为x+1的节点伸展为根节点的右儿子,根据二叉树的性质,此时根节点右儿子的左子树为空,所以将这个新节点变为根节点右儿子的左子树即可
当需要删除下标为x的元素时,可以先把下标为x-1的节点伸展到根节点,再将下标为x+1的节点伸展为根节点的右儿子,根据二叉树的性质有,根节点右儿子的左子树就是我们需要删除的那个元素,直接将根节点右儿子的左子树变为空即可
注意:对于splay区间操作,一般在序列前后各加一个哨兵,方便处理,但由于在序列开始加入了哨兵,当处理序列上的区间[l,r]时,在splay上的区间应该是[l+1,r+1]
1 |
|
1 |
|
练习: