在之前本喵曾详细讲解过二叉树,今天介绍的二叉搜索数是普通二叉树的进阶版,也就是在二叉树的基础上加一些限制条件。
二叉搜索树又称为二叉排序树,它或者是一颗空树,或者是具有以下性质的二叉树:
总结来说,二叉搜索树的左子树小于根节点,右子树大于根节点。
template<class K>
struct BSTreeNode
{
BSTreeNode(const K& key)
:_key(key)
,_left(nullptr)
,_right(nullptr)
{}
K _key;
BSTreeNode<K>* _left;
BSTreeNode<K>* _right;
};
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node;
protected:
Node* _root = nullptr;
};
代码框架如上所示,节点中有key值,有左右字节的的指针,二叉搜索树中只有一个根节点。具体操作以及成员函数下面慢慢来完成。
任何一个数据结构,重要的操作无非就是增删查改。
- 谨记二叉搜索树的特性,左边子节点小于根节点,右边子节点大于根节点。
插入过程:
下面就是将上面过程用代码描述出来:
为了能够看到二叉搜索树中的内容,需要写一个打印函数,按照中序遍历的方式:
由于根节点是保护成员,所以在调用打印函数InOrder的时候,无法传入根节点,所以采用再封装一层的方式,将中序遍历的具体实现保护起来,只提供一个中序遍历的接口,在接口内部向中序遍历传入根节点。
可以看到,按照中序遍历打印出二叉搜索树的所有节点,是升序的。新插入的节点0确实是最小的。
通过调试可以看到,插入节点0最后插入到了叶子节点1的左边,和我们的分析相符。
插入16,根据二叉搜索树的特性,最终插入到了叶子节点14的右边,如上图所示。
查找比较容易,同样按照二叉搜索树的特性:
- 根据代码可以看到,最多寻找二叉搜索树的层数次。
- 每次更换查找节点时都会进入新的一层去寻找。
- 所以时间复杂度是O(logN)
删除节点左右子节点都是空:
如上图所示,删除节点1,该节点的左右字节的都是空。
寻找要删除的节点位置和上面查找的逻辑是一样的,当找到以后,需要确定要被删除的节点是父节点左右节点的哪个,然后置空并删除。
- 在寻找的过程中,需要记录下父节点的位置,方便后面确定置空的位置。
可以看到,节点1成功被删除,而且和我们分析的结果一致。
被删除节点左子节点为空,右子节点不为空:
如上图所示,删除节点14,该节点的左子节点是13,右字节的是空。删除后,左字节的需要挂在该节点的父节点10处。
要删除节点右子节点为空,父节点指向该节点的左字节的:
寻找逻辑以及判断要删除节点是父节点的左节点还是右节点和之前一样,不一样的是需要让被删除节点的父节点指向删除节点的左边非空子节点。
可以看到,14节点被删除后,它的非空左节点放在了父节点的右节点处,和我们分析的一直。
要删除节点左子节点为空,父节点指向该节点的右子节点:
和上面情况相似,只是需要让被删除节点的父节点指向该节点的非空右节点。
要删除节点左右子节点都非空:
如上图所示,要删除的节点是8,它的左右子节点都非空,此时前三种方法都不再适用了。
这里有一个问题,使用左子树的最大节点或者右子树的最小节点代替被删除节点后,还能保持二叉搜索树的结构吗?
答案是能的:
- 使用右子树的最小节点代替被删节点:被删节点的右子树中所有节点都比它要大,右子树本身就是二叉搜索树。右子树的最小节点必然比要删除的节点大,所以也就比被删节点左子树的所有值都大,所以代替后仍然保持二叉搜索树的结构。
- 使用左子树的最大节点代替被删节点:被删节点的左子树中所有节点都比它要小,左子树本身就是二叉搜索树。左子树的最大节点必然要比要删除的节点小,所以就比被删节点的右子树的所有值都小,所以代替后仍然保持二叉搜索树的结构。
问题优化:
在删除成员函数中,我们将parent的初始值设置成了nullptr,当遇到上面这种情况时就会导致解引用空指针的错误,所以要最开始将parent初始化成根指针。
此时的根节点是没有父节点的,所以没办法用之前的一般逻辑来判断根节点是它父节点的左还是右,必须要进行特殊处理,将让其为空,随便指向根节点的左右都行。
综上所述,删除二叉搜索树中的节点有三种情况:
删除节点 | 方法 |
---|---|
左子节点空,右不空 | 被删除节点的父节点指向右边的非空节点 |
左子节点不空,右为空 | 被删除节点的父节点指针左边的非空节点 |
左右子节点都为空 | 寻找左子树最大值或者右子树最小值来代替 |
删除的过程中,只要有一点代码上的逻辑错误都会报错。
迭代删除全部节点,没有错误,而且符合预期,说明我们的删除逻辑此时就没有任何问题了。
二叉搜索数是不支持修改的,如果修改了某个节点就会破坏它的结构,除非修改后重新建立二叉搜索树,这样就不划算了。
涉及到二叉树怎么能不用递归呢,上面的操作也可以用递归方式实现。
使用非递归方式的时候,在cur指针去下一层之前,都得记录一下它的父节点,为了后面确认新节点是插入到父节点的左边还是右边。
此时使用引用后,递归调用函数的root节点就是上一层函数的子节点本身,直接插入就可以,无需再进行判断。
画一次递归展开图:插入红色圆圈中的7。
可以将数组中的数组成功插入到二叉搜索树中,结果和非递归的一样。
当交换后,要删除的值就到了右子树的位置了,如上图红色框中所示,所以可以转化成删除右子树中的指定节点3,同样采用递归调用。
递归和非递归掌握一种就可以,个人建议非递归方式。
虽然递归方式代码量小,但是逻辑复杂,不好把控。
二叉搜索树和其他容器一样,都是模板类,所以也有默认成员函数。
构造函数:
写构造函数只是为了在显式定义了拷贝构造函数后,创建类对象时仍然有合适的构造函数调用,而不报错。
- 构造函数和拷贝构造函数构成重载,而拷贝构造还是并不是默认构造函数,需要传参的,此时编译器就不会自定生成默认构造函数了,所以需要我们显式定义。
单独写一个函数来进行复制,采用前序遍历的方式:
赋值运算符重载:
析构函数:
释放时需要采用递归的方式,因为类外无法对根节点传参,同样使用再次封装的方式,将释放的具体细节封装在一个函数中。
- 释放需要采用后序遍历的逻辑,否则会因为先释放了根节点而找不到子节点。
二叉搜索树是专门用来搜索的,它的效率非常的高,相比于二分查找,还支持插入删除时,不用挪动数据。它的应用场景主要右两种:K模型和KV模型。
- K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。
给一个单词word,判断该单词是否拼写正确,具体方式如下:
通俗来说就是,二叉搜索树中有没有key值。
我们输入的单词回去二叉搜索树中查找,有就告诉我们有,没有就反馈没有。
- KV模型:每一个关键码key,都有与之对应的值Value,即
的键值对。该种方式在现实生活中非常常见:
查找仍然是按照key值来查找,模型的维护仍然按照key来维护,只是key还有一个对应的值。
再比如统计水果数量,统计成功后,给定水果名称就可快速找到其出现的次数,水果名与其出现次数就是
对我们写好的二叉搜索树模型进行改造:
这就是KV模型的应用,当然不仅如此,还可以存放英文和中午的对应关系等等。
二叉搜索树,听名字就知道它是专门用来搜索的,因为它的搜索效率非常高。
正常情况下,二叉搜索树是一个二叉树的模样,虽然不一定是完全二叉树:
- 左右两个图都符合二叉搜索树的特性,并且数值都一样。
- 但是结构就完全不同。
我们说的二叉搜索树搜索效率高是指的左图这种结构:
简单分析就可以知道,每次比较完后,都会取下一层的节点进行比较,最多进行层数次,也就是log2N次。
而对于右图中这种情况,搜索效率最差:
如果退化成单支树,二叉搜索树的性能就失去了。我们后续学习的AVL树和红黑树就可以让它不论按照什么次序插入关键码,二叉搜索树的性能都能达到最优。
二叉搜索树的学习,是为了给后面使用STL容器中的map和set打基础。