二叉搜索树比起二叉树又有什么不一样呢?
- 🏐什么是二叉搜索树
- 🏐二叉搜索树的实现
- 🏀节点类:
- 🏀构造函数
- 🏀析构函数
- 🏀插入insert
- ⚽非递归版本
- ⚽递归版本
- 🏀查找find
- ⚽非递归版本
- ⚽递归版本
- 🏀删除erase
- ⚽非递归版本
- ⚽递归版本
- 🏀拷贝问题
- 🏀赋值
- 🏐二叉搜索树的应用及分析
- 🏀性能分析
- 💬一些小点
👀先看这里👈
😀作者:江不平
📖博客:江不平的博客
📕学如逆水行舟,不进则退
🎉欢迎关注🔎点赞👍收藏⭐️留言📝
❀本人水平有限,如果发现有错误的地方希望可以告诉我,共同进步👍
数据对于我们来说除了存储之外最重要的就是查找了,怎么才能快速的进行查找呢?有序的就好查找,二分查找对于我们来说效率并不高,搜索树和哈希表是更好的方式。
🏐什么是二叉搜索树
二叉查找树(Binary Search Tree),(又:二叉搜索树,二叉排序树)它或者是一棵空树,或者是具有下列性质的二叉树:
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉排序树。
二叉搜索树作为一种经典的数据结构,它既有链表的快速插入与删除操作的特点,又有数组快速查找的优势;所以应用十分广泛。
给个缺省值,连构造函数都不用写了
🏐二叉搜索树的实现
二叉搜索树(BST)又称二叉查找树或二叉排序树。一棵二叉搜索树是以二叉树来组织的,可以使用一个链表数据结构来表示,其中每一个结点就是一个对象。
一般地,除了key和位置数据之外,每个结点还包含属性lchild、rchild和parent,分别指向结点的左孩子、右孩子和双亲(父结点)。如果某个孩子结点或父结点不存在,则相应属性的值为空(NULL)。根结点是树中唯一父指针为NULL的结点,而叶子结点的孩子结点指针也为NULL。
🏀节点类:
要实现二叉搜索树先构造个节点类出来
template<class K>
struct BSTreeNode
{
BSTreeNode<K>* _left;
BSTreeNode<K>* _right;
K _key;
BSTreeNode(const K& key)
:_left(nullptr)
, _right(nullptr)
, _key(key)
{}
};
🏀构造函数
构造函数构造一个空树即可,也可以调用默认的构造函数
第一种:
BSTree()
{}
第二种
BSTree(){
:root(nullptr)
}
第三种:C++的用法,使用default关键字强制编译器生成默认的构造
BSTree() = default;
🏀析构函数
析构函数要完成对每个结点的释放,注意释放时是后序顺序,当结点都被释放完后,别忘了把最后一个节点置空。
void _Destory(Node*& root)
{
if (root == nullptr)//空树无需释放
{
return;
}
_Destory(root->_left);//释放左子树
_Destory(root->_right);//释放右子树
delete root;//释放根节点
root = nullptr;//置空
}
~BSTree()
{
_Destory(_root);
}
🏀插入insert
插入分两种情况,空树和不是空树,所以要先进行判断
不是空树则按照二叉搜索树的性质来进行插入,若存在与插入相等的值就插入失败,根据这一逻辑,来写insert函数的实现。
⚽非递归版本
我们需要定义一个parent指针,该指针用于标记待插入结点的父结点。
这样一来,当我们找到待插入结点的插入位置时,才能很好的将待插入结点与其父结点连接起来。
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
bool Insert(const K& key)
{
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
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//如果存在与插入相等的值,则插入失败
{
return false;
}
}
cur = new Node(key);//把key值插入节点完成,还需要完成连接,才算是插入,所以还需要parent节点
if (parent->_key < key)
{
parent->_right = cur;
}
else
{
parent->_left = cur;
}
return true;
}
}
⚽递归版本
注意这里的参数root,给了引用,不给引用的话就需要记录下parent,需要连接起来,不然出了函数之后就没有插入成功,因为它不加引用就是个局部变量。这个函数参数在所有函数中都加了引用。加了引用就可以很好的把节点都连接起来
bool _InsertR(Node*& root, const K& key)
{
if (root == nullptr)
{
root = new Node(key);
return true;
}
if (root->_key < key)
return _InsertR(root->_right, key);
else if (root->_key > key)
return _InsertR(root->_left, key);
else
return false;
}
🏀查找find
与插入insert结构基本一致
⚽非递归版本
bool 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 true;
}
}
}
⚽递归版本
bool _FindR(Node* root, const K& key)
{
if (root == nullptr)
return false;
if (root->_key < key)
return _FindR(root->_right, key);
else if (root->_key > key)
return _FindR(root->_left, key);
else
return true;
}
🏀删除erase
删除操作就要麻烦的多了,根据有没有子树可以分为这么几种情况:
- 待删除结点是是叶子结点,没有子树
- 待删除结点有左子树或右子树
- 待删除结点左右子树都有
对于没有子树的情况我们也可以归到没有只有一个子树中去
对于一个子树的,如果是只有左子树,那么就父亲结点连接到左子树上,如果是只有右子树的,那么父亲结点连接到右子树上。
左右子树都存在的情况我们采用替换法,寻找待删除结点左子树当中最大的结点;或寻找或是待删除结点右子树当中值最小的结点去进行替换,替换后就会出现前面的情况,按照前面方法删除即可
注意只能是待删除结点左子树当中值最大的结点,或是待删除结点右子树当中值最小的结点代替待删除结点被删除,因为只有这样才能使得进行删除操作后的二叉树仍保持二叉搜索树的特性。
⚽非递归版本
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
{
// 开始删除
// 1、左为空
// 2、右为空
// 3、左右都不为空
if (cur->_left == nullptr)
{
if (cur == _root)
{
_root = cur->_right;
}
else
{
if (cur == parent->_left)
{
parent->_left = cur->_right;
}
else
{
parent->_right = cur->_right;
}
}
delete cur;
cur = nullptr;
}
else if (cur->_right == nullptr)
{
if (_root == cur)
{
_root = cur->_left;
}
else
{
if (cur == parent->_left)
{
parent->_left = cur->_left;
}
else
{
parent->_right = cur->_left;
}
}
delete cur;
cur = nullptr;
}
else
{
// 找到右子树最小节点进行替换
Node* minParent = cur;
Node* min = cur->_right;
while (min->_left)
{
minParent = min;
min = min->_left;
}
swap(cur->_key, min->_key);
if (minParent->_left == min)
minParent->_left = min->_right;
else
minParent->_right = min->_right;
delete min;
}
return true;
}
}
return false;
}
⚽递归版本
bool _EraseR(Node*& root, const K& key)
{
if (root == nullptr)
return false;
if (root->_key < key)
return _EraseR(root->_right, key);
else if (root->_key > key)
return _EraseR(root->_left, key);
else
{
Node* del = root;
if (root->_left == nullptr)
{
root = root->_right;//两个函数作用域中同名的参数是不一样的,引用在这个地方也很关键,这样就建立起联系了,以前写的那一堆因为引用就变成了一句
}
else if (root->_right == nullptr)
{
root = root->_left;
}
else
{
// 找右树的最左节点替换删除
Node* min = root->_right;
while (min->_left)
{
min = min->_left;
}
swap(root->_key, min->_key);
//return EraseR(key); 错的
return _EraseR(root->_right, key);
}
delete del;
return true;
}
}
🏀拷贝问题
遇到一个节点拷贝一个节点,这里是深拷贝,然后再左子树再右子树
Node* _Copy(Node* root)
{
if (root == nullptr)
{
return nullptr;
}
Node* copyRoot = new Node(root->_key);
copyRoot->_left = _Copy(root->_left);
copyRoot->_right = _Copy(root->_right);
return copyRoot;
}
🏀赋值
一般都是复用copy函数啦
一般写法:
//传统写法
const BSTree<K>& operator=(const BSTree<K>& t)
{
if (this != &t) //防止自己给自己赋值
{
_root = _Copy(t._root); //拷贝t对象的二叉搜索树
}
return *this; //支持连续赋值
}
还有一种赋值运算符重载函数的写法非常巧妙,函数在接收右值时并没有使用引用进行接收,因为这样可以间接调用BSTree的拷贝构造函数完成拷贝构造。我们只需将这个拷贝构造出来的对象的二叉搜索树与this对象的二叉搜索树进行交换,就相当于完成了赋值操作,而拷贝构造出来的对象t会在该赋值运算符重载函数调用结束时自动析构。
BSTree<K>& operator=(BSTree<K> t)
{
swap(_root, t._root);
return *this;
}
🏐二叉搜索树的应用及分析
key模型(用来查找,判断key值在不在)
例如:用来判断一篇文章中是否出现错误单词,将其与单词库中的进行比对,若不存在就说明出现了错误单词。
key_value模型(通过key去找value,即<key, value>的键值对)
例如:翻译问题,英译汉或者汉译英(不能互译)
🏀性能分析
查找find是最能反应性能的了,因为插入和删除几乎很多操作都要用到查找。增删查的时间复杂度为O(h),h是树的高度,最坏的情况为O(N),没有修改是因为二叉搜索树的特性,修改了就不是二叉搜索树了
实际上,二叉搜索树在极端情况下是没办法保证效率的,因此由二叉搜索树又衍生出来了AVL树、红黑树等,它们对二叉搜索树的高度进行了优化,使得二叉搜索树非常接近完全二叉树,因此对于这些树来说,它们的效率是可以达O(logN)的。
💬一些小点
- 二叉搜索树排序是天然的去重
- 类里面调用递归不好调,因为默认成员函数一般不传参,但是递归调用一般要传参,可是我们外面调用必须要获取私有成员root,解决办法:写一个get_root或者友元,第三种:套一层,用无参的调用有参的
- 从实际用途的角度来说写非递归好一点,因为深了会溢出的可能,思想上偏向递归的话就写递归,不然还是循环好一点
求高度什么的相关实现和二叉树基本一致,详情看:你知道有一种树叫二叉树吗? - 平衡树和搜索树只是效率上的差别