简单介绍一下二叉搜索树
二叉搜索树也称为二叉排序树。它是一种具有特殊性质的二叉树。它有如下性质。
1、当前节点的左子树的值一定小于当前节点,当前节点的右子树的值一锭大于当前节点。这也就意味着,在接近完全二叉树的情况下(高度较为合适的情况下),二叉搜索树的查找效率极高(O(LogN))。但是,当极端请况下,可能就会退化成链表。所以,二叉搜素树查找的最坏实践复杂度是O(N)。
2、并且当前节点的左子树和右子树都符合二叉搜索树的性质。
3、中序遍历有序性。二叉搜素树的中序遍历是一个升序序列。
为什么学二叉搜索树
在前面二叉树的博客中,提到了二叉树的增删查改没有意义。但是搜索二叉树不一样,它的增删查改是有意义的。并且学习二叉搜索树是为了后面的AVL树和红黑树打下基础。
模拟实现一份二叉搜索树
插入接口的实现
这里用的是循环的方式遍历树。实现的思路如下,首先,需要对第一次插入做一个特殊处理。即当根节点为空时,直接new 一个节点赋值给根节点。若不是第一次插入,那么则需要遍历树,根据搜索二叉树的性质找到合适的位置插入,需要注意的是,由于适合插入的位置一定是空节点,所以需要保存一下需要插入节点的父亲节点。这样才能让新插入的节点连接到树上。
下面,再实现一下递归版本的插入接口。整体还是以封装一份递归子函数来实现主体逻辑。递归版本的核心就是子函数的函数头的设计,即bool _InsertR(Node*& root, const K& key)。将第一个参数设置成实参的别名,这样修改形参root其实就是修改实参。这样的话就不用管插入的位置是在父节点的左子树还是右子树了。
递归出口就是插入数据的位置,当root走到空就插入数据并返回true。
递归主体就是当插入的值比当前节点的值大,递归去右子树找到合适的位置插入。当插入的值比当前节点的值小,递归去左子树找到合适的位置插入。当插入的值等于当前节点值,说明该元素存在,返回false。
中序遍历的实现
采用子函数递归的方式实现,因为将根节点设置成了私有成员,类外不可用。所以提供了一个无参的主函数调用类内子函数。中序其实就是先遍历左子树,访问根,再去右子树遍历。
查找接口的实现
根据搜索二叉树的特性进行一次遍历即可。当前节点的值大于要查找的值时,去当前节点的左边查找。若当前节点的值小于要查找的值时,去当前节点的右边查找。若当前节点的值等于要查找的值时,直接返回true。当遍历结束后还没找到则返回false。
下面实现一份递归版本的代码,具体的实现思路如下,依旧采用对外提供一个主函数,在类内写一个递归子函数实现查找的逻辑。
参数部分这么设计,主函数的函数头为 bool FindR(const K& key)。递归子函数的函数头为bool _FindR(Node* root, const K& key) 。
递归子函数主体逻辑实现思路如下,递归出口为当root为空时,则表示当前查找的值不存在。递归主体为当root的值比要找的值小时,去root的右子树找。当root的值比要找的值大时,去root的左子树找。当root的值等于要找的值时,返回 true。
删除接口的实现
删除接口也是二叉搜索树的最核心接口。实现起来比较的复杂,下面且听我分析,由于找到带删除元素都逻辑上面已经实现过了,这里不再赘述。下面直接讲删除的逻辑。
首先,删除的情况有三种,分别是删除的这个节点是叶子节点、删除的节点的左子树或右子树为空以及删除的这个节点既有左子树又有右子树。
其实第1种情况和第2种情况其实是可以归为一类来看的。因为这两种情况本质上都是将被删除节点左右子树关系托孤给被删除节点的父节点。这里我就以托孤法来形容这种情况。
第3种情况既可以从右子树中找到合适的节点替换,也可以从左子树中找到合适的节点替换。然后删除8这个位置,以保持二叉搜索树的特性。从右子树找最小的值与8替换或左子树找最大的值与8替换都可以。
下面先讲解托孤法,通过样例来进行讲解
假设需要删除cur这个节点。此时cur的左子树为空,此时意味着需要将cur的右子树部分托孤给parent。具体操作就是将cur->right 赋值给parent->left 。然后删除cur。
下面再看一个特殊的托孤法的场景
由于cur就是根节点,意味着我们需要对这种情况进行特殊的处理。直接修改root的值为cur的右子树。
那么托孤法主要逻辑是判断当前节点的左子树为空还是右子树为空的场景作为切入点。而叶子节点恰好符合,所以,可以在这个逻辑里面进行处理。
下面提供代码,可以分别带入上面给的样例感受一下。
下面讲解一下左右都不为空的处理逻辑。以从左子树中找最大值为例,那就是定义一个变量leftMax以根节点的左子树进行赋值,再保存leftMax的父节点。然后依次遍历leftMax的右子树,便可以找到左子树的最大值。不过需要注意一个特殊情况,就是leftMax一开始就是左子树的最大值。这个逻辑需要单独处理。当找到左子树的最大值后,用左子树的最大值和被删除的元素的值进行交换。然后修改父节点的连接关系即可。
//删除接口——迭代版本
bool Erase(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
//比cur的值小
if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
//比cur的值大
else if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
else //找到了
{
//大体分两种情况,1、托孤 2、找子树的最大值交换
//左边为空的情况
if (cur->_left == nullptr)
{
//边界情况
if (cur == _root)
{
_root = cur->_right;
}
else
{
// 需要考虑cur所处的位置
if (parent->_left == cur)
{
parent->_left = cur->_right;
}
else
{
parent->_right = cur->_right;
}
}
}
else if (cur->_right == nullptr)//右为空
{
//边界情况
if (cur == _root)
{
_root = cur->_left;
}
else
{
// 需要考虑cur所处的位置
if (parent->_left == cur)
{
parent->_left = cur->_left;
}
else
{
parent->_right = cur->_left;
}
}
}
else//左右不为空,与左子树的最大值交换
{
//这里parent不能为null,否则下面特殊情况会出错
parent = cur;
Node* leftMax = cur->_left;
while (leftMax->_right)
{
parent = leftMax;
leftMax = leftMax->_right;
}
swap(leftMax->_key, cur->_key);
//特殊情况 _root->left == leftMax
if (parent->_left == leftMax)
{
parent->_left = leftMax->_left;
}
else
{
parent->_right = leftMax->_left;
}
//符合统一删除逻辑
cur = leftMax;
}
//统一删除逻辑
delete cur;
return true;
}
}
return false;
}
下面再介绍递归版本的实现。非递归版本相比于递归版本来说是比较复杂的,但是实践当中还是推荐使用非递归版本代码。因为递归有栈溢出的风险。回到正题,下面实现递归版本代码。
首先递归的实现思路如下,先设计递归子函数的函数头,这里就说一下函数头的设计,由于删除接口会修改节点的指向,所以形参部分依旧是选择节点的指针的引用进行。bool _EraseR(Node*& root, const K& key)。主函数部分依旧是和上面的递归主函数的方案保持一致。
递归的出口部分为当root为空时,表示key不存在,直接返回false。
递归主体逻辑为当root的值比要找的值小时,去root的右子树找。当root的值比要找的值大时,去root的左子树找。当root的值等于key时,开始执行删除逻辑。首先,需要将当前节点保存一份。避免执行下面逻辑导致节点丢失,进而引发内存泄漏。删除逻辑大体分为托孤(左为空或右为空的情况)和 与左子树的最大值交换后删除(左右不为空)。
左为空和右为空的情况直接修改root的指向即可。因为这里传参传的是实参的别名,形参部分是的类型是实参的引用就可以改变实参的指向。
左和右都不为空情况下,和左子树的最大值进行交换后,再次递归从root->left的位置开始,删除key。最后,进入函数后会执行托孤部分的逻辑,将节点删除。
析构函数的实现
采用子函数递归的方式,后序遍历进行依次释放节点并将节点置空。在析构函数内部调用这个子函数即可。需要注意的是子函数的函数头的参数部分,用节点指针的引用可以改变节点的指向。
拷贝构造的实现
拷贝构造实现思路如下,使用一个子函数递归前序遍历依次完成节点拷贝和链接。再实现一个现代写法的拷贝赋值运算符重载即可。
二叉搜索树的应用场景
通常二叉搜索树有两种模型,一种是key的模型,另一种是key,value模型。上面,模拟实现实现的就是一个key模型的二叉搜索树。那这两者有什么区别呢?
key模型的二叉搜索树通常用于快速判断一个key在不在的场景。比如说校园卡刷门禁系统。此时key就是你的学号,门禁系统根据你的学号判断你是否是在校生,是就放行。
key,value模型的二叉搜索树是一个根据key值去找对应的value值的一个搜索模型。比如说一个英文词典程序,通过英文单词,去词库系统中找匹配的中文翻译。
简单搭一个key value模型的二叉搜索树
通过上面手撕的key模型的二叉搜索树,我们简单进行一下修改便可以把它改造成key value结构的二叉搜索树。
namespace xyx_kv
{
template<class K, class V>
struct BSTNode
{
BSTNode(const K& key, const V& value)
:_left(nullptr)
, _right(nullptr)
, _key(key)
, _value(value)
{}
BSTNode* _left;
BSTNode* _right;
K _key;
V _value;
};
template<class K, class V>
class BSTree
{
public:
typedef BSTNode<K, V> Node;
BSTree()
:_root(nullptr)
{}
BSTree(const BSTree<K, V>& t)
{
_root = Copy(t._root);
}
BSTree<K, V>& operator=(BSTree<K, V> t)
{
swap(_root, t._root);
return *this;
}
~BSTree()
{
Destroy(_root);
}
void InOrder()
{
_inOrder(_root);
cout << endl;
}
Node* FindR(const K& key)
{
return _FindR(_root, key);
}
bool InsertR(const K& key, const V& val)
{
return _InsertR(_root, key, val);
}
bool EraseR(const K& key)
{
return _EraseR(_root, key);
}
private:
void Destroy(Node*& root)
{
if (root == nullptr)
return;
Destroy(root->_left);
Destroy(root->_right);
delete root;
root = nullptr;
}
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;
}
bool _EraseR(Node*& root, const K& key)
{
//key 不存在
if (root == nullptr)
return false;
if (root->_key > key) //比root小,去root的左子树找
return _EraseR(root->_left, key);
else if (root->_key < key) //比root大,去root的左子树找
return _EraseR(root->_right, key);
else //找到了
{
// 1、左为空
// 2、右为空
// 3、左右不为空
Node* del = root;//保存以便释放
if (root->_left == nullptr)
{
root = root->_right;
}
else if (root->_right == nullptr)
{
root = root->_left;
}
else
{
Node* leftMax = root->_left;
while (leftMax->_right)
{
leftMax = leftMax->_right;
}
swap(root->_key, leftMax->_key);
return _EraseR(root->_left, key);
}
delete del;
return true;
}
}
bool _InsertR(Node*& root, const K& key, const V& val)
{
if (root == nullptr)
{
root = new Node(key, val);
return true;
}
if (root->_key > key)
{
return _InsertR(root->_left, key, val);
}
else if (root->_key < key)
{
return _InsertR(root->_right, key, val);
}
else
{
return false;
}
}
Node* _FindR(Node* root, const K& key)
{
if (root == nullptr)
return nullptr;
if (root->_key > key)
{
return _FindR(root->_left, key);
}
else if (root->_key < key)
{
return _FindR(root->_right, key);
}
else
{
return root;
}
}
void _inOrder(Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << ":" << root->_value << endl;
_InOrder(root->_right);
}
private:
Node* _root;
};
下面简单写一个英文词典简易程序带大家感受一下key value搜索模型的二叉搜索树。
下面再看一个根据经典的key value模型的应用,统计string类数据出现的次数。
介绍完了key 模型和 key value模型的二叉搜索树。其实在实际使用中库里面有现成的方案可供我们使用。key模型对应的是set,key value模型对应的是map。有些许不同的是它们底层用的是红黑树(平衡二叉搜索树)来实现的。后面还会详细介绍。
总结
二叉搜索树是一种特殊的二叉树。它不仅严格要求当前节点的左子树的值小于当前节点的值,还严格要求当前节点的右子树的值大于当前节点的值。它的搜索模型有两种分别是key模型和key value 模型。对应了快速判断在不在的场景和根据关键值辅助查找数据的场景。
学习二叉搜索树的意义在于对于一些查找算法有了新的理解。和二分查找相比搜索二叉树在实景使用中更加优秀,虽然二分查找看起来很快,但是在实践中其实使用场景比较局限,二分查找需要数据具有二义性(有序性其实就是二义性)。而搜索二叉树相比于二分查找使用场景更多。
虽然在理想情况下(接近完全二叉树),二叉搜索树的查找效率是O(LogN)的。但是,二叉搜索树也伴随着特殊情况下,高度过高导致查找效率退化至O(N)。所以,可以认为二叉搜索树的查找的实际时间复杂度为O(N)。那如何让搜索二叉树的结构始终处于一个理想的状态呢?那就要引入平衡因子的概念,是二叉搜索树升级成AVL树和红黑树。这在后续的文章中在详细的介绍了。
好了本篇文章就到这里,感谢您的阅读!如有错误请指出。参考代码,点击跳转