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

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:在叙述中,「左/右儿子」会简写为「 lc/rc 」,「父结点」会简写为「 fc 」,某一结点 x 的权值大小会记为「 size(x) 」。

如何让其「有序」?不难想到,如果让每个结点的 lcfcrc 有序的话,整棵树的中序遍历也许就是有序的。那么,我们先规定:对于BST上所有的结点,均满足:

size(lc)size(fc)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就要自增一下~

    这个操作是为了解决“去重”的问题而不改变输入的数据。

    • 如果插入的权值大于自身,就应该放到「 rc 」对应的子树里面去;
    • 如果插入的权值小于自身,就应该放到「 lc 」对应的子树里面去。
    if (tree[x].val > v) insert(tree[x].left, v); if (tree[x].val < v) insert(tree[x].right, v);

完成了插入,我们接下来继续考虑其他操作。

我们发现,求「最大/最小值」似乎是最好做的。为什么呢?一个直观的感受就是,最左边的是最小的,最右边的是最大的。其实也是这样。

根据「重要性质1」,我们从根结点开始, lc 肯定比 fc 小,那么 lclc 肯定比 lc 小,那么 lclclc 肯定比 lclc 小……也就是说,一直向左边跑,找到的就是最小值。

给出函数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. 如果有根:
    • 如果 size(lc)rk 大,就说明左子树里最大的数的排名比 rk 要大,此时应该往左边找;
    • 否则,比较左子树大小与该结点重复的次数之和(记为 sum )与 rk 的大小(请注意,因为结点会重复,所以一定要把重复的给加在一起)
      • 如果 sumrk :说明要找的元素就在重复结点的里面;
      • 否则,就在右子树里找。即寻找在右子树里,相对排名rksum 的数。
// 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))); }

这句话:

否则,就在右子树里找。即寻找在右子树里,相对排名rksum 的数。

我困惑了很久,我们还是得回到queryrk的定义上来。

由于我们的queryrk(int x, int rk)函数的根不是 root 而是 x ,那么在往右边找的过程中,相对于 root 排名为 rk 的数,在 x 中就应该为 rksum 。如图:

那为什么往左跳的时候就不需要用相对排名了呢?图都在这里了,相信大家拿出纸笔画一画就知道了。

接下来即为已知数找对应的排名。和上一步操作类似,只不过这次我们只需要利用「二分查找」的思想,不断地向左子树或右子树跳即可。

给出queryval(int x, int val),用于在根 x 下寻找 val 的排名。

  1. 如果树是空的,当然不存在排名(本题中为 0 );
  2. 如果有根:
    • 如果val与当前节点的值相等,那么直接返回左子树的大小;
    • 否则:
      • 如果val小一些,就应该往左边跳。
      • 如果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. 考虑这个节点的 cnt
    • 如果 cnt1 大,直接减少即可;
    • 如果 cnt 就是 1
      • 如果该节点为叶节点,直接删除;
      • 如果该节点在只有一个儿子,那么直接用他的儿子替代他;
      • 否则,用左子树的最大值/右子树的最小值替代他。

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


3. 小结

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

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

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


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


__EOF__

本文作者SDLTF
本文链接https://www.cnblogs.com/sdltf/p/17611277.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   SD!LTF  阅读(30)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 25岁的心里话
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示