二叉搜索树

二叉搜索树
苏丙榅1. 二叉树搜索树概述
二叉搜索树(Binary Search Tree, BST)也叫二叉排序树(Binary Sort Tree),是一种特殊的二叉树,它满足以下性质:
- 左子树:左子树上所有节点的值都小于根节点的值。
- 右子树:右子树上所有节点的值都大于根节点的值。
- 递归性质:左子树和右子树本身也是二叉排序树。
二叉搜索树的一个重要特性是,它的中序遍历(左 → 根 → 右)结果是一个有序的序列。因此,二叉搜索树常用于实现动态集合的查找、插入和删除操作。
2. 二叉搜索树的基本操作
2.1 查找操作
二叉搜索树(BST)的查找操作是一种高效的算法,其平均时间复杂度为 O(log n)
。查找步骤是基于二叉搜索树的特性:对于每个节点,左子树的所有节点值小于当前节点,右子树的所有节点值大于当前节点。以下是详细的查找步骤:
- 从根节点开始:查找操作必须从二叉搜索树的根节点开始。
- 比较目标值与当前节点值
- 如果 目标值 == 当前节点值,查找成功,返回该节点。
- 如果 目标值 < 当前节点值,则递归查找左子树。
- 如果 目标值 > 当前节点值,则递归查找右子树。
- 递归或迭代查找:重复上述比较过程,直到找到目标值或到达叶子节点(查找失败)。
- 查找结束
- 如果找到目标值,返回对应的节点。
- 如果到达叶子节点仍未找到目标值,则返回
nullptr
或表示查找失败的值。
以下图的二叉搜索树为例,查找目标 7,具体查找过程如下:
- 从根节点 8 开始
- 7 < 8,向左子树查找。
- 访问节点 3
- 7 > 3,向右子树查找。
- 访问节点 6
- 7 > 6,向右子树查找。
- 访问节点 7
- 7 == 7,查找成功,返回节点 7。
2.2 插入操作
二叉搜索树(BST)的插入操作是构建和维护二叉搜索树的关键操作之一。平均情况下,插入操作的时间复杂度为 O(log n)
,其中 n 是树中节点的数量。插入操作的核心思想是利用二叉搜索树的性质,将新节点插入到正确的位置,保持树的结构。以下是详细的插入步骤:
- 从根节点开始:插入操作从二叉搜索树的根节点开始。如果树为空,则新节点成为根节点。
- 比较新节点的值与当前节点的值
- 如果 新节点的值 < 当前节点的值,递归向左子树插入。
- 如果 新节点的值 > 当前节点的值,递归向右子树插入。
- 如果 新节点的值 == 当前节点的值,根据具体需求决定是否插入(BST 中通常不允许重复值)。
- 找到插入位置:重复上述比较过程,直到找到一个空位置(即
nullptr
),然后将新节点插入到该位置。 - 插入完成:新节点被插入到树中,树的结构保持不变。
还是以上图的二叉搜索树为例,插入目标值 5,具体插入过程如下:
- 从根节点 8 开始
- 5 < 8,向左子树插入。
- 访问节点 3
- 5 > 3,向右子树插入。
- 访问节点 6
- 5 < 6,向左子树插入。
- 访问节点 4
- 5 > 4,向右子树插入。
- 找到空位置
- 节点 4 的右子节点为空,将新节点 5 插入到此处。
2.3 删除操作
二叉搜索树(BST)的删除操作是构建和维护二叉搜索树的关键操作之一。平均情况下,删除操作的时间复杂度为 O(log n)
,其中 n 是树中节点的数量。删除节点需要保持二叉搜索树的性质(左子树的所有值小于根节点,右子树的所有值大于根节点)。删除操作有以下三种情况:
- 删除叶子节点:如果待删除节点没有子节点,直接删除即可。
- 删除只有一个子节点的节点:如果待删除节点只有一个子节点,用其子节点替代被删除的节点。
- 删除有两个子节点的节点:
- 找到其右子树的最小值节点(或左子树的最大值节点)
- 用该节点的值替换待删除节点的值,然后删除该节点。
删除节点的详细步骤如下:
查找待删除节点:从根节点开始,递归或迭代查找待删除节点。
根据情况删除节点
情况 1:待删除节点是叶子节点,直接删除该节点,并将其父节点的对应指针设置为
nullptr
。例如,删除叶子节点 8,删除前的二叉搜索树如下:
1
2
3
4
53
/ \
1 6
/ \
4 8删除后的二叉搜索树如下:
1
2
3
4
53
/ \
1 6
/
4情况 2:待删除节点只有一个子节点,用其子节点替代被删除的节点,并更新父节点的指针。
例如,删除节点 6(只有一个右子节点),删除前的二叉搜索树如下:
1
2
3
4
53
/ \
1 6
/
4删除后的二叉搜索树如下:
1
2
33
/ \
1 4情况 3:待删除节点有两个子节点,找到其右子树的最小值节点(或左子树的最大值节点),用该节点的值替换待删除节点的值,然后删除该节点。
例如,删除节点 3,删除前的二叉搜索树如下:
1
2
3
4
53
/ \
1 6
/ \
4 8- 找到右子树的最小值节点 4。
- 将节点 3 的值替换为 4。
- 删除节点 4。
删除后的二叉搜索树如下:
1
2
3
4
54
/ \
1 6
\
8
3. 二叉搜索树的实现
对于一棵二叉搜索树而言,在它的每个节点中存储的数据可以是基础类型,也可以是复合类型,所以为了增强数据的处理能力,我们可以可以把二叉搜索树类定义成一个泛型类:
1 | // bstree.hpp |
template<typename T, typename CompFunc = less<T>>
是 C++ 中的模板声明语法,用于定义一个通用的类或函数,使其能够处理多种数据类型。
template<typename T>
表示定义一个模板,T
是一个占位符类型,可以在实例化时替换为具体的类型(如int
、double
、std::string
等)。typename CompFunc = less<T>
表示第二个模板参数CompFunc
,默认值为std::less<T>
,即默认使用std::less
来比较两个T
类型的值。CompFunc
可以是一个函数对象或函数指针,用于定义自定义的比较逻辑。
std::less<T>
的定义如下:
1 | template<typename T> |
operator()
:这是std::less<T>
的核心函数,用于比较两个值lhs
和rhs
。- 返回值:如果
lhs < rhs
,返回true
;否则返回false
。
如果想要自定义比较函数,按照上面的std::less<T>
示例如法炮制即可, 比如:
1 | template<typename T> |
3.1 构造和析构
C++ 的模板是一种编译时机制,模板代码在编译时根据具体类型生成具体的代码(即模板实例化)。与普通函数或类不同,模板的定义和实现不能分离到 .h
和 .cpp
文件中,原因如下:
普通函数/类的编译模型:
- 声明(
.h
文件):告诉编译器函数或类的存在和接口。 - 定义(
.cpp
文件):提供函数或类的具体实现。 - 编译器在编译
.cpp
文件时生成目标代码,链接器在链接时将声明和定义关联起来。
模板的编译模型:
- 模板的实例化需要在编译时完成,因此编译器必须能够看到模板的完整定义(包括实现)。
- 如果将模板的实现放到
.cpp
文件中,编译器在编译其他使用模板的源文件时无法看到模板的实现,导致无法实例化模板。 - 对于模板代码,编译器的处理流程如下:
- 词法分析和语法分析:与非模板代码相同。
- 模板解析:解析模板定义,但不生成代码。
- 模板实例化:在模板被使用时(如
BST<int>
),生成具体的代码。 - 代码生成:生成目标机器代码。
为了防止编译器无法看到模板类的完整实现,导致实例化失败,我们通常会将模板的定义和实现都写在同一个 .hpp
文件中。下面是定义的二叉搜索类的构造和析构函数的实现代码:
1 | // bstree.hpp |
3.1.1 构造函数
1 | template<typename T, typename CompFunc> |
功能:
- 初始化 BST 的根节点
m_root
为nullptr
,表示树为空。 - 初始化比较函数对象
m_comp
,用于比较节点值。
参数:
comp
:比较函数对象,默认值为CompFunc()
(即std::less<T>
,用于升序排序)。
示例:
1 | BST<int> bst; // 使用默认比较函数 std::less<int> |
3.1.2 析构函数
1 | template<typename T, typename CompFunc> |
功能:
- 调用
clear(m_root)
递归释放树中所有节点的内存。 - 将根节点
m_root
置为nullptr
,避免悬空指针。
实现细节:
clear(Node* root)
是一个辅助函数,用于递归释放树中所有节点的内存。- 递归释放过程:
- 如果当前节点为空,直接返回。
- 递归释放左子树。
- 递归释放右子树。
- 释放当前节点。
3.2 插入操作
3.2.1 非递归实现
在二叉搜索树种插入新节点,核心逻辑如下:
- 树为空:
- 如果根节点
m_root
为空,直接将新节点作为根节点。
- 如果根节点
- 搜索插入位置:
- 使用
cur
指针从根节点开始遍历树。 - 使用
parent
指针保存cur
的父节点地址。 - 如果当前节点的值等于待插入值,提示插入失败并返回。
- 如果当前节点的值小于待插入值,移动到右子树。
- 如果当前节点的值大于待插入值,移动到左子树。
- 使用
- 插入新节点:
- 根据比较结果,将新节点插入到
parent
的左孩子或右孩子位置。
- 根据比较结果,将新节点插入到
因此,我们可以写出如下代码:
1 | // bstree.hpp |
3.2.2 递归实现
根据上面 BST 类的定义可以看出,在进行节点递归插入的时候对 insert 函数进行了重载:
- 外部接口:
void insert(const T& value)
- 内部接口:
void insert(Node* root, const T& value)
因为在进行递归操作的时候,需要不停的对树中的节点进行切换,因此需要提供一个Node 类型
的参数,又由于对树的操作是从根节点开始的,而我们定义的BST类
的根节点是私有成员,在类外部不可见,因此在类中又提供了一个重载的外部接口insert
,在外部接口insert
中对内部接口insert
进行调用。
1 | // bstree.hpp |
将新节点添加到二叉搜索树的核心思想如下:
递归的返回值:
- 在递归插入操作中,每次递归调用都会返回当前子树的根节点。
- 对于非空节点,返回的是当前节点本身(
root
),目的是为了保持当前子树的结构不变。 - 对于空节点,返回的是新创建的节点,这个节点会被赋值给其父节点的左指针或右指针。
维护树的结构:返回
root
的目的是将新节点连接到其父节点的左子树或右子树。- 如果插入到右子树,
root->right = insert(root->right, value)
,返回的root
会更新右子树的指针。 - 如果插入到左子树,
root->left = insert(root->left, value)
,返回的root
会更新左子树的指针。
- 如果插入到右子树,
递归的终止条件:当递归到空节点时,返回新创建的节点,这个节点会被赋值给其父节点的左指针或右指针。
1
2
3
4if (root == nullptr)
{
return new Node(value); // 返回新节点
}
关于第二个重载函数insert
的返回值的语法问题,typename BST<T, CompFunc>::Node*
这种语法用于表示嵌套依赖类型。关于它的一些细节总结如下:
嵌套依赖类型
在 C++ 中,嵌套依赖类型是指模板类或模板函数中,依赖于模板参数的类型。这些类型通常是嵌套在模板类中的成员类型,或者是通过模板参数间接定义的类型。
在这里,
BST<T, CompFunc>::Node
是一个嵌套依赖类型。编译器在解析代码时无法直接确定Node
是一个类型还是一个成员变量或其他内容,因为模板参数T
和CompFunc
是未知的。为了告诉编译器Node
是一个类型,而不是其他东西,必须使用typename
关键字。1
2
3
4
5template<typename T, typename CompFunc>
typename BST<T, CompFunc>::Node* BST<T, CompFunc>::queryNormal(const T& value)
{
// ...
}BST<T, CompFunc>
是一个模板类。Node
是BST<T, CompFunc>
的嵌套类型。
关于
typename
关键字的说明在模板中,编译器无法确定嵌套依赖类型是一个类型还是一个成员变量,因为在模板中类型和函数的确定会延迟到模板实例化时,所以代码生成是延迟的。因此,必须使用
typename
关键字来明确告诉编译器这是一个类型。例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21template<typename T>
class MyClass
{
public:
using value_type = T; // 定义 value_type 为 T 的别名
T value;
};
template<typename T>
void foo()
{
typename T::value_type x; // 等价于 T x;
x = 10;
std::cout << "x: " << x << std::endl;
}
int main()
{
foo<MyClass<int>>(); // T::value_type 是 int
return 0;
}typename
只能用于嵌套依赖类型:如果类型不依赖于模板参数,则不需要使用typename
。typename
不能用于非类型成员:例如,typename T::value
是错误的,因为value
是一个成员变量,而不是类型。typename
和class
的区别:在模板参数中,typename
和class
可以互换,但在嵌套依赖类型中,只能使用typename
。
当
foo
函数被调用时,模板参数T
会被具体化。在这里:1
2
3
4
5int main()
{
foo<MyClass<int>>(); // T 被实例化为 MyClass<int>
return 0;
}T
是MyClass<int>
,因此T::value_type
是int
。typename T::value_type x;
等价于int x;
。
3.3 删除操作
从二叉搜索树中删除一个值为 value
的节点。删除过程中需要处理三种不同的情况:
- 情况1:待删除节点没有子节点(叶子节点)。
- 情况2:待删除节点只有一个子节点。
- 情况3:待删除节点有两个子节点。
3.3.1 非递归实现
1 | // bstree.hpp |
在上面的示例代码中,函数的具体处理流程如下:
检查空树
- 如果树为空(
m_root
为nullptr
),直接返回false
,表示删除失败。
- 如果树为空(
搜索待删除节点
从根节点开始,使用比较函数
m_comp
查找值为value
的节点。m_comp(cur->data, value)
返回true
表示cur->data < value
,因此向右子树搜索;否则向左子树搜索。
检查是否找到待删除节点
- 如果没有找到值为
value
的节点,返回false
,表示删除失败。
- 如果没有找到值为
处理情况3(待删除节点有两个子节点)
如果待删除节点有两个子节点,找到其左子树中的最大节点(前驱)或右子树中的最小节点(后继)。
这里选择左子树的最大节点(前驱),通过不断向右遍历左子树找到。
将前驱节点的值覆盖到待删除节点,然后将
cur
指向前驱节点。这样,问题转化为删除前驱节点(前驱节点最多只有一个子节点,即情况1或2)。
处理情况1或2(待删除节点有一个或没有子节点)
找到待删除节点的子节点(情况2)。
- 如果左子节点存在,
child
指向左子节点。 - 如果左子节点不存在,
child
指向右子节点。
- 如果左子节点存在,
如果左右子节点都不存在,
child
为nullptr
(情况1)。
调整父节点指针
如果待删除节点是根节点(
parent
为nullptr
),直接将根节点指向child
。否则,根据待删除节点是父节点的左子节点还是右子节点,将父节点的指针指向
child
。
删除节点
释放待删除节点的内存。
返回
true
,表示删除成功。
3.3.2 递归实现
与上面的非递归删除相比,使用递归方式实现代码更加简洁,逻辑更清晰。以下是示例代码:
1 | // bstree.hpp |
函数1:
remove(const T& value)
- 这是外部调用的接口函数,接收要删除的值
value
。 - 调用内部递归函数
remove(Node* root, const T& value)
,从根节点开始查找并删除节点。 - 更新根节点
m_root
为删除操作后的新根节点。
- 这是外部调用的接口函数,接收要删除的值
函数2:
remove(Node* root, const T& value)
- 这是递归删除的核心函数。
递归删除二叉搜索节点的具体流程如下:
检查空树:
- 如果当前子树为空(
root
为nullptr
),直接返回nullptr
,表示没有找到要删除的节点。
- 如果当前子树为空(
查找待删除节点
如果当前节点的值等于
value
,进入删除逻辑。如果当前节点的值小于
value
,递归查找右子树。如果当前节点的值大于
value
,递归查找左子树。
处理删除逻辑
情况3:待删除节点有两个子节点
- 如果待删除节点有两个子节点,找到其左子树中的最大节点(前驱)或右子树中最小节点(后继)。
- 用前驱节点(或后继节点)的值覆盖待删除节点的值。
- 递归删除前驱(或者后继)节点(此时前驱/后继节点最多只有一个子节点,即情况1或2)。
情况1或2:待删除节点有一个或没有子节点
- 如果待删除节点只有左子节点,删除当前节点并返回左子节点。
- 如果待删除节点只有右子节点,删除当前节点并返回右子节点。
- 如果待删除节点没有子节点(叶子节点),删除当前节点并返回
nullptr
。
- 如果待删除节点只有左子节点,删除当前节点并返回左子节点。
返回当前子树的根节点
- 返回当前子树的根节点,用于更新父节点的指针。
3.4 查找操作
3.4.1 非递归实现
在二叉搜索树中查找一个值为 value
的节点,并返回指向该节点的指针。如果未找到,则返回 nullptr
。
非递归查找示例代码如下:
1 | // bstree.hpp |
查找函数的处理流程分为以下几步:
- 初始化变量指针:从根节点
m_root
开始遍历。 - 遍历树,比较当前节点的值:
- 如果当前节点的值等于
value
,找到了指定的节点。 - 如果当前节点的值小于
value
(m_comp(cur->data, value)
返回true
),向右子树搜索。 - 如果当前节点的值大于
value
,向左子树搜索。 - 如果遍历到
nullptr
,表示未找到值为value
的节点。
- 如果当前节点的值等于
- 返回结果
- 找到了,返回值为
value
的节点。 - 没有找到,返回
nullptr
。
- 找到了,返回值为
3.4.2 递归实现
二叉树搜索树的递归查找示例代码如下:
1 | // bstree.hpp |
函数1:
query(const T& value)
- 这是外部调用的接口函数,接收要查找的值
value
。 - 调用内部递归函数
query(Node* root, const T& value)
,从根节点开始查找。
- 这是外部调用的接口函数,接收要查找的值
函数2:
query(Node* root, const T& value)
检查空树
- 如果当前子树为空(
root
为nullptr
),直接返回nullptr
,表示未找到目标节点。
- 如果当前子树为空(
比较当前节点的值
如果当前节点的值等于
value
,返回当前节点指针。如果当前节点的值小于
value
(m_comp(root->data, value)
返回true
),递归查找右子树。如果当前节点的值大于
value
,递归查找左子树。
3.5 中序遍历
对二叉搜索树进行中序遍历就会得到一个升序的序列,中序遍历的顺序为:左子树 -> 根节点 -> 右子树。对应的代码实现如下:
1 | // bstree.hpp |
4. 测试
在一个新的源文件里,我们可以编写如下代码测试一下编写的二叉排序树类中的相关API函数:
1 | // test.cpp |
终端打印结果如下:
1 | 中序遍历二叉搜索树: 1 2 4 6 9 33 35 39 45 55 77 |