1. 概念
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
-
若它的左子树不为空,则左子树上所有结点的值都小于等于根结点的值。
-
若它的右子树不为空,则右子树上所有结点的值都大于等于根结点的值。
-
它的左右子树也分别为二叉搜索树。
-
二叉搜索树中可以支持插入相等的值,也可以不支持插入相等的值,具体看使用场景定义,后续我们学习 map / set / multimap / multiset 系列容器底层就是二叉搜索树,其中 map / set 不支持插入相等值,multimap / multiset支持插入相等值。
下列图中就是两个二叉搜索树,左边的树是不支持插入相等值,右边的树是支持插入相等值。并且这两个树中的左子树都比其根节点要小,右子树都比其根节点要大。

2. 性能分析
比如现在我用左边的二叉树去查找数据 7 。首先判断数据 7 和根节点 8 比大还是小,结果是小,那就去左子树中找,然后找到左子树的根节点 3 。判断数据 7 和根节点 3 比大还是小,结果是大,那就去右子树中找,然后找到右子树的根节点 6 ,判断数据 7 和根节点 6 比大还是小,结果是大,那就去右子树找,然后找到右子树的根节点 7 ,判断数据 7 和根节点 7 比大还是小,结果是相等,那就找到了。
因此,用二叉搜索树查找一个数据,最多查找这个树的层数次。
最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其高度为:log2N。
最差情况下,二叉搜索树退化为单支树(或者类似单支),其高度为:N。
这里的 N 指的是二叉搜索树中**结点的总数量,**所以综合而言二叉搜索树增删查改时间复杂度为:O(N)。
那么这样的效率显然是无法满足我们需求的,我们后续课程需要继续讲解二叉搜索树的变形,平衡二叉搜索树 AVL 树和红黑树,才能适用于我们在内存中存储和搜索数据。
另外需要说明的是,二分查找也可以实现O(log2N)级别的查找效率,但是二分查找有两大缺陷:
-
需要存储在支持下标随机访问的结构中,并且有序。
-
插入和删除数据效率很低,因为存储在下标随机访问的结构中,插入和删除数据一般需要挪动数据。
这里也就体现出了平衡二叉搜索树的价值。
3. 二叉搜索树的实现

首先创建一个节点的结构体,里面存储左子节点和右子节点,以及该节点中存储的值。接着来实现二叉搜索树的主体结构,先实例化出来一个根节点,暂时令其为空。
3.1 插入函数
第一步,我们先模拟实现插入函数,逻辑是这样的:
-
树为空,则直接新增结点,赋值给 root 指针
-
树不空,按二叉搜索树性质,插入值比当前结点大往右走,插入值比当前结点小往左走,找到空位置,插入新结点。
-
如果支持插入相等的值,插入值跟当前结点相等的值可以往右走,也可以往左走,找到空位置,插入新结点。要注意的是要保持逻辑一致性,插入相等的值不要一会往右走,一会往左走。

在这段代码当中,主要讨论树不为空的情况,先建立一个变量_cur记录下根节点,再建立一个变量parent用来记录_cur的父节点。我们通过比较插入值和_cur所指向的节点的值进行比较,如果插入值大于_cur所指向的值,就让_cur的位置不断地向其子节点移动,while循环中有一个return false的语句,代表不允许插入相同值。
当_cur到达叶子节点的时候,它的左右子节点都是nullptr,此时就跳出循环,_cur指向的时nullptr,但parent指向的是_cur的根节点。我们先让_cur节点存储数据 key ,但此时但并不知道_cur指向的是parent的左还是右节点,所以需要进行条件判断。
3.2 中序遍历
二叉树中序遍历的顺序是:左子树 → 根节点 → 右子树。因此我们的解决办法就是通过递归来实现:

先搭建一个大致的框架,大概是这样。但是我们其实会面临一个问题:因为这个函数是写在类模板里面的,那么比如说:当我实例化出一个对象 T 之后,想要调用这个函数,那使用方法就是T.InOrder( T->_root),但是我们发现,类当中的_root是被私有限定住的,无法在外部调用。但是被private限定符限定的成员变量,在类内部可以使用,于是我们采用这个方法:

我们将_InOrder函数封装在InOrder函数当中,那么在InOrder函数中,就可以调用私有成员_root,并且在函数调用的时候写成这样即可:T.InOrder( )即可。
3.3 查找函数
-
从根开始比较,查找 x,x 比根的值大则往右边走查找,x 比根值小则往左边走查找。
-
最多查找高度次,走到空,还没找到,这个值不存在。
-
如果不支持插入相等的值,找到 x 即可返回。
-
如果支持插入相等的值,意味着有多个 x 存在,一般要求查找中序的第一个 x。如下图,查找 3,要找到 1 的右孩子的那个 3 返回。

接下来模拟实现代码,我们要查找一个模板类型为K类型的key值:
查找函数其实跟插入函数很像,都是去移动cur的位置,这里的else语句判断的是要查找的key和节点中的值相同,如果相同那就返回ture,代表找到了。
3.4 删除函数
删除函数比较复杂,因为涉及到多种情况:
首先查找元素是否在二叉搜索树中,如果不存在,则返回 false。
如果查找元素存在则分以下四种情况分别处理:(假设要删除的结点为 N)
-
要删除结点 N 左右孩子均为空
-
要删除的结点 N 左孩子为空,右孩子结点不为空
-
要删除的结点 N 右孩子为空,左孩子结点不为空
-
要删除的结点 N 左右孩子结点均不为空。
对应以上四种情况的解决方案:
-
把 N 结点的父亲对应孩子指针指向空,直接删除 N 结点
-
把 N 结点的父亲对应孩子指针指向 N 的右孩子,直接删除 N 结点
-
把 N 结点的父亲对应孩子指针指向 N 的左孩子,直接删除 N 结点
对于2和3这两种情况因为比较类似,我们以2为例,就是这样:

- 无法直接删除 N 结点,因为 N 的两个孩子无处安放,只能用替换法删除。找 N 左子树的值最大结点 R (最右结点) 或者 N 右子树的值最小结点 R (最左结点) 替代 N,因为这两个结点中任意一个,放到 N 的位置,都满足二叉搜索树的规则。替代 N 的意思就是 N 和 R 的两个结点的值交换,转而变成删除 R 结点,R 结点符合情况 2 或情况 3,可以直接删除。
上面这段话用图片解释就是:
要删除的节点是3,3的左子树当中最大的数字是2,3的右子树当中最大的数字是4,我们取数字2和数字3交换位置,交换了之后再将3直接删除。这样的话结构依然属于二叉搜索树,并且交换后3就没有子节点,直接删除三这个节点就会更加方便。因此这里的3就代表N,这里的2就代表R,同时R也可以取4。
接下来实现代码:

首先第一步是查找,要先找到要删除的这个数据在不在二叉搜索树当中。最后的else语句就代表找到了,然后再进行上面的四种情况的判断,不然就是没有找到,直接return false。并且在这里很重要的一点:我们上面的代码中的parent = cur,它只是记录下来parent是哪个节点,cur是哪个节点,但并没有指出cur是parent的左子节点还是右子节点。
接下来我们需要思考,其实第一种情况是可以归并到第二和第三种情况中的,因为第二和第三种情况的逻辑就是:如果要删除节点的左 / 右节点不为空,就让删除节点的父节点的子结点指针,指向要删除节点的左 / 右节点,然后再删除要删除的节点。
我们首先要知道的是,第一种情况下,如果删除结束之后,要删除的那个节点的父节点,一定是指向空节点也就是nullptr的,那按照这个逻辑,第一种情况是没有左右子节点,那就意味着它的左右子节点为nullptr,那让要删除节点的父节点指向要删除节点的子节点,即nullptr,然后再删除要删除的节点,和我们期望的情况一模一样。

首先来看左子树为空的情况下,当parent指向的节点为nullptr时,这就代表我们要删除的节点是根节点,就像是这样:

在查找代码的逻辑下,cur第一次就查找到了我们要找的数据,所以就没有parent = cur的这一行代码执行,那parent就依然为nullptr,然后我们要删除的就是8这个节点,就让_root的指针指向8这个节点的右子节点。

接下来是parent不为nullptr的情况,也就是我们要删除的节点不是根节点,按照我们前面说的
:**我们上面的查找代码中的parent = cur,它只是记录下来parent是哪个节点,cur是哪个节点,但并没有指出cur是parent的左子节点还是右子节点。**所以在这里需要进行判断,上面的代码的逻辑就如下图所示:

判断右子树为空的代码和判断左子树为空的代码几乎一样,在这里就不过多赘述。
下面编写左右子树都不为空的情况,我们前面说了,要找左子树中最大的或者右子树中最小的,我们在这里选择找右子树中最小的。因为对于二叉搜索树来说,小的都放左边,大的都放右边,那我们要找最小的数,也就意味着只要找到最左边的值就行:

我们先定义了一个变量Rightmin记录下cur的右子树的最小值,找到之后让cur节点的值等于其右子树中最小的值。下面还有一个注意的点,用图片来解释:

在这里进行赋值之后,再把右子树中节点的值最小的那个节点,也就是 4 这个节点删除之后,还需要修正它的父节点也就是 6 的子节点的指向,并且我们在while循环中的判断条件是Rightmin的左子树为空,所以我们需要将父节点的左子节点的指针指向右子树中最小值的右子节点。因此我们还需要记录右子树中最小值的父节点:

在这里我将右子树中最小值的父节点进行了初始化,初始化值为cur,这是因为如果说在一开始,没有进入while循环,也就意味着最开始Rightmin->_left就是nullptr,此时RightminParent如果是nullptr的话,那在下面的这一句代码中:RightminParent->_left = Rightmin->_right;就会引发空指针解引用的问题。并且和上面的一样,我们也不知道这个Rightmin是RightminParent的左子节点还是右子节点,所以依然需要进行判断。
3.5 二叉搜索树实现的代码
cpp
#pragma once
template<class K>
struct BSTNode
{
K _key;
BSTNode<K>* _left;
BSTNode<K>* _right;
BSTNode(const K& key)
:_key(key)
, _left(nullptr)
, _right(nullptr)
{}
};
template<class K>
class BSTree
{
typedef BSTNode<K> Node;
public:
bool Insert(const Node& key)
{
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
Node* _cur = _root;
Node* parent = _cur;
while (_cur)
{
if (_cur->_key < key)
{
parent = _cur;
_cur = _cur->_right;
}
else if (_cur->_key > key)
{
parent = _cur;
_cur = _cur->_left;
}
else
{
return false;
}
}
_cur = new Node(key);
if (parent->_key < key)
{
_cur = parent->_right;
}
else
{
_cur = parent->_left;
}
return true;
}
void InOrder()
{
_InOrder(_root);
}
bool Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_key > key)
{
cur = cur->_left;
}
else if (cur->_key < key)
{
cur = cur->_right;
}
else
{
return true;
}
}
return false;
}
bool Erase(const K& key)
{
Node* cur = _root;
Node* parent = nullptr;
while (cur)
{
if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
else
{
//先判断左子树为空的情况下
if (cur->_left == nullptr)
{
if (parent == nullptr)
{
_root = cur->_right;
}
else
{
if (parent->_left == cur)
{
parent->_left == cur->_right;
}
else
{
parent->_right == cur->_right;
}
delete cur;
return true;
}
}
//判断右子树为空的情况
else if (cur->_right == nullptr)
{
if (parent == nullptr)
{
_root = cur->_left;
}
else
{
if (parent->_left == cur)
{
parent->_left = cur->_left;
}
else
{
parent->_right = cur->_left;
}
}
delete cur;
return true;
}
else
{
Node* RightminParent = cur;
Node* Rightmin = cur->_right;
while (Rightmin->_left)
{
RightminParent = Rightmin;
Rightmin = Rightmin->_left;
}
cur->_key = Rightmin->_key;
if (RightminParent->_left == Rightmin)
{
RightminParent->_left = Rightmin->_right;
}
else
{
RightminParent->_right = Rightmin->_right;
}
delete Rightmin;
return true;
}
}
}
return false;
}
private:
void _InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
private:
Node* _root = nullptr;
};
4. key的搜索场景
key 搜索场景是一种只关心 "某个关键值(key)是否存在" 的搜索需求。
场景一:小区无人值守车库,小区车库买了车位的业主车才能进小区,那么物业会把买了车位的业主的车牌号录入后台系统,车辆进入时扫描车牌在不在系统中,在则抬杆,不在则提示非本小区车辆,无法进入。
场景 2:检查一篇英文文章单词拼写是否正确,将词库中所有单词放入二叉搜索树,读取文章中的单词,查找是否在二叉搜索树中,不在则波浪线标红提示
在这样的场景下,我们不需要存储额外的关联数据,只需要存储 key 本身。整个操作的核心目标就是:
1. 判断存在性:快速确认一个 key 是否在数据集合中。
2. 基础增删查:支持添加新 key、删除已存在的 key,以及查找 key 是否存在。
key的搜索场景实现的二叉树搜索树支持增删查,但是不支持修改,修改key破坏搜索树结构了。因为二叉搜索树的结构是完全依赖于 key 的值来维护的。每个节点的位置,都是由它的 key 与父节点、左右子节点的大小关系决定的。如果直接修改某个节点的 key 值,会打破这种大小关系,导致整棵树的结构不再符合二叉搜索树的规则,后续的增删查操作都会出错。在这种场景下,如果需要更新一个 key,正确的做法是先删除旧的 key,再插入新的 key,这样才能保证树的结构始终是合法的。
5. key/value的搜索场景
key/value(键值对)搜索场景 是比单纯的 key 搜索场景更通用、更常见的应用场景。它的核心是:通过一个唯一的关键值(key),查找 / 关联到对应的业务数据(value)。
-
key:用于检索的唯一标识(比如用户 ID、手机号、订单号);
-
value:与 key 绑定的具体业务数据(比如用户信息、订单详情、商品价格)。
简单说,它不只是判断 "key 在不在",而是 "找到 key 后,还要拿到对应的具体信息"------ 这是和纯 key 搜索场景最核心的区别。因此,树的结构中 (结点) 除了需要存储 key 还要存储对应的 value,增 / 删 / 查还是以 key 为关键字走二叉搜索树的规则进行比较,可以快速查找到 key 对应的 value。key/value 的搜索场景实现的二叉树搜索树支持修改,但是同样的,不支持修改 key,修改 key 破坏搜索树性质了,可以修改 value。
使用的场景有以下几个:
场景 1:简单中英互译字典,树的结构中 (结点) 存储 key (英文) 和 value (中文),搜索时输入英文,则同时查找到了英文对应的中文。
场景 2:商场无人值守车库,入口进场时扫描车牌,记录车牌和入场时间,出口离场时,扫描车牌,查找入场时间,用当前时间 - 入场时间计算出停车时长,计算出停车费用,缴费后抬杆,车辆离场。
场景 3:统计一篇文章中单词出现的次数,读取一个单词,查找单词是否存在,不存在这个说明第一次出现,(单词,1),单词存在,则 ++ 单词对应的次数。
6. key/value二叉搜索树代码实现
首先我们要明确的一点是:不管是key搜索场景,还是key/value搜索场景,它们两个代码的整体框架都是差不多的。就比如key/value场景下的节点结构体是这样的:

然后对于插入函数,代码逻辑和key搜索场景的也一样,只是在插入时开辟新节点的时候把value的值也带上就行:

但是对于查找函数来说,我们不仅要查看我们需要的key值是否在二叉搜索树当中,还需要找到key所对应的value值,所以返回类型需要进行改变:

如果找到这个所要的key值,就返回该存储该值的节点,否则就返回空指针表示没找到。
对于删除函数,就不需要进行改动,因为我只需要找到key值对应的节点直接进行删除逻辑就行,不需要用到value的值。
然后中序遍历当中有一点可以做出调整:

在打印key值的同时,将value的值也打印出来。
接着我们来先测试这部分代码:

这里给出一个测试用例,实例化了一个对象dict代表字典,然后插入英文和中文,将要查找的单词输入到str当中,再去进行查找,如果找到,就打印出来这个单词对应的中文,否则打印:无此单词,请重新输入。

代码如我们预期的结果一样,证明我们目前的逻辑没有问题。
上面的代码实现的是我们刚刚所说的场景1,接下来实现一下场景3。

在这段代码当中我们给出了一系列水果的名称,然后希望能统计出出现水果名称的次数。首先实例化出了一个对象num用于存储节点,接着遍历整个数组,先判断这个水果名称是否在二叉搜索树当中,如果在,那就直接让这个水果的出现的次数+1,也就是pNode->_value++;如果不在,那pNode就是nullptr,此时将这个水果名称插入到二叉搜索树当中,并令其出现的次数先为一。最后进行中序遍历将这个搜索树打印出来。
本文到此结束,感谢各位读者的阅读,如果有讲解的不到位或者错误的地方,欢迎各位指正或批评。