【数据结构】二叉搜索树

作者:一只大喵咪1201
专栏:《数据结构与算法》
格言:你只管努力,剩下的交给时间!
【数据结构】二叉搜索树_第1张图片

在之前本喵曾详细讲解过二叉树,今天介绍的二叉搜索数是普通二叉树的进阶版,也就是在二叉树的基础上加一些限制条件。

二叉搜索树

  • 概念
  • 操作(非递归方式)
    • 插入
    • 查找
    • 删除
    • 修改
  • 操作(递归方式)
    • 插入
    • 查找
    • 删除
  • 默认成员函数
  • 应用
    • K模型
    • KV模型
  • 性能分析
  • 总结

概念

二叉搜索树又称为二叉排序树,它或者是一颗空树,或者是具有以下性质的二叉树:

  • 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值。
  • 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值。
  • 它的左右子树也分别是二叉搜索树。

总结来说,二叉搜索树的左子树小于根节点,右子树大于根节点。

【数据结构】二叉搜索树_第2张图片
如上图中,左边就不符合,右边就是二叉搜索树。

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值,有左右字节的的指针,二叉搜索树中只有一个根节点。具体操作以及成员函数下面慢慢来完成。

操作(非递归方式)

任何一个数据结构,重要的操作无非就是增删查改。

插入

【数据结构】二叉搜索树_第3张图片
以上图为例,在现有的二叉搜索树中插入0。

  • 谨记二叉搜索树的特性,左边子节点小于根节点,右边子节点大于根节点

插入过程:

  • 插入节点0 < 根节点10,插入到左边新的根节点。
  • 插入节点0 < 左根节点3,插入到左边新的根节点。
  • 插入节点0 < 左根节点1,插入到左边新的根节点。
  • 左边新的根节点为空,可以插入。

下面就是将上面过程用代码描述出来:

【数据结构】二叉搜索树_第4张图片

  • 如果是空树,插入的第一个节点就需要插入到根的位置,需要特殊处理。
  • 不是空树时,根据二叉搜索树的特性,寻找到要插入的位置。
  • 正式插入时,需要判断是插入叶子节点的左边还是右边,所以在寻找插入位置的时候,需要用一个指针来记录位置的父节点。

为了能够看到二叉搜索树中的内容,需要写一个打印函数,按照中序遍历的方式:

【数据结构】二叉搜索树_第5张图片
由于根节点是保护成员,所以在调用打印函数InOrder的时候,无法传入根节点,所以采用再封装一层的方式,将中序遍历的具体实现保护起来,只提供一个中序遍历的接口,在接口内部向中序遍历传入根节点。

【数据结构】二叉搜索树_第6张图片

可以看到,按照中序遍历打印出二叉搜索树的所有节点,是升序的。新插入的节点0确实是最小的。

【数据结构】二叉搜索树_第7张图片
通过调试可以看到,插入节点0最后插入到了叶子节点1的左边,和我们的分析相符。

【数据结构】二叉搜索树_第8张图片
插入16,根据二叉搜索树的特性,最终插入到了叶子节点14的右边,如上图所示。

【数据结构】二叉搜索树_第9张图片
可以看到,新插入的节点16最终插入到了叶子节点14的右边。

查找

查找比较容易,同样按照二叉搜索树的特性:

  • 要查找的数比根节点大就去右边找。
  • 要查找的数比根节点小就去左边找。
  • 当找到空节点说明没有这个节点。

【数据结构】二叉搜索树_第10张图片

  • 根据代码可以看到,最多寻找二叉搜索树的层数次。
  • 每次更换查找节点时都会进入新的一层去寻找。
  • 所以时间复杂度是O(logN)

删除

删除节点左右子节点都是空:
【数据结构】二叉搜索树_第11张图片
如上图所示,删除节点1,该节点的左右字节的都是空。

  • 找到指定节点并删除。
  • 父节点指向被删除节点的分支置为空。

【数据结构】二叉搜索树_第12张图片
寻找要删除的节点位置和上面查找的逻辑是一样的,当找到以后,需要确定要被删除的节点是父节点左右节点的哪个,然后置空并删除。

  • 在寻找的过程中,需要记录下父节点的位置,方便后面确定置空的位置。

【数据结构】二叉搜索树_第13张图片

可以看到,节点1成功被删除,而且和我们分析的结果一致。

被删除节点左子节点为空,右子节点不为空:

【数据结构】二叉搜索树_第14张图片
如上图所示,删除节点14,该节点的左子节点是13,右字节的是空。删除后,左字节的需要挂在该节点的父节点10处。

  • 找到要删除的节点,确定它左子节点非空,右节点为空。
  • 删除该节点,让该节点的父节点指向该节点左边的非空节点。

要删除节点右子节点为空,父节点指向该节点的左字节的:

【数据结构】二叉搜索树_第15张图片
寻找逻辑以及判断要删除节点是父节点的左节点还是右节点和之前一样,不一样的是需要让被删除节点的父节点指向删除节点的左边非空子节点。

【数据结构】二叉搜索树_第16张图片
可以看到,14节点被删除后,它的非空左节点放在了父节点的右节点处,和我们分析的一直。

要删除节点左子节点为空,父节点指向该节点的右子节点:

和上面情况相似,只是需要让被删除节点的父节点指向该节点的非空右节点。

【数据结构】二叉搜索树_第17张图片
代码如上,和上一种情况相似,不再解释。

要删除节点左右子节点都非空:

【数据结构】二叉搜索树_第18张图片
如上图所示,要删除的节点是8,它的左右子节点都非空,此时前三种方法都不再适用了。

  • 找到要删除的节点位置,确定它的左右子节点都非空。
  • 找到该节点右子树中的最小节点,或者左子树中的最大节点作为替代节点。
  • 将替代节点的值赋值给要删除的节点,来保证仍然是二叉搜索树的结构。
  • 删除替代节点,再将剩下的节点链接。

【数据结构】二叉搜索树_第19张图片

  • 右子树的最小节点,必然在被删除节点整个右子树的最左边,这是由二叉搜索树的结构决定的。
  • 所以代替完成后,最小节点被删除后,它右节点不管空不空都需要挂在最小节点父节点上。

这里有一个问题,使用左子树的最大节点或者右子树的最小节点代替被删除节点后,还能保持二叉搜索树的结构吗?

答案是能的:

  • 使用右子树的最小节点代替被删节点:被删节点的右子树中所有节点都比它要大,右子树本身就是二叉搜索树。右子树的最小节点必然比要删除的节点大,所以也就比被删节点左子树的所有值都大,所以代替后仍然保持二叉搜索树的结构。
  • 使用左子树的最大节点代替被删节点:被删节点的左子树中所有节点都比它要小,左子树本身就是二叉搜索树。左子树的最大节点必然要比要删除的节点小,所以就比被删节点的右子树的所有值都小,所以代替后仍然保持二叉搜索树的结构。

问题优化:

  1. 最小值节点是它父节点的左节点还是右节点判断:
    【数据结构】二叉搜索树_第20张图片
    在最小值节点被删除后,需要连接它右边的节点到最小值节点的父节点。
  • 之前只是按照我们的例子确定出最小值节点是它父节点的右边节点,这并不具有一般性。
  • 如果删除的不是8,而是3的时候,如上图,此时最小节点就是它父节点的左边子节点。
  • 所以在链接的时候要进行判断最小值节点是它父节点的左边节点还是右边节点。
  1. 二叉搜索树中只有一个根节点,并且删除的就是根节点

【数据结构】二叉搜索树_第21张图片
在删除成员函数中,我们将parent的初始值设置成了nullptr,当遇到上面这种情况时就会导致解引用空指针的错误,所以要最开始将parent初始化成根指针。

【数据结构】二叉搜索树_第22张图片
此时的根节点是没有父节点的,所以没办法用之前的一般逻辑来判断根节点是它父节点的左还是右,必须要进行特殊处理,将让其为空,随便指向根节点的左右都行。

  1. 被删除节点的左右子节点都是空可以归为左空或者右空的一种特殊情况。

【数据结构】二叉搜索树_第23张图片

  • 将被删除节点左为空和右为空的情况,判断条件放松一点,只是左为空指向右节点,右为空只向左节点。
  • 此时当被删除节点左右节点都为空时,使用左为空,父节点指向右节点,此时右节点同样是空,符合要求。

综上所述,删除二叉搜索树中的节点有三种情况:

删除节点 方法
左子节点空,右不空 被删除节点的父节点指向右边的非空节点
左子节点不空,右为空 被删除节点的父节点指针左边的非空节点
左右子节点都为空 寻找左子树最大值或者右子树最小值来代替

删除的过程中,只要有一点代码上的逻辑错误都会报错。

【数据结构】二叉搜索树_第24张图片
迭代删除全部节点,没有错误,而且符合预期,说明我们的删除逻辑此时就没有任何问题了。

修改

二叉搜索数是不支持修改的,如果修改了某个节点就会破坏它的结构,除非修改后重新建立二叉搜索树,这样就不划算了。

操作(递归方式)

涉及到二叉树怎么能不用递归呢,上面的操作也可以用递归方式实现。

插入

【数据结构】二叉搜索树_第25张图片

  • 使用根节点指针的引用,巧妙的记录了父节点。

使用非递归方式的时候,在cur指针去下一层之前,都得记录一下它的父节点,为了后面确认新节点是插入到父节点的左边还是右边。

此时使用引用后,递归调用函数的root节点就是上一层函数的子节点本身,直接插入就可以,无需再进行判断。

画一次递归展开图:插入红色圆圈中的7。

【数据结构】二叉搜索树_第26张图片
红线是递的过程,绿线是归的过程。

【数据结构】二叉搜索树_第27张图片
可以将数组中的数组成功插入到二叉搜索树中,结果和非递归的一样。

查找

【数据结构】二叉搜索树_第28张图片
查找都不涉及到记录父节点,只是有一个单纯的比较。

【数据结构】二叉搜索树_第29张图片
1存在,可以找到,20不存在,找不到。

删除

【数据结构】二叉搜索树_第30张图片
可以看到,代码量真的比非递归少多了。

  • 当找到要删除的节点以后,需要进行保存,因为递归传参是引用,递归下去后,root就会变化。
  • 当要删除节点的左右子节点都非空的时候,将右子树最小节点与要删除的节点的值进行交换。
  • 然后再删除要删除节点右子树中的指定值。

【数据结构】二叉搜索树_第31张图片
当交换后,要删除的值就到了右子树的位置了,如上图红色框中所示,所以可以转化成删除右子树中的指定节点3,同样采用递归调用。

【数据结构】二叉搜索树_第32张图片
将二叉搜索树中的所有值都删除,和非递归方式的效果相同。

递归和非递归掌握一种就可以,个人建议非递归方式。

虽然递归方式代码量小,但是逻辑复杂,不好把控。

默认成员函数

二叉搜索树和其他容器一样,都是模板类,所以也有默认成员函数。

构造函数:

写构造函数只是为了在显式定义了拷贝构造函数后,创建类对象时仍然有合适的构造函数调用,而不报错。

  • 构造函数和拷贝构造函数构成重载,而拷贝构造还是并不是默认构造函数,需要传参的,此时编译器就不会自定生成默认构造函数了,所以需要我们显式定义。

【数据结构】二叉搜索树_第33张图片
构造函数和拷贝构造都属于构造函数。

  • 拷贝构造中,需要将二叉搜索树的所有节点复制过来,而且要一模一样,因为同样的数字,构成的二叉搜索树模型可能会不一样。

单独写一个函数来进行复制,采用前序遍历的方式:

【数据结构】二叉搜索树_第34张图片
代码非常简单,有兴趣的小伙伴可以去画一下它的递归展开图。

【数据结构】二叉搜索树_第35张图片
可以看到,成功进行了拷贝。

赋值运算符重载:

【数据结构】二叉搜索树_第36张图片
使用现代写法,参数不能是引用,否则会改变实参。

  • 让根和传参时生成的临时二叉搜索树的根进行交换。
  • 函数栈帧销毁时,销毁的是原本根中指向的nullptr。
  • 而原本的根指向的是临时二叉搜索树,但是此时不会被销毁了。

【数据结构】二叉搜索树_第37张图片
可以看到,成功进行了赋值。

析构函数:

【数据结构】二叉搜索树_第38张图片
释放时需要采用递归的方式,因为类外无法对根节点传参,同样使用再次封装的方式,将释放的具体细节封装在一个函数中。

  • 释放需要采用后序遍历的逻辑,否则会因为先释放了根节点而找不到子节点。

应用

二叉搜索树是专门用来搜索的,它的效率非常的高,相比于二分查找,还支持插入删除时,不用挪动数据。它的应用场景主要右两种:K模型和KV模型。

K模型

  • K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。

给一个单词word,判断该单词是否拼写正确,具体方式如下:

  • 以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树。
  • 在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。

通俗来说就是,二叉搜索树中有没有key值。

【数据结构】二叉搜索树_第39张图片

  • 创建一个vector,里面放多个单词。
  • 将造好的词典放入到二叉搜索树中。
  • 自定义输入单词,判断词典中有没有。

【数据结构】二叉搜索树_第40张图片
我们输入的单词回去二叉搜索树中查找,有就告诉我们有,没有就反馈没有。

KV模型

  • KV模型:每一个关键码key,都有与之对应的值Value,即的键值对。该种方式在现实生活中非常常见:

查找仍然是按照key值来查找,模型的维护仍然按照key来维护,只是key还有一个对应的值。

再比如统计水果数量,统计成功后,给定水果名称就可快速找到其出现的次数,水果名与其出现次数就是就构成一种键值对。

对我们写好的二叉搜索树模型进行改造:

【数据结构】二叉搜索树_第41张图片

  • 将代码放入一个新的命名空间中,名字叫KV。
  • 模板参数增加一个,并且在节点中也增加上参数V。

【数据结构】二叉搜索树_第42张图片
全部修改如上图所示,同样放入KV命名空间中。

【数据结构】二叉搜索树_第43张图片

  • 当二叉搜索树中没有该水果时,插入,并且将数量写一,此时就是在插入一对新的键值。
  • 当有这个水果时,只让数量进行加1。

这就是KV模型的应用,当然不仅如此,还可以存放英文和中午的对应关系等等。

性能分析

二叉搜索树,听名字就知道它是专门用来搜索的,因为它的搜索效率非常高。

  • 插入和删除操作都必须先查找,搜索效率代表了二叉搜索树中各个操作的性能。

正常情况下,二叉搜索树是一个二叉树的模样,虽然不一定是完全二叉树:

【数据结构】二叉搜索树_第44张图片
类似于上图所示情况。

  • 但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:

【数据结构】二叉搜索树_第45张图片

  • 左右两个图都符合二叉搜索树的特性,并且数值都一样。
  • 但是结构就完全不同。

我们说的二叉搜索树搜索效率高是指的左图这种结构:

  • 最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为N*log2N。

简单分析就可以知道,每次比较完后,都会取下一层的节点进行比较,最多进行层数次,也就是log2N次。

而对于右图中这种情况,搜索效率最差:

  • 最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为N2次。

如果退化成单支树,二叉搜索树的性能就失去了。我们后续学习的AVL树和红黑树就可以让它不论按照什么次序插入关键码,二叉搜索树的性能都能达到最优。

总结

二叉搜索树的学习,是为了给后面使用STL容器中的map和set打基础。

你可能感兴趣的:(数据结构与算法,数据结构)