平衡树从入门到入土【待更新】
O.写在前面
本文的题目叫「平衡树从入门到入土」。因为我想让每一个学过树形结构的同学,都能够学会这种十分重要的数据结构。不论是上课睡觉没有听还是准备提前预习的同学,都能从这篇文章受益。
平衡树的核心思想在于如何保证「平衡」——显然,也是最难理解的。大部分平衡树是通过「旋转」来保持平衡性质的。说句实话,我在分清楚左旋、右旋的时候就花了好长时间。因此,在这篇文章里,我会尽可能清楚的说明什么是左旋、什么是右旋。如果没有理解旋转,那平衡树就是没学。
本文会从最基础的「BST二叉搜索树」开始,过渡到「AVL二叉平衡树」、「Treap树堆」以及「红黑树」,最后到「Splay伸展树」。我会尽可能的说清楚「怎么想到的」「为什么」「凭什么」等我自己曾经有的困惑,争取让每一个读者都能理解这些伟大且重要的数据结构。
那么,让我们先从最基础的「BST二叉搜索树」开始吧!
(注:本文中如果在引用块中使用了``xxx``标记,表示作者想强调此处的xxx是在代码里定义的变量名。这种写法只会在定义函数的时候出现)
I.万恶之源「BST二叉搜索树」
事先声明1:「
BST二叉搜索树」并不是平衡树,请注意。
事先声明2:本文「排序」指的是升序排序。
1. 引入
每个算法或数据结构的出现都有其需要解决的问题。「BST二叉搜索树」用来解决如下问题(对应例题:P5076 【深基16.例7】普通二叉树(简化版):
- 集合中有一些数;
- 支持「添加」操作;
- 求某一个数的「排名」;
排名:将数组元素排序后第一个相同元素之前的数的个数加一。
e.g. \(1,\ 3,\ 5,\ 7\) 中, \(3\) 的排名是 \(2\) 。 - 求某一「排名」对于的数;
- 求某一个数的「前驱」和「后继」;
前驱: \(x\) 的前驱定义为数组元素排序后小于 \(x\) 且最大的数。简而言之,即为数组排序且去重后 \(x\) 前面的一个数。
后继:同「前驱」,只不过是要求大于 \(x\) 且最小的数。简而言之,即为数组排序且去重后 \(x\) 后面的一个数。 - 求「最大/最小值」;
- 「删除」某一个数。
可以看到,所有操作的核心即为动态排序去重,如果使用std::sort()和线性表肯定是无法在如此庞大的询问量下通过的。此时,我们需要一个新的数据结构。这里,我们引入「BST二叉搜索树」来解决这个问题。
Q: 为什么想到使用树形结构?
A: 线性表很难胜任动态排序(如果你感兴趣可以去学学「跳表」,就我个人感官而言也不像是线性表)。那么我们也许需要找一个不线性的数据结构来解决问题。于是想到最简单的树——二叉树,并且二叉树的中序遍历和数组的按顺序输出差异不大,我们也可以很方便的完成「插入」操作,那么我们也许可以对二叉树进行一些改造,使之能达成我们的目的。
2. 核心思路
事先声明3:在叙述中,「左/右儿子」会简写为「 \(\text{lc/rc}\) 」,「父结点」会简写为「 \(\text{fc}\) 」,某一结点 \(x\) 的权值大小会记为「 \(\text{size}(x)\) 」。
如何让其「有序」?不难想到,如果让每个结点的 \(\text{lc}\) ,\(\text{fc}\) 和 \(\text{rc}\) 有序的话,整棵树的中序遍历也许就是有序的。那么,我们先规定:对于BST上所有的结点,均满足:
即左比父小,父比右小。
本文称这个不等式为「重要性质1」。
那么,一棵可能的二叉搜索树长这样:

接下来,我们就要对其添加操作了……
2.1 操作
事先声明4:在代码中,
treesize为结点个数;tree[x]为结点 \(x\);tree[x].val为结点 \(x\) 处存的数值;tree[x].cnt为结点 \(x\) 存的值所出现的次数;tree[x].left和tree[x].right分别为结点 \(x\) 的左儿子和右儿子结点;tree[x].size为结点 \(x\) 作为根时的子树大小。考虑到指针操作对于一部分入门者还是有些困难,我们这里使用数组实+结构体实现:
struct node {
int val;
int size;
int cnt;
int left;
int right;
} tree[1000010];
并且,我强烈建议大家拿纸拿笔画一画,更利于理解。
鲁迅说过,想要对结点操作,首先得有结点。(划掉)。那么我们首先来实现添加结点的操作。
所有的结点,我们可以从上到下找到他应该存在的位置——即把他从根结点下降到叶结点位置——再把他插入。这样我们就维护了「重要性质1」。
给出函数
insert(int x, int v),表示在x为根的树上插入一个结点:
- 如果是一个空树,那么直接新建结点就好了捏!
treesize ++; if(tree[tree[x].left].size == 0 || tree[tree[x].right].size == 0) { // 左子树是空的/右子树是空的 tree[x].cnt = tree[x].size = 1; tree[x].val = x; } - 如果不是,不管怎样,这个结点的子树大小得自增一下诶!
tree[x].size ++; - 如果插入的权值等于自身,那么这个结点的
cnt就要自增一下~这个操作是为了解决“去重”的问题而不改变输入的数据。
- 如果插入的权值大于自身,就应该放到「 \(\text{rc}\) 」对应的子树里面去;
- 如果插入的权值小于自身,就应该放到「 \(\text{lc}\) 」对应的子树里面去。
if (tree[x].val > v) insert(tree[x].left, v); if (tree[x].val < v) insert(tree[x].right, v);
完成了插入,我们接下来继续考虑其他操作。
我们发现,求「最大/最小值」似乎是最好做的。为什么呢?一个直观的感受就是,最左边的是最小的,最右边的是最大的。其实也是这样。
根据「重要性质1」,我们从根结点开始, \(\text{lc}\) 肯定比 \(\text{fc}\) 小,那么 \(\text{lc}\) 的 \(\text{lc}\) 肯定比 \(\text{lc}\) 小,那么 \(\text{lc}\) 的 \(\text{lc}\) 的 \(\text{lc}\) 肯定比 \(\text{lc}\) 的 \(\text{lc}\) 小……也就是说,一直向左边跑,找到的就是最小值。
给出函数
getMin(int x)和getMax(int x),分别用于在根x下找最小值和最大值。
// 既然OIwiki给了if的写法,我就按照我习惯的三目写法了
int getMin(int x) { return (tree[tree[x].left].size != 0) ? getMin(tree[x].left) : tree[x].val; }
int getMax(int x) { return (tree[tree[x].right].size != 0) ? getMax(tree[x].right) : tree[x].val; }
接下来来到一个有点烧脑的操作——找排名。
所谓找排名,换个角度理解,就是找这个数左边有多少个数,再加一即为这个数的排名(因为排名从1开始)。
给出
queryrk(int x, int rk)用于寻找根x下排名为rk的数。
- 如果这个树没有根,显然不存在(在本题中为
INF) - 如果有根:
- 如果 \(\text{size(lc)}\) 比 \(\text{rk}\) 大,就说明左子树里最大的数的排名比 \(\text{rk}\) 要大,此时应该往左边找;
- 否则,比较左子树大小与该结点重复的次数之和(记为 \(\text{sum}\) )与 \(\text{rk}\) 的大小(请注意,因为结点会重复,所以一定要把重复的给加在一起)
- 如果 \(\text{sum}\geq \text{rk}\) :说明要找的元素就在重复结点的里面;
- 否则,就在右子树里找。即寻找在右子树里,相对排名为 \(\text{rk} -\text{sum}\) 的数。
// OK,依旧是三目
int queryrk(int x, int rk) {
return (!x) ? \
INF : \
(tree[tree[x].left].size >= rk ? \
queryrk(tree[x].left, rk) : \
(tree[tree[x].left].size + tree[x].cnt >= rk ? \
tree[x].val : \
queryrk(tree[x].right, rk - tree[tree[x].left].size - tree[x].cnt)));
}
这句话:
否则,就在右子树里找。即寻找在右子树里,相对排名为 \(\text{rk} -\text{sum}\) 的数。
我困惑了很久,我们还是得回到
queryrk的定义上来。由于我们的
queryrk(int x, int rk)函数的根不是 \(\text{root}\) 而是 \(\text{x}\) ,那么在往右边找的过程中,相对于 \(\text{root}\) 排名为 \(\text{rk}\) 的数,在 \(\text{x}\) 中就应该为 \(\text{rk} -\text{sum}\) 。如图:
那为什么往左跳的时候就不需要用相对排名了呢?图都在这里了,相信大家拿出纸笔画一画就知道了。
接下来即为已知数找对应的排名。和上一步操作类似,只不过这次我们只需要利用「二分查找」的思想,不断地向左子树或右子树跳即可。
给出
queryval(int x, int val),用于在根x下寻找val的排名。
- 如果树是空的,当然不存在排名(本题中为 \(0\) );
- 如果有根:
- 如果\(\text{val}\)与当前节点的值相等,那么直接返回左子树的大小;
- 否则:
- 如果\(\text{val}\)小一些,就应该往左边跳。
- 如果\(\text{val}\)大一些,就应该往右边跳。并且将答案加上左子树的大小以及根节点的大小。
int queryval(int x, int val) {
return (!x) ? \
0 : \
(val == tree[x].val ? \
tree[tree[x].left].size : \
(val < tree[x].val ? \
queryval(tree[x].left, val) : \
(queryval(tree[x].right, val) + tree[tree[x].left].size + tree[x].cnt)));
}
这句话:
并且将答案加上左子树的大小以及根节点的大小。
我们还是看到上一个图片:
当你在往右跳的时候,假设你从10号跳到了13号,如果直接返回左子树的大小,你会发现其实上一步跳来的根节点所对应的大小,以及上一步跳来的根节点对应左子树的大小,被忽略了。
例如在我们的假设下,如果直接返回13对应左子树的大小,你就忽略了10号和9号。
接下来我们来寻找前继和后驱。如果你真正明白了上面两个操作的实现思路,那么找前驱和找后继就会变得很显然了。这里,我们不给出具体的操作思路而只给出代码,希望大家能自行理解。
给出函数
getbefore(int x, int val, int ans = -INF),用于在根x下寻找val的前继,如果没找到则返回-INF;
给出函数getnext(int x, int val, int ans = INF),用于在根x下寻找val的后驱,如果没找到则返回INF。
int getbefore(int x, int val, int ans = -INF) {
return (tree[x].val >= val) ? \
(!tree[x].left ? ans : getbefore(tree[x].left, val, ans)) : \
(!tree[x].right ? tree[x].val : getbefore(tree[x].right, val, tree[x].val)) ;
}
int getnext(int x, int val, int ans = INF) {
return (tree[x].val <= val) ? \
(!tree[x].right ? ans : getnext(tree[x].right, val, ans)) : \
(!tree[x].left ? tree[x].val : getnext(tree[x].left, val, tree[x].val));
}
最后一个操作是本题中没有涉及到的,但我认为还是需要提一下:删除。
给出函数
delete(int x, int val),用于在根x下删除值为val的节点。
- 找到这个节点;
- 考虑这个节点的 \(\text{cnt}\) :
- 如果 \(\text{cnt}\) 比 \(1\) 大,直接减少即可;
- 如果 \(\text{cnt}\) 就是 \(1\):
- 如果该节点为叶节点,直接删除;
- 如果该节点在只有一个儿子,那么直接用他的儿子替代他;
- 否则,用左子树的最大值/右子树的最小值替代他。
本题并不需要删除操作,故此处不给出删除的代码。在下一小节中,我们会从代码层面实现删除操作。
3. 小结
感觉如何?如果你是第一次接触这种有点复杂的数据结构,接受起来可能还是需要花点时间。
需要注意的是,我们在处理二叉树问题时「总是改变当前根节点,把一个大的问题转化为一个小的问题」。
稍作休息,接下来,我们就要进入「AVL二叉平衡树」的介绍了。


浙公网安备 33010602011771号