平衡树从入门到入土【待更新】

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上所有的结点,均满足:

\[\text{size(lc)} \leq \text{size(fc)} \leq \text{size(rc)} \]

即左比父小,父比右小。

本文称这个不等式为「重要性质1」。

那么,一棵可能的二叉搜索树长这样:

接下来,我们就要对其添加操作了……

2.1 操作

事先声明4:在代码中,

  • treesize为结点个数;
  • tree[x]为结点 \(x\)
  • tree[x].val 为结点 \(x\) 处存的数值;
  • tree[x].cnt 为结点 \(x\) 存的值所出现的次数;
  • tree[x].lefttree[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 为根的树上插入一个结点:

  1. 如果是一个空树,那么直接新建结点就好了捏!
    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;
    }
    
  2. 如果不是,不管怎样,这个结点的子树大小得自增一下诶!
    tree[x].size ++;
    
  3. 如果插入的权值等于自身,那么这个结点的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 的数。

  1. 如果这个树没有根,显然不存在(在本题中为INF
  2. 如果有根:
    • 如果 \(\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 的排名。

  1. 如果树是空的,当然不存在排名(本题中为 \(0\) );
  2. 如果有根:
    • 如果\(\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的节点。

  1. 找到这个节点;
  2. 考虑这个节点的 \(\text{cnt}\)
    • 如果 \(\text{cnt}\)\(1\) 大,直接减少即可;
    • 如果 \(\text{cnt}\) 就是 \(1\)
      • 如果该节点为叶节点,直接删除;
      • 如果该节点在只有一个儿子,那么直接用他的儿子替代他;
      • 否则,用左子树的最大值/右子树的最小值替代他。

本题并不需要删除操作,故此处不给出删除的代码。在下一小节中,我们会从代码层面实现删除操作。


3. 小结

感觉如何?如果你是第一次接触这种有点复杂的数据结构,接受起来可能还是需要花点时间。

需要注意的是,我们在处理二叉树问题时「总是改变当前根节点,把一个大的问题转化为一个小的问题」。

稍作休息,接下来,我们就要进入「AVL二叉平衡树」的介绍了。


II.升级开始「AVL二叉平衡树

posted @ 2023-08-07 13:59  SD!LTF  阅读(19)  评论(0编辑  收藏  举报