
◆ 博主名称: 晓此方-CSDN博客 大家好,欢迎来到晓此方的博客。
⭐️C++系列个人专栏: 主题曲:C++程序设计
⭐️ 踏破千山志未空,拨开云雾见晴虹。 人生何必叹萧瑟,心在凌霄第一峰
0.1概要&序論
这里是「此方」,好久不见。 今天我们要学习的是二叉搜索树。它是在普通二叉树的基础上加入特定约束,从而具备了高效的搜索能力 。虽然这种结构能够支持高效的插入、删除与查找操作,但其性能背后也隐藏着潜在的 效率风险 。同时,在 key 与 key-value 两种不同的应用场景 下,二叉搜索树的设计与实现方式也会产生不同的变化。这里是「此方」。让我们现在开始吧!
前情提要,没有系统学习过一般二叉树的小伙伴直接看这篇文章可能会有些吃力 ,此方在这里留一个传送门:Re:从零开始的链式二叉树:建树、遍历、计数、查找、判全、销毁全链路实现与底层剖析
一,二叉搜索树的概念
1.1二叉搜索树的概念和性质
二叉搜索树(英文名:Binary Search Tree, BST),又称二叉排序树。它或者是一棵空树,或者是具有以下性质的二叉树:
左子树性质 :若它的左子树不为空,则左子树上所有结点的值都小于(或小于等于)根结点的值。
右子树性质 :若它的右子树不为空,则右子树上所有结点的值都大于(或大于等于)根结点的值。
递归性质:它的左右子树也分别为二叉搜索树。
光讲概念肯定不好理解,我们画两个图看看:

如上图就是搜索二叉树的模型,根8左边子树上的数都比8小(左子树性质 ),右边子树上的数都比8大(右子树性质 )。并且这种性质在3为根的左子树,6为根的左子树等等都成立(递归性质)。
如右图,搜索二叉树除了左边这种每一个键值不重复的情况,还有右边这颗树一样的重复键值情况。这个我们会在接下来讲到:
1.2关于相等值的处理
二叉搜索树中是否支持插入相等的值,取决于具体的实现和使用场景:
1.不允许重复 :某些实现(如 C++ STL 中的 set 和 map)不允许插入与已有节点值相等的节点。
2.允许重复:另一些实现(如 C++ STL 中的 multiset 和 multimap)则支持插入相等的值,这些值通常会被放置在同一子树(通常是右子树)上。
set,map,multiset 和 multimap这些STL我们会在接下来的文章中详细介绍。他们的底层是平衡二叉树中的红黑树。
二,二叉搜索树的性能分析
2.1二叉搜索树的性能与其结构形态密切相关
最优情况 :当二叉搜索树为一棵完全二叉树(或高度平衡的树)时,其高度为 log₂N。此时,查找、插入、删除操作的时间复杂度均为 O(log N)。
最差情况 :当二叉搜索树退化为一个链表(例如,插入的数据本身就是有序的),其高度为 N。此时,查找、插入、删除操作的时间复杂度均退化为 O(N)。
还是用图片解释概念,如下左图,二叉树接近完全二叉树,增删查的时间复杂度为logN。如右图,二叉树接近一个链表,增删查的时间复杂度均退化为 O(N)。
恐怕有小伙伴不知道这个链表怎么来的,这里还是补充一下为妙 :有序序列[10,9,8,7,6,5,4,3,2,1]构建二叉树:根为10,9<10插在10的左边,8<9插在9的左边,以此类推序列后面的数只能插在父亲结点的左边,最后导致整个搜索二叉树呈现出一条链表状态。

综合来看,二叉搜索树各种操作的平均时间复杂度为 O(N)。这样的效率在最坏情况下无法满足实际需求。
因此,在后续的学习中,我们需要了解二叉搜索树的改进版本------平衡二叉搜索树,如 AVL 树和红黑树。这些结构通过特定的规则确保树的高度始终保持在 O(log N) 的范围内,从而保证了高效的增删查改性能。
2.2二叉搜索树对二分查找的优势
平衡二叉搜索树的优势可以通过与二分查找的对比来理解。虽然二分查找也能达到 O(log N) 的查找效率,但它存在两个主要缺陷:
存储限制 :必须将数据存储在支持下标随机访问的结构(如数组)中,并且要求数据是有序的(或者有一定规律可言) 。
更新效率低 (最难受的点):在支持随机访问的结构中插入和删除元素,通常需要移动大量数据,导致效率低下。
而平衡二叉搜索树恰好弥补了这两个缺陷,它无需连续存储空间,且能高效地完成动态数据的插入、删除和查找操作。
------以下正式开始讲二叉搜索树的增删查操作实现原理------
三,实现框架结构
先搭个框架方便接下来好写代码。
cpp
#pragma once
#include<iostream>
using namespace std;
namespace key{
template <class K>
class SBTnode{
public:
K _key;
SBTnode<K>* left;
SBTnode<K>* right;
SBTnode(const K& key)
:_key(key)
, left(nullptr)
, right(nullptr)
{}
};
template <class K>
class SBTree{
using Node = SBTnode<K>;
public:
///..................
protected:
Node* _root = nullptr;
};
}
准备两个模板类,一个是搜索二叉树的结点模板类,一个是搜索二叉树的功能实现模板类。
3.1二叉搜索树节点模板类SBTnode
类模板参数 K 表示节点中存储的数据类型,使该节点结构可以适用于任意类型的数据。
节点内部包含三个成员:_key 用于存储节点的关键字(数据),left 和 right 分别指向当前节点的左子节点和右子节点。
构造函数 SBTnode(const K& key) 接收一个键值 key,用于初始化节点的数据成员 _key,同时将左右孩子指针 left 和 right 初始化为空,表示新创建的节点在初始状态下没有子节点。
3.2二叉树功能实现类SBTree
- 重命名一个结点为Node,可以用typedef也可以using(C++11新增)
- 组合进来一个根结点指针 并添加缺省值Node* _root = nullptr;
四,二叉搜索树的查找
4.1基本思想与代码实现
从根节点开始比较,查找值 x:
- 若 x 大于根节点的值,则向右子树查找;
- 若 x 小于根节点的值,则向左子树查找。
- 最多查找树的高度次,若遍历到空节点仍未找到目标值,则说明该值不存在。

代码实现如下: (非常简单,不细讲了。)
cpp
Node* Find(const K& key){
Node* cur = _root;
while (cur){
if (cur->_key < key)
cur = cur->right;
else if (cur->_key > key)
cur = cur->left;
else
return cur;
}
return nullptr;
}
4.2特别情况分析
如果不支持插入相等的值(即不允许重复),则在找到第一个等于 x 的节点时即可返回。
如果支持插入相等的值(即允许重复),则可能有多个值为 x 的节点。此时通常要求查找中序遍历下的第一个 x。
(例如:查找值为 3,需要找到在中序序列中首次出现的 3,即某个节点(如值为 1 的节点)的右孩子为 3 的那个节点并返回。)
关于重复值查找的规则C++并没有做出规定,属于未指定行为,像multimap,multiset这些容器在这方面的实现取决于编译器。
五,二叉搜索树的插入
5.1具体过程图文解释
插入的具体过程如下:
- 树为空,则直接新增结点,赋值给root指针。
- 树不空,按二叉搜索树性质,插入值比当前结点大往右走,插入值比当前结点小往左走,找到空位置,插入新结点。
- 如果支持插入相等的值,插入值跟当前结点相等的值可以往右走,也可以往左走,找到空位置,插入新结点。(要注意的是要保持逻辑一致性,插入相等的值不要一会往右走,一会往左走)
我们用一个例子解释一下原理:

如上有一颗二叉搜索树,我们这个时候要插入一个数16。如下图:按照二叉树的性质不断向下查找到指定位置并插入。

如下图,当我们要插入一个重复的数字的时候,可以规定他在原有的重复数字的左子树或者是右子树。 然后用二叉树的规则同样进行查找并插入。

5.2代码实现详细解析
稍微有点复杂,我们分块儿讲。具体可以将代码分成三部分:
1.查找插入位置,2.实现插入,3.特殊情况处理
5.2.1查找插入位置
从根节点开始,根据 key 与当前节点 cur->_key 的大小关系不断向左或向右查找,同时记录父节点 parent 。当 cur 走到 nullptr 时,说明找到插入位置;若遇到相同 key,则返回 false 表示不允许重复插入。
cpp
bool Insert(const K& key){
Node* cur = _root;
Node* parent = nullptr;
while (cur){
if (cur->_key < key){
parent = cur;
cur = cur->right;
}
else if (cur->_key > key){
parent = cur;
cur = cur->left;
}
else
return false;
}
}
5.2.2实现插入
当查找结束后,parent 指向待插入位置的父节点,此时创建新节点 new Node(key)。随后再次比较 key 与 parent->_key 的大小关系:若 key 小于父节点,则插入为左孩子;若大于父节点,则插入为右孩子。这样即可保持二叉搜索树左子树小于根、右子树大于根 的有序结构。
cpp
cur = new Node(key);
if (parent->_key > key)
parent->left = cur;
else if (parent->_key < key)
parent->right = cur;
return true;
5.2.3特殊情况处理
如果整棵树为空(即 _root == nullptr),说明当前插入的是第一个节点。此时无需进行查找过程,直接创建新节点并将 _root 指向该节点即可。这一步完成了二叉搜索树的初始化。
cpp
if (_root == nullptr){
_root = new Node(key, value);
return true;
}
六,二叉搜索树的删除
<二叉搜索树最麻烦的点来了。>
6.1执行删除操作时共有多少种情况
首先查找元素是否在二叉搜索树中,如果不存在,则返回 false。如果查找元素存在,则分以下四种情况分别处理:(假设要删除的结点为 N)
情况1 . 要删除结点 N 左右孩子均为空
情况2 . 要删除的结点 N 左孩子为空,右孩子结点不为空
情况3 . 要删除的结点 N 右孩子为空,左孩子结点不为空
情况4. 要删除的结点 N 左右孩子结点均不为空
6.2所有情况的应对策略思想总结
对应以上四种情况的解决方案我总结出了三种:
直接删除法,托孤法,替换删除法。
6.2.1直接删除法
把 N 结点的父亲对应孩子指针指向空,直接删除 N 结点(情况 1 可以当成 2 或者 3 处理,效果是一样的 )

6.2.2托孤法
情况二 ,把 N 结点的父亲对应孩子指针指向 N 的右孩子,直接删除 N 结点。
情况三,把 N 结点的父亲对应孩子指针指向 N 的左孩子,直接删除 N 结点。
如图所示,当前需要删除的结点为 14。该结点只有一个孩子 13,按照托孤法的处理方式,只需让 14 的父结点 10 的右指针直接指向 13,再删除结点 14 即可。
这样既完成了结点删除,又保证了二叉搜索树"左小右大"的结构性质不被破坏。如果 13 结点下方还存在子树,也会随着 13 一同接入原来的位置。

6.2.3替换删除法
先看图,假设我们要删除3结点或者是8结点这类有两个子节点的结点。

无法直接删除 N 结点,因为 N 的两个孩子无处安放,(为什么?如果还是用托孤法,被删除结点有两个孩子,被删除结点的父亲只有一个空出来的指针,指向其中一个孩子另外一个孩子怎么办? )所以删除有两个孩子的结点,我们必须另寻他法。
回想我们曾今在堆删除堆顶元素的时候采用的方法:从堆的底部取一个数和堆顶元素交换,然后删除堆底元素并对对顶做向下调整算法。
我们也可不可以将一个数和被删除的数交换来实现替换式的删除?因为搜索二叉树的规则严谨性,这个数不能随便取得。于是我们设计出以下规则来取得这个替换数。
找 N 左子树的值最大结点 R(最右结点)或者 N 右子树的值最小结点 R(最左结点)替代 N,因为这两个结点中任意一个,放到 N 的位置,都满足二叉搜索树的规则。
(替代 N 的意思就是 N 和 R 的两个结点的值交换,转而变成删除 R 结点,R 结点符合情况 2 或情况 3,可以直接删除。)
注意,这里有错误(是我忽略了这个问题): 一句话先说结论:只能让替代结点R去覆盖N,而不能让替代结点R去交换N,原理如下:

如上图,假设我们要去删除3,并且找到了右子树的最小结点4,这个时候交换4和3:如下图:

如果在这个时候继续选择从头遍历去查找3并删除的话,我们是找不到的,因为3的位置不符合二叉搜索树的规则。就是这个细节我们下面的代码要注意一下。 ok来看看代码怎么写:
6.3代码实现详细解析
6.3.0先给出完整代码
cpp
bool Erase(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_key < key)
{
parent = cur;
cur = cur->right;
}
else if (cur->_key > key)
{
parent = cur;
cur = cur->left;
}
else
{
if (cur->left == nullptr)
{
if (cur == _root)
{
_root = cur->right;
}
else
{
if (parent->left == cur)
{
parent->left = cur->right;
}
else if (parent->right == cur)
{
parent->right = cur->right;
}
}
}
else if (cur->right == nullptr)
{
if (cur == _root)
{
_root = cur->left;
}
else
{
if (parent->left == cur)
{
parent->left = cur->left;
}
else if (parent->right == cur)
{
parent->right = cur->left;
}
}
}
else
{
Node* replaceparent = cur;
Node* replace = cur->right;
while (replace->left)
{
replaceparent = replace;
replace = replace->left;
}
cur->_key = replace->_key;
if (replaceparent->left == replace)
{
replaceparent->left = replace->right;
}
else if (replaceparent->right == replace)
{
replaceparent->right = replace->right;
}
cur = replace;
}
delete (cur);
return true;
}
}
return false;
}
6.3.1查找待删除结点
cpp
Node* parent = nullptr;
Node* cur = _root;
while (cur){
if (cur->_key < key){
parent = cur;
cur = cur->right;
}
else if (cur->_key > key){
parent = cur;
cur = cur->left;
}
这一段代码的作用是 在二叉搜索树中查找要删除的结点。
cur:当前遍历到的结点,parent:记录 cur 的父结点
查找过程完全遵循 二叉搜索树的查找规则,因此代码不断向下移动,同时更新 parent,这样在真正删除节点时,就可以修改父节点的指针。 如果循环结束 cur == nullptr,说明 树中不存在该元素,函数最终返回 false。
6.3.2 情况一、二、三:当前结点没有左/右孩子
cpp
if (cur->left == nullptr){
if (cur == _root)
_root = cur->right;
else{
if (parent->left == cur)
parent->left = cur->right;
else if (parent->right == cur)
parent->right = cur->right;
}
}
这一部分对应前面原理中的 :情况1:左右孩子都为空,情况2/3:左/右孩子为空,右/左孩子不为空。
这三种情况在代码中可以 统一处理。 原因是:如果一个结点没有左/右孩子,那么只需要让父节点直接指向它的右/左孩子,这就是前面所说的托孤法。
代码中特别处理了 删除根节点的情况:_root = cur->right; 因为根节点没有父节点,只能直接修改 _root 指针。
6.3.4 情况四:左右子树都存在
cpp
else
{
Node* replaceparent = cur;
Node* replace = cur->right;
while (replace->left)
{
replaceparent = replace;
replace = replace->left;
}
当待删除结点左右子树都存在时,不能直接删除。因为如果直接删除该结点,它的左右子树将无法同时接入父结点,因此必须使用替换删除法
这里采用的方法是:在当前结点的右子树中寻找最小结点。实现方式是先进入右子树,然后不断向左寻找,直到找到最左侧的结点,这个结点就是当前结点的中序后继。
cpp
cur->_key = replace->_key;
找到替换结点后,将 replace 的值赋给当前结点 cur。这样在逻辑上就相当于:用 replace 替换了 cur 的位置。
此时真正需要删除的结点,就从 cur 变成了 replace。
cpp
if (replaceparent->left == replace)
{
replaceparent->left = replace->right;
}
else if (replaceparent->right == replace)
{
replaceparent->right = replace->right;
}
由于 replace 是右子树中的最小结点,它一定没有左孩子,最多只可能存在一个右孩子。因此删除它时可以按照托孤法处理。
只需要让 replaceparent 指向 replace->right,即可把 replace 从树结构中摘除。
cpp
cur = replace;
}
最后将 cur 指向 replace,在后续统一执行 delete(cur),完成结点释放。
七,二叉搜索树的其他接口代码
这里不必细讲了,大家应该也能看得懂
7.1交换函数
cpp
void swap(SBTree& other)
{
std::swap(_root, other._root);
}
7.2中序遍历
cpp
void Inorder()
{
inorder(_root);
cout << endl;
}
void inorder(Node* root)
{
if (root == nullptr)
{
return;
}
inorder(root->left);
cout << root->_key << " " << root->_value << endl;
inorder(root->right);
}
7.3强制构造和拷贝构造
cpp
SBTree() = default;
SBTree(const SBTree& tree)
{
_root = Copy(tree._root);
}
Node* Copy(Node* root)
{
if (root == nullptr)
{
return nullptr;
}
Node* newnode = new Node(root->_key, root->_value);
newnode->left = Copy(root->left);
newnode->right = Copy(root->right);
return newnode;
}
7.4赋值重载
cpp
SBTree& operator= (const SBTree tree)
{
swap(tree);
return *this;
}
7.5析构函数
cpp
~SBTree()
{
destory(_root);
_root = nullptr;
}
void destory(Node* root)
{
if (root == nullptr)
{
return;
}
destory(root->left);
destory(root->right);
delete(root);
}
八,key搜索场景/key-value搜索场景
8.1 key搜索场景
只有key作为关键码,结构中只需要存储key即可 ,关键码即为需要搜索到的值,搜索场景只需要判断key在不在 。key的搜索场景实现的二叉树搜索树支持增删查,但是不支持修改,修改key破坏搜索树结构了。
场景1:小区无人值守车库,小区车库买了车位的业主车才能进小区,那么物业会把买了车位的业主的车牌号录入后台系统,车辆进入时扫描车牌在不在系统中,在则抬杆,不在则提示非本小区车辆,无法进入。
场景2:检查一篇英文文章单词拼写是否正确,将词库中所有单词放入二叉搜索树,读取文章中的单词,查找是否在二叉搜索树中,不在则波浪线标红提示。
8.2 key/value搜索场景
每一个关键码key,都有与之对应的值value ,value可以任意类型对象。树的结构中(结点)除了需要存储key还要存储对应的value ,增/删/查还是以key为关键字走二叉搜索树的规则进行比较,可以快速查找到key对应的value。key/value的搜索场景实现的二叉树搜索树支持修改,但是不支持修改key,修改key破坏搜索树性质了,可以修改value。
场景1:简单中英互译字典,树的结构中(结点)存储key(英文)和vlauе(中文),搜索时输入英文,则同时查找到了英文对应的中文。
场景2:商场无人值守车库,入口进场时扫描车牌,记录车牌和入场时间,出口离场时,扫描车牌,查找入场时间,用当前时间-入场时间计算出停车时长,计算出停车费用, 缴费后抬杆,车辆离场。
场景3:统计一篇文章中单词出现的次数,读取一个单词,查找单词是否存在,不存在这个说明第一次出现,(单词,1),单词存在,则++单词对应的次数。
8.3 key/value搜索场景代码
我之前带着大家写的是key搜索场景的代码,key/value搜索场景的代码只需要在次基础上稍作修改即可,以下呈现给大家。
cpp
namespace key_value{
template <class K, class V>
class SBTnode{
public:
K _key;V _value;
SBTnode<K, V>* left;
SBTnode<K, V>* right;
SBTnode(const K& key, const V& value)
:_key(key)
, _value(value)
, left(nullptr)
, right(nullptr)
{}
};
template <class K, class V>
class SBTree{
using Node = SBTnode<K, V>;
public:
void swap(SBTree& other){std::swap(_root, other._root);}
void Inorder(){
inorder(_root);
cout << endl;
}
void inorder(Node* root){
if (root == nullptr)
return;
inorder(root->left);
cout << root->_key << " " << root->_value << endl;
inorder(root->right);
}
SBTree() = default;
SBTree(const SBTree& tree){_root = Copy(tree._root);}
Node* Copy(Node* root){
if (root == nullptr)
return nullptr;
Node* newnode = new Node(root->_key, root->_value);
newnode->left = Copy(root->left);
newnode->right = Copy(root->right);
return newnode;
}
SBTree& operator= (const SBTree tree){
swap(tree);
return *this;
}
~SBTree(){
destory(_root);
_root = nullptr;
}
void destory(Node* root){
if (root == nullptr)
return;
destory(root->left);
destory(root->right);
delete(root);
}
Node* Find(const K& key){
Node* cur = _root;
while (cur){
if (cur->_key < key)
cur = cur->right;
else if (cur->_key > key)
cur = cur->left;
else
return cur;
}
return nullptr;
}
bool Insert(const K& key, const V& value){
Node* cur = _root;
Node* parent = nullptr;
if (_root == nullptr){
_root = new Node(key, value);
return true;
}
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, value);
if (parent->_key > key)
parent->left = cur;
else if (parent->_key < key)
parent->right = cur;
return true;
}
bool Erase(const K& key){
Node* parent = nullptr;
Node* cur = _root;
while (cur){
if (cur->_key < key){
parent = cur;
cur = cur->right;
}
else if (cur->_key > key){
parent = cur;
cur = cur->left;
}
else
{
if (cur->left == nullptr){
if (cur == _root)
_root = cur->right;
else{
if (parent->left == cur)
parent->left = cur->right;
else if (parent->right == cur)
parent->right = cur->right;
}
}
else if (cur->right == nullptr){
if (cur == _root)
_root = cur->left;
else{
if (parent->left == cur)
parent->left = cur->left;
else if (parent->right == cur)
parent->right = cur->left;
}
}
else{
Node* replaceparent = cur;
Node* replace = cur->right;
while (replace->left){
replaceparent = replace;
replace = replace->left;
}
cur->_key = replace->_key;
if (replaceparent->left == replace)
replaceparent->left = replace->right;
else if (replaceparent->right == replace)
replaceparent->right = replace->right;
cur = replace;
}
delete (cur);
return true;
}
}
return false;
}
protected:
Node* _root=nullptr;
};
}
九,后语和未来连载规划
从本文开始,本人将开始连载C++中面试经常出现的高效查找容器和方法。

最初我们学习了二分查找法,但由于需要移动数据和排序的限制,其效率并不总是理想,使用场景也相对有限。
于是出现了二叉搜索树(由二叉树演变而来 )和哈希表。二叉搜索树自身存在一些缺陷,因此诞生了平衡二叉搜索树的代表------AVL树 。随着算法的发展,又出现了另一种平衡搜索树------红黑树 。在此基础上,C++提供了set和map两个容器 ,分别适用于不同的搜索需求。而哈希表则沿着另一条赛道成为高效查找的新星。
好了,本期内容到此结束,我是此方,我们下期再见。バイバイ!