
👦个人主页:@Weraphael
✍🏻作者简介:目前学习C++和算法
✈️专栏:C++航路
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注✨
二叉搜索树(Binary Search Tree)是基于普通二叉树的一种改进版本。因为普通二叉树没有实际价值,插入、删除等操作没有意义,但二叉搜索树就不一样了。
二叉搜索树又称二叉查找树。如果不为空则满足一下性质:
因此,二叉搜索树的查找效率极高。(有点类似二分查找)
下图展示了二叉搜索树

除此之外,二叉搜索树又称做二叉排序树。中序遍历(左子树 根 右子树)的结果为升序。
一般来说,二叉树使用链表来定义,不同的是,由于二叉树每个结点都存在两条出边,因此指针域变为两个,分别指向左子树和右子树的根结点的指针,因此又把这种链表叫做二叉链表。


为了防止出现野指针问题,我们直接将根结点初始化为空nullptr

步骤:
当插入一个值时,必须先找到满足二叉搜索性质的位置
如果找到满足条件的位置并且为空,则结束循环。进行插入:创建新节点、判断需要插在左边还是右边、链接新节点


【代码实现】

new对于自定义类型除了开空间,还会调用构造函数。因此我们可以在BSTreeNode中写一个构造函数初始化_key。

步骤:
【查找成功】

【查找失败】

【代码实现】

二叉搜索树的删除是个麻烦事,需要考虑很多情况,因此大多数面试都会考察搜索二叉树的删除操作。
首先我们需要考虑,被删除的那个结点是否有孩子
因此可能会存在四种情况:


【代码实现】

二叉搜索树的遍历操作和二叉树一模一样的,因此可以有前序遍历、中序遍历以及后序遍历。但是除了中序遍历以为,其他的遍历都没有什么意义。详细遍历解析请看这篇博客 -> 点击跳转
左子树 根 右子树)的结果为升序。
由于封装的缘故,面临着一个尴尬的问题:二叉搜索树的根_root是私有,在main函数中无法直接获取
解决办法:
公有化(破坏类的封装性,不推荐)
将这种需要用到根的函数再封装(推荐)。

递归查找逻辑:
<查找值,递归至右树查找>查找值,递归至左树查找注意:二叉树的递归常常是通过将问题分解为更小规模的子问题来解决的。通过传递根节点作为参数,我们可以在每次递归中处理当前节点,并将问题分解为处理左子树和右子树的子问题。所以,递归中用到根节点是不可避免的。因此,还需要对函数进行封装。
template <class K>
class template <class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
bool Find(const K& key)
{
return _Find(_root, key);
}
private:
bool _Find(Node* root, const K& key)
{
if (root == nullptr)
return false;
// 查找的值比当前结点大,递归至右树查找
if (root->_key < key)
{
return _Find(root->_right, key);
}
// 查找的值比当前结点小,递归至左树查找
else if (root->_key > key)
{
return _Find(root->_left, key);
}
// 最后一种情况就是找到了
else
{
return true;
}
}
Node* _root;
};
这里和其它不同的是,在传参的时候使用了 引用。传引用是为了在递归查找过程中能够修改目标节点的
这么说有点抽象,可以先看代码理解;过会画完递归展开图大家就能够理解了。
template <class K>
class template <class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
bool Insert(const K& key)
{
return _Insert(_root, key);
}
private:
bool _Insert(Node*& root, const K& key)
{
// 当走到空,说明找到了合适的位置
if (root == nullptr)
{
// 还得找到父亲,为什么加个引用就搞定了?
root = new Node(key);
return true;
}
// 插入的值比当前结点大,往右找
if (root->_key < key)
{
return _Insert(root->_right, key);
}
// 插入的值比当前结点小,往左找
else if (root->_key > key)
{
return _Insert(root->_left, key);
}
// 当插入的值和树发生冗余,直接返回false
else
{
return false;
}
}
Node* _root;
};

【递归展开图】

递归删除时也使用了引用,其想法和插入一样,不需要找删除结点的父亲,直接修改目标节点的指针值
template <class K>
class template <class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
bool Earse(const K& key)
{
return _Erase(root, key);
}
private:
bool _Erase(Node* root, const K& key)
{
// 如果是空结点,说明删除失败
if (root == nullptr)
return false;
if (root->_key < key)
{
return _Erase(root->_right, key);
}
else if (root->_key > key)
{
return _Erase(root->_left, key);
}
// 找到key
else
{
Node* del = root;
// 还是分四种情况
// 1. 删除结点的左孩子为空(包含没有孩子的情况)
if (root->_left == nullptr)
{
root = root->_right;
}
// 2. 删除结点的右孩子为空
else if (root->_right == nullptr)
{
root = root->_left;
}
// 3. 删除结点孩子都不为空
else
{
//递归为小问题去处理
Node* maxLeft = root->_left;
while (maxLeft->_right)
{
maxLeft = maxLeft->_right;
}
//注意:需要交换
std::swap(root->_key, maxLeft->_key);
//注意:当前找的是左子树的最右节点,所以递归从左子树开始
return _Erase(root->_left, key);
}
delete del; //释放节点
return true;
}
return false;
}
Node* _root;
};
释放结点有很多种方式,比如可以先删除根结点,再删除其子结点,但是这样有点麻烦,因为删除了根结点,就要记录其子结点。这里有一个非常简便的方式:使用后序遍历的思想,先释放孩子,再释放双亲结点。
template <class K>
class template <class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
~BSTree()
{
_destory(_root);
}
private:
void _destory(Node*& root)
{
if (root == nullptr)
return;
//后序遍历销毁
destory(root->_left);
destory(root->_right);
delete root;
root = nullptr;
}
Node* _root;
};
销毁问题考虑完以后,就要想是否会有浅拷贝问题,因为浅拷贝问题会导致一个结点析构两次,程序就崩了。而我们在类和对象中说过,如果类中有动态分配内存的指针变量,则需要手动编写深拷贝的拷贝构造函数。
深拷贝逻辑:前序遍历的思想,逐个创建好节点,链接后才返回
template <class K>
class template <class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
BSTree(const BSTree<K>& t)
:_root(nullptr)
{
_root = CopyTree(t._root);
}
private:
Node *CopyTree(Node *root)
{
if (root == nullptr)
{
return nullptr;
}
Node *newroot = new Node(root->_key);
newroot->_left = CopyTree(root->_left);
newroot->_right = CopyTree(root->_right);
return newroot;
}
Node* _root;
};
和拷贝构造同理,需要实现深拷贝
直接写现代写法
template <class K>
class template <class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
BSTree<K>& operator=(BSTree<K> t)
{
if (this != &tree)
{
std::swap(_root, tree._root);
return *this;
}
}
private:
Node* _root;
};
我们在一开始说过,二叉搜索树的查找效率极高,具有一定的实际价值。
搜索二叉树的时间复杂度取决于树的高度。因此当是一颗平衡二叉搜索树时,时间复杂度是O(log n)。因为每次搜索都可以通过比较目标值与当前节点的值来确定向左子树还是右子树进行搜索,这样每次都可以将搜索范围减半。
是不是有点类似于二分查找,但二分查找不是一个很实用的算法。因为对比二叉搜索树来说,二分查找(底层是一个数组)的删除和插入的效率低0(n)(特别是中间插入和删除)

二叉搜索树看起来这么完美,但下限没有保障。在最坏的情况下,当搜索二叉树是一个不平衡的树时,时间复杂度为O(n),其中n是树中节点的数量。这是因为在最坏情况下,每次搜索都要遍历树的所有节点。

因此,为了解决这个问题,引入了AVL树和红黑树(后续会讲解)
只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。
Key模型的应用场景:快速判断在不在的场景比如,
以第三个为例,词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树。在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误
刚刚实现的二叉搜索树就是一个key的查找模型
#include
#include "BSTree.h"
#include
using namespace std;
int main()
{
vector<string> words = { "apple", "hello", "world", "c++" };
BSTree<string> bs;
// 存入搜索二叉树中
for (auto x : words)
{
bs.Insert(x);
}
string s;
while (cin >> s)
{
if (bs.Find(s))
{
cout << "拼写正确" << endl;
}
else
{
cout << "拼写错误" << endl;
}
}
return 0;
}
【程序结果】

key / value 的模型:通过一个值找到另一个值注:即使是key / value模型,也只是将 key作为查找、插入、删除的依据,基本逻辑与value没有任何关系,value仅仅起到一个存储额外值的作用。
key / value代码实现只展现修改部分
【结点结构】

【二叉树结构】
bool ,而是指向当前节点的指针,因为value可以被修改。当然还有插入操作template<class K, class V>
struct BSTNode
{
BSTNode<K,V>* _left;
BSTNode<K,V>* _right;
K _key;
V _value;
BSTNode(const K& key, const V& value)
:_left(nullptr)
, _right(nullptr)
, _key(key)
, _value(value)
{}
};
template<class K, class V>
class BSTree
{
typedef BSTNode<K, V> Node;
public:
BSTree()
:_root(nullptr)
{}
Node* Find(const K& key)
{
return _Find(_root, key);
}
bool Insert(const K& key, const V& value)
{
return _Insert(_root, key, value);
}
private:
Node* _Find(Node* root, const K& key)
{
if (root == nullptr)
return nullptr;
if (root->_key < key)
{
return _Find(root->_right, key);
}
else if (root->_key > key)
{
return _Find(root->_left, key);
}
else
return root;
}
bool _Insert(Node*& root, const K& key, const V& value)
{
if (root == nullptr)
{
root = new Node(key, value);
return true;
}
if (root->_key < key)
{
return _Insert(root->_right, key, value);
}
else if (root->_key > key)
{
return _Insert(root->_left, key, value);
}
else
{
return false;
}
}
private:
Node* _root;
};
常见场景:
#include
#include
#include "Kval.h"
using namespace std;
int main()
{
// 输入单词,查找单词对应的中文翻译
BSTree<string, string> dict;
dict.Insert("string", "字符串");
dict.Insert("tree", "树");
dict.Insert("left", "左边、剩余");
dict.Insert("right", "右边");
dict.Insert("sort", "排序");
// 插入词库中所有单词
string str;
while (cin >> str)
{
BSTNode<string, string>* ret = dict.Find(str);
if (ret == nullptr)
{
cout << "单词拼写错误,词库中没有这个单词:" << str << endl;
}
else
{
cout << str << "中文翻译:" << ret->_value << endl;
}
}
return 0;
}
【输出结果】

除此之外,还可以再带一个值用于统计,这其实就是哈希的思想(建立映射关系)
int main()
{
// 统计水果出现的次数
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
"苹果", "香蕉", "苹果", "香蕉" };
BSTree<string, int> countTree;
for (const auto& str : arr)
{
// 先查找水果在不在搜索树中
// 1、不在,说明水果第一次出现,则插入<水果, 1>
// 2、在,则查找到的节点中水果对应的次数++
//BSTreeNode<string, int>* ret = countTree.Find(str);
auto ret = countTree.Find(str);
if (ret == NULL)
{
countTree.Insert(str, 1);
}
else
{
ret->_value++;
}
}
countTree.InOrder();
}
【输出结果】

Gitee代码仓库:点击跳转