【数据结构】基础:二叉搜索树
摘要:本文为二叉树的进阶,主要介绍其概念与基本实现(递归与非递归),再介绍其应用,主要介绍内容为KV模型。最后为简单的性能分析。
文章目录
- 【数据结构】基础:二叉搜索树
- 一、概念
- 二、二叉搜索树的实现
- 三、非递归实现相关操作
- 3.1 插入
- 3.2 查找
- 3.3 删除
- 四、递归实现相关操作
- 4.1 插入
- 4.2 查找
- 4.3 删除
- 五、应用
- 六、性能分析
- 六、性能分析
一、概念
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树
二、二叉搜索树的实现
在此使用c++
进行最普通的二叉搜索树的实现,首先需要对节点进行定义,节点包括的内容为左右指针以及键值,其次再对该树进行定义,只需要对其根节点进行定义即可,代码示例如下:
template <class K>
class BinarySearchTreeNode {
public:
BinarySearchTreeNode<K>* left_;
BinarySearchTreeNode<K>* right_;
K key_;
BinarySearchTreeNode(const K& key)
:left_(nullptr)
, right_(nullptr)
, key_(key)
{}
};
template <class K>
class BinarySearchTree {
private:
BinarySearchTreeNode<K>* root_;
public:
BinarySearchTree()
:root_(nullptr)
{}
};
三、非递归实现相关操作
3.1 插入
与一般的二叉树不同,插入节点时需要保护二叉搜索树的结构,因此对以下情况进行分类讨论:
- 对于空树,直接构建节点并插入
- 若不为空树,根据二叉搜索树的特性,寻找合适位置进行插入
代码示例如下:
bool Insert(const K& key) {
// 对于空树,直接构建节点并插入
if (root_ == nullptr) {
root_ = new BinarySearchTreeNode<K>(key);
return true;
}
// 若不为空树,根据二叉搜索树的特性,寻找合适位置进行插入
BinarySearchTreeNode<K>* parent = nullptr;
BinarySearchTreeNode<K>* cur = root_;
while (cur) {
if (cur->key_ < key) {
parent = cur;
cur = parent->right_;
}
else if (cur->key_ > key) {
parent = cur;
cur = parent->left_;
}
else
return false;
}
cur = new BinarySearchTreeNode<K>(key);
// 虽然找到位置,但忘记了是插到左边还是右边,重新判断一下
if (cur->key_ < parent->key_) {
parent->left_ = cur;
}
else {
parent->right_ = cur;
}
return true;
}
3.2 查找
根据二叉搜索树的性质进行查找,当遇到较大的节点找右子树,较小的节点找左子树,相等返回,如果不存在则返回空节点
bool Find(const K& key) {
BinarySearchTreeNode<K>* cur = root_;
while (cur) {
if (cur->key_ == key) {
return true;
}
else if (cur->key_ > key) {
cur = cur->right_;
}
else if (cur->key_ < key) {
cur = cur->left_;
}
}
return false;
}
3.3 删除
对于二叉搜索树的删除是较为复杂的,当删除叶子节点或只有一个子节点的节点是较为简单的,因为这样不太困难的保持树的性质,然而对于存在两个叶子节点的树删除过程是比较困难的,为此需要对各个情况进行分类讨论,过程如下:
情况一:需要删去的节点cur
没有子节点
此时,只需要将父节点parent
指向该节点指针指向空指针nullptr
即可,也可以理解为指向该节点cur
的子节点。
情况二:需要删去的节点cur
存在一个子节点
此时需要将父节点parent
指向该节点cur
指向需要删去的节点cur
的非空子节点
以上两种情况可以将其归类为同一种删除方式,即将父节点指向需要删除节点的子节点,如果子节点非空,则优先指向该节点。
因此可写下如下代码:
// 情况一:不存在子节点
// 情况二:存在一个子节点,该子节点为右节点
if (cur->left_ == nullptr) {
if (parent->left_ == cur) {
parent->left_ = cur->right_;
}
else {
parent->right_ = cur->right_;
}
}
// 情况二:存在一个子节点,该子节点为左节点
else if (cur->right_ == nullptr){
if (parent->left_ == cur) {
parent->left_ = cur->left_;
}
else {
parent->right_ = cur->left_;
}
}
但是,同时需要考虑到如果需要删除的节点是根节点的情况,此时的parent
是一个空节点,此特殊情况直接对根节点进行设置即可,代码修改如下:
// 情况一:不存在子节点
// 情况二:存在一个子节点,该子节点为右节点
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;
}
// 情况二:存在一个子节点,该子节点为左节点
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;
}
情况三:存在两个子节点,此时采用替代法。所谓替代法就是在子树中寻找一个适合的节点进行替换,一般为左子树的最大节点或右子树的最小节点。此处以右子树的最小节点进行举例,对于右子树的最小节点无疑有两种情况,分别为不存在子节点或者只存在右节点,因为不符合以上两种状态就是不是右子树最小节点。
方法为:
- 从需要删除的右子树开始找最小子节点,由于二叉搜索树的特性,比较小的节点只能在右节点,如果右节点不存在则没有比其更小的节点,此处使用递归完成寻找
- 由于进行替换后,还需要保持二叉树的连接,需要引入最小节点的父节点,该父节点需要赋值为需要删除的节点,如果赋值为空的话,可能会因为最小节点是右子树的根,此时父节点仍然是空,会导致程序崩溃
- 在找到最小节点后,进行替换,覆盖和交换都无所谓,删除方法,因为与值无关
- 在删除前,需要连接好二叉树,将父节点原本指向最小节点的指针指向最小节点的子节点,非空节点优先,而且只有是右节点有可能,因此直接指向右节点即可
// 情况三:存在两个子节点
else {
// 从需要删除的右子树开始找最小子节点
BinarySearchTreeNode<K>* min = cur->right_;
// 对应的父节点
BinarySearchTreeNode<K>* minParent = cur;
// 找最小节点
while (min->left_) {
minParent = min;
min = min->left_;
}
// 替换:覆盖法
cur->key_ = min->key_;
// 将父节点原本指向最小节点的指针指向最小节点的子节点,非空节点优先(只有右节点有可能)
if (minParent->left_ == min) {
minParent->left_ = min->right_;
}
else {
minParent->right_ = min->right_;
}
delete min;
}
完整代码如下:
bool Erase(const K& key) {
BinarySearchTreeNode<K>* parent = nullptr;
BinarySearchTreeNode<K>* cur = root_;
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;
}
// 情况二:存在一个子节点,该子节点为左节点
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;
}
// 情况三:存在两个子节点
else {
// 从需要删除的右子树开始找最小子节点
BinarySearchTreeNode<K>* min = cur->right_;
// 对应的父节点
BinarySearchTreeNode<K>* minParent = cur;
// 找最小节点
while (min->left_) {
minParent = min;
min = min->left_;
}
// 替换:覆盖法
cur->key_ = min->key_;
// 将父节点原本指向最小节点的指针指向最小节点的子节点,非空节点优先(只有右节点有可能)
if (minParent->left_ == min) {
minParent->left_ = min->right_;
}
else {
minParent->right_ = min->right_;
}
delete min;
}
return true;
}
}
return false;
}
四、递归实现相关操作
4.1 插入
使用递归实现,我们可以将插入转换为子问题,如果空树,将会直接插入,如果不是空树且不存在与树中,将转换为插入到子树的子问题中,进行递归调用。需要注意的是这里的传参,可以使用引用或双指针,否则这里的递归传参是无效的传参。
由于这里设计的树的根节点是私有成员,因此对该递归函数进行了封装,代码示例如下:
public:
bool Insert_Recursion(const K& key) {
return _Insert_Recursion(root_, key);
}
private:
bool _Insert_Recursion(BinarySearchTreeNode<K>*& root, const K& key) {
if (root == nullptr) {
root = new BinarySearchTreeNode<K>(key);
return true;
}
if (key > root->key_) {
return _Insert_Recursion(root->right_, key);
}
else if (key < root->key_) {
return _Insert_Recursion(root->left_, key);
}
else
return false;
}
4.2 查找
查找问题同样可以将其转换为子问题,但查找的树是空树或者找到了对应的节点时结束递归,否则根据大小转换为子树查找的子问题。代码示例如下:
public:
BinarySearchTreeNode<K>* Find_Recurison(const K& key) {
return _Find_Recurison(root_, key);
}
private:
BinarySearchTreeNode<K>* _Find_Recurison(BinarySearchTreeNode<K>* root, const K& key) {
if (root == nullptr) {
return nullptr;
}
else if (key == root->key_) {
return root;
}
else if (key < root->key_) {
return _Find_Recurison(root->left_, key);
}
else {
return _Find_Recurison(root->right_, key);
}
}
4.3 删除
同样的思路将删除转换为子问题进行解决:
根据二叉树的性质将删除问题转换为子树进行删除的问题,结束条件为删除成功或者是该点不存在,当该点存在时,进行删除,判断其节点个数:
- 若无节点或者只有一个节点,将父节点与子节点连接,优先非空子节点(由于是引用传递,因此传入的是父节点的别名,直接赋值即可)。
- 如果存在两个节点,找出合适节点替换,如右子树最小节点,再进行递归删除该节点的右子树。
注意:非递归在书写时是不可以递归调用非递归删除函数的,因为非递归删除是从根节点开始删除,因此对于以根节点的二叉树来说,是不符合二叉搜索树的性质的。
public:
bool Erase_Recursion(const K& key) {
return _Erase_Recursion(root_, key);
}
private:
bool _Erase_Recursion(BinarySearchTreeNode<K>*& root, const K& key) {
if (root == nullptr) {
return false;
}
if (root->key_ < key) {
_Erase_Recursion(root->right_, key);
}
else if(root->key_ > key) {
_Erase_Recursion(root->left_, key);
}
else {
BinarySearchTreeNode<K>* del = root;
if (root->left_ == nullptr) {
root = root->right_;
}
else if (root->right_ == nullptr) {
root = root->left_;
}
else {
BinarySearchTreeNode<K>* min = root->right_;
while (min->left_)
{
min = min->left_;
}
std::swap(min->key_, root->key_);
return _Erase_Recursion(root->right_, key);
}
delete del;
return true;
}
}
五、应用
二叉搜索树的一般应用为:
- 搜索功能:Key搜索模型、Key/Value搜索模型
- 排序与去重功能
此处重点介绍Key搜索模型与Key/Value搜索模型
- K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值
- KV模型:每一个关键码key,都有与之对应的值Value,即<Key, Value>的键值对。
在之前实现的是K模型的,树的节点只有Key作为关键码储存,而对于KV模型,则需要用键值对进行储存。实际上可以简单的理解为一种检索,因此只需要对K模型进行简单的修改就可以完成KV模型的实现,主要步骤如下:
引入新的成员
Value
,类型为由模板进行设定对相应的构造函数、插入函数进行修改,由于这些是与
value
相关的值
代码示例如下:
namespace ns_KeyValue {
template <class K,class V>
class BinarySearchTreeNode {
public:
BinarySearchTreeNode<K,V>* left_;
BinarySearchTreeNode<K,V>* right_;
K key_;
V value_;
BinarySearchTreeNode(const K& key,const V& value)
:left_(nullptr)
, right_(nullptr)
, key_(key)
, value_(value)
{}
};
template <class K,class V>
class BinarySearchTree {
typedef BinarySearchTreeNode<K, V> BinarySearchTreeNode;
private:
BinarySearchTreeNode* root_;
private:
void _InOrder(BinarySearchTreeNode* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->left_);
cout << root->key_ << ":" << root->value_ << endl;
_InOrder(root->right_);
}
public:
BinarySearchTree()
:root_(nullptr)
{}
BinarySearchTreeNode* Find(const K& key){
BinarySearchTreeNode* 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) {
if (root_ == nullptr) {
root_ = new BinarySearchTreeNode(key,value);
return true;
}
BinarySearchTreeNode* parent = nullptr;
BinarySearchTreeNode* cur = root_;
while (cur) {
if (cur->key_ < key) {
parent = cur;
cur = parent->right_;
}
else if (cur->key_ > key) {
parent = cur;
cur = parent->left_;
}
else
return false;
}
cur = new BinarySearchTreeNode(key,value);
if (cur->key_ < parent->key_) {
parent->left_ = cur;
}
else {
parent->right_ = cur;
}
return true;
}
bool Erase(const K& key) {
BinarySearchTreeNode* parent = nullptr;
BinarySearchTreeNode* cur = root_;
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;
}
// 情况二:存在一个子节点,该子节点为左节点
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;
}
// 情况三:存在两个子节点
else {
// 从需要删除的右子树开始找最小子节点
BinarySearchTreeNode* min = cur->right_;
// 对应的父节点
BinarySearchTreeNode* minParent = cur;
// 找最小节点
while (min->left_) {
minParent = min;
min = min->left_;
}
// 替换:覆盖法
cur->key_ = min->key_;
// 将父节点原本指向最小节点的指针指向最小节点的子节点,非空节点优先(只有右节点有可能)
if (minParent->left_ == min) {
minParent->left_ = min->right_;
}
else {
minParent->right_ = min->right_;
}
delete min;
}
return true;
}
}
return false;
}
void InOrder()
{
_InOrder(root_);
cout << endl;
}
};
}
KV模型实例:英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文<word, chinese>就构成一种键值对,在此使用KV模型进行查找与使用,并打印对应的提示信息,代码示例如下:
void Test_Chinese_English() {
BinarySearchTree<string, string> CE_KVTree;
CE_KVTree.Insert("left", "左");
CE_KVTree.Insert("right", "右");
CE_KVTree.Insert("up", "上");
CE_KVTree.Insert("down", "下");
CE_KVTree.InOrder();
string word;
while (cin>>word )
{
BinarySearchTreeNode<string,string>* ret = CE_KVTree.Find(word);
if (ret == nullptr) {
cout << "Without this word\n";
}
else {
cout << ret->value_ << endl;
}
}
}
down:下
left:左
right:右
up:上
down
下
left
左
right
右
up
上
dkfjld
Without this word
六、性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
- 最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为:log2(N)
- 最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为:N/2
补充:
- 代码将会放到:C++/C/数据结构代码链接 ,欢迎查看!
E_KVTree.Find(word);
if (ret == nullptr) {
cout << “Without this word\n”;
}
else {
cout << ret->value_ << endl;
}
}
}
```shell
down:下
left:左
right:右
up:上
down
下
left
左
right
右
up
上
dkfjld
Without this word
六、性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
- 最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为:log2(N)
- 最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为:N/2
[外链图片转存中…(img-eTDXJ0II-1675151605450)]
补充:
- 代码将会放到:C++/C/数据结构代码链接 ,欢迎查看!
- 欢迎各位点赞、评论、收藏与关注,大家的支持是我更新的动力,我会继续不断地分享更多的知识!