二叉搜索树简介
二叉搜索树是一种特殊的二叉树,它有如下的性质:
- 左儿子(如果存在)的节点值比当前节点的值要小
- 右儿子(如果存在)的节点值比当前节点的值更大
- 某个节点的左/右子树(如果存在)也为一个二叉搜索树
- 中序遍历得到的节点值序列是不下降的(属于是推论)
二叉搜索树是很多重要树形数据结构的理论基础,例如树堆、红-黑树、伸展树等等。
二叉搜索树的实现
对于一个树形数据结构,我们首先需要明确它的建树操作,这相当于一栋大楼的基石;其次考虑它的节点操作,即单个点的增加、删除、求值等,相当于盖楼的承重柱,有骨梁的作用;最后是它的应用范围,好比给大楼装上玻璃。我们接下来从以上三个方面实现一个普通的二叉搜索树——
宏定义目录
把可能会用到的宏定义函数放在开头:
宏名称 | 定义 | 作用 |
---|---|---|
leftSubtree(idx) |
tree[tree[idx].ls] |
获取当前节点的左子树对象 |
rightSubtree(idx) |
tree[tree[idx].rs] |
类似上边,获取右子树对象 |
NEGAINF_NODE |
2 |
记录负无穷边界节点的下标 |
POSIINF_NODE |
1 |
记录正无穷边界节点的下标 |
基本结构
我们知道二叉树是可能会有左右子节点的,因此需要设计两个变量存储左右子节点 的下标;点的权值也是我们需要关心的;为了应对可能出现的权值重复,我们可以考虑加入一个权值计数,让相同权值的点不会被加入两次;最后为了应对一些题目的要求,维护一个记录子树总点数的变量。结构体看起来大概是这样的:
1 | struct BST { |
建树&单点创建
写
接下来就是单点的创建了。在
1 | int idx = 0, root; |
为 allocate()
函数设返回值大概是它与链式前向星最大的区别点了,后文会讲到设返回值的妙用。
单点查询
根据开头介绍的
- 若
,即查询到目标节点,返回下标 - 若
,向左子树递归搜索 - 若
,向右子树递归搜索 - 若下标无意义,返回不存在(表现在下标为
)
1 | int query(int idx, int dat) { |
插入数值
注意:这和前文所述的单点创建并不等价。在这一操作中,我们需要关注已有的其他点的权值情况。在
理论上我们可以通过调用 query()
函数来快捷得知该权值的存在性,但是实践时一般不采用这种做法。考虑到先前所讲的几大函数都没有对节点之间的从属关系进行更新,又因为插入操作会涉及到节点之间的关系转换,故将更新左右儿子的操作放在此处。
首先还是需要获取该权值理应存放的位置,求解策略跟单点查询相同。只是我们将递归参数“下标”设为引用形式,这样在一层层向上返回时就可以顺便更新父节点的左右儿子下标了。此时单点创建函数的返回值将作为新建点的序号向上传递,一路更新至根节点。
1 | void insert(int &idx, int dat) { |
前驱&后继
前驱:在所有权值小于当前点权的点之中,权值最大的那一个。
后继:在所有权值大于当前点权的点之中,权值最小的那一个。
以求前驱为例(如果权值是给定值的点不存在,我们默认它的前驱是负无穷):首先我们需要从根节点开始,向下找到给定权值应该存放的位置。如果找到权值是给定值的点,要保证答案点的权值小于基准点,我们就要先向左子树走(树内权值均小于父节点),接着一直沿右子树(树内权值均大于父节点)走到底即可找到前驱。求后继类似,只是需要先找到基准点的右子树,然后一直沿左子树走到底。
因为查询排名和查询数值都有一步查找对应点的步骤,与查找前驱后继的过程相同,故而可以把二者合一以简化代码(见后文)。
1 | int precursor(int idx, int dat) { |
单点删除
在一棵合法的
对于某个节点,它可能有如下几种情况:
- 它是一个叶子节点,可以直接删除。
- 它在一条链上(只有一个子节点):删除这个点,同时让它的子节点代替它。
- 它是一个二叉节点(两个子节点):考虑提出一种方式把问题简化为以上两种特殊情况。首先找到前驱或者后继,因为前驱和后继均只有一个方向上的子树,简化成第二种情况。我们可以让前驱/后继替换要删除的节点,紧接着把替换过去的原节点删除即可!就好像剪贴覆盖的过程。
- 如果这个点的重复数量大于一,则减去一个重复数。
1 | void remove(int &idx, int dat) { |
排名查询
所谓排名,就是看有多少元素比你当前的元素大/小,所得数目加一的结果。
- 如果需要向右子树扩展查找,则累加左子树大小和当前根节点的重复次数。
- 如果当前根节点的权值等于给定权值,则累加左子树大小,并额外加一。
- 如果需要向左子树扩展,递归返回向左子树查找的结果。
第一点很好理解:对于根节点,它的左子树所有点权都比根节点的小。此时我们需要查询的点权显然是大于当前根节点的(位于右子树),因而左子树、包括当前根节点在内的所有点都会排在目标点之前,需要累加起来。
第二点告诉我们这样一条消息:当前根节点就是目标节点,所以左子树的所有节点都排在它之前,需要累加。这个情况可以看作第一点的特殊形式,必须注意的是,这里无需再累加当前根节点的重复次数。
对于第三点:因为我们不确定左子树的情况(有大有小),因此无需多虑直接递归进去就好。
1 | int getRankByData(int idx, int dat) { |
数值查询
题目会给定一个排名,让你获取对应排名的节点权值。与上边排名查询不同的是,这里给出的排名必须有一个对应的点,因为排名是离散的、权值不离散,所以这里需要判断无解的情况。基本分五类讨论:
- 如果左子树的大小(节点的重复数也计入)大于等于所查询的排名参数
,向左子树递归。 - 如果左子树的大小在如下范围内(当前根节点重复数为
): ,返回当前根节点的权值。 - 不在以上三类情况中,向右子树递归,参数减去左子树的大小以及当前根节点的重复数。
- 当前下标无意义,判无解并返回。
首先,左子树是一定小于当前根节点的。如果左子树的大小都超过了排名参数,就代表目标点在左子树内,继续向左子树递归找排名为
对于第二点,当前根节点的重复数为
最后是第三点,因为左子树的大小与根节点重复数的和小于当前排名,代表我们要找的点在右子树。在右子树中它的排名又是多少呢?显然,因为左子树和根节点占去了一部分排名,在右子树中,它的排名就需要减去这二者的和。
第四点属于无解判断,不再赘述。
1 | int getDataByRank(int idx, int rank) { |
典例演练
洛谷 P5076 [深基16.例7] 普通二叉树(简化版)
题目地址:P5076
题目难度:普及+/提高
您需要写一种数据结构,来维护一些数(都是绝对值
以内的数)的集合,最开始时集合是空的。其中需要提供以下操作,操作次数 不超过 :
- 定义数
的排名为集合中小于 的数的个数 。查询数 的排名。注意 不一定在集合里。 - 查询排名为
的数。保证集合里至少有 个数。 - 求
的前驱(前驱定义为小于 ,且最大的数)。若不存在则输出 。 - 求
的后继(后继定义为大于 ,且最小的数)。若不存在则输出 。 - 插入一个数
,本题的数据保证插入前 不在集合中。 保证执行
操作时,集合中有至少一个元素。 输入格式:
第一行是一个整数
,表示操作次数。 接下来
行,每行两个整数 ,分别表示操作序号以及操作的参数 。 输出格式:
输出有若干行。对于操作
,输出一个整数,表示该操作的结果。
这是一道模板题,套用上边的模板即可(甚至不涉及节点删除)。为了压缩篇幅,代码放在此处。
你怎么知道我花了三节晚自习+一个上午过样例、大半个下午调代码?(因为查询数值时没判断是否是边界节点)
常见问题
Q:为什么初始的两个边界节点需要设置它们的重复计数为
?
从实际意义上说,这两个点其实并不参与我们的一系列树上操作。它们只是起到一个防止溢出的作用——好比人体的阑尾,对消化吸收并没有什么帮助,但是它就是长在那个地方就是用来发炎的,你也不好说什么(doge)。
Q:
的时间复杂度?为什么我用 会超时?
由于
后记&致谢
在写这篇文章的时候,经历了许多挫折……先是电脑意外关机,写了一千多字的草稿没保存直接没了;然后是注销电脑前望保存草稿,写了那么多,又一下子没了……为了保证代码的正确性,在做P5076的时候硬是顶着莫名的RE和WA把代码调出来了……
当然,调我又臭又长的数据结构代码是需要很大耐心和毅力的(以及心理承受能力)。这期间需要感谢:
- 花半小时用控制变量法拯救我的RE代码的—— Brailliant11001
- 花半小时理解我的代码,提出Hack数据来警示我的—— DWHJHY
- 花重金买来一大包(450g装)薯片为我们疲惫的大脑补充营养的—— Aventurine_Stone
参考资料:
[1] DWHJHY.二叉搜索树(BST)-BINARY SEARCH TREE [EB/OL] .https://www.luogu.com/article/vqr2u9g2 ,2024-4-12/2024-7-14
[2] Brailliant11001.二叉搜索树(BST)[EB/OL] .https://www.luogu.com/article/zqha49ef ,2024-1-31/2024-7-14