一、二叉搜索树的定义
一棵二叉树,可以为空,但如果不为空则要满足
①非空左子树的所有键值小于其根节点的键值
②非空右子树的所有键值大于其根节点的键值
③左右子树都是二叉搜索树
④又名排序二叉树,他有个特性是中序遍历结果有序
二、二叉搜索树的实现
需要实现为类模板的格式:
template<class K, class V>
class BSTree{};
他的节点需要包括左右子树和自身的值
template<class K, class V>
struct BSTreeNode
{
K _key;
V _value;
BSTreeNode<K,V>* _left;
BSTreeNode<K,V>* _right;
}
为了简化节点的名称,实现BSTree的时候typedef一下
typedef BSTreeNode<K, V> Node;
2.1查找
返回值设置为节点指针,可以凭借循环的逻辑直接查找,找不到返回为空
Node* 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 cur;
}
return nullptr;
}
2.2插入
插入需要保证符合二叉搜索树无冗余的原则,然后按照查找逻辑走,遇到空即可进行插入
因为查找逻辑只能帮我们找到当前节点的指针,但是插入需要父节点指针来定位,所以我们可以选择用双指针的思想辅助找到父节点
之后我们再插入的时候,可以通过比较来确定插入左还是右
bool Insert(const K& key, const V& value)
{
if (_root == nullptr)
{
_root = new Node(key,value);
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, value);
//没有重复,parent此时指向要插入节点的父节点,cur直接存新节点
if (key < parent->_key)
{
parent->_left = cur;
}
else
{
parent->_right = cur;
}
return true;
}
2.3中序遍历
这里选择递归实现可以大幅度简化代码
但是我们要递归就要传入根节点,但正常调用的中序遍历是不可能传入作为私有的根节点的,为此我们提供两种解决方案
①实现一个私有的子函数
void _InOrder(Node* root)
来进行递归
②实现一个共有的GetRoot函数来获取_root的值
个人感觉第二个有点不符合使用习惯,这里用第一种方式来实现
void InOrder()
{
_InOrder(_root);
}
void _InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << ":"<<root->_value<<endl;
_InOrder(root->_right);
}
2.4删除
删除的情况分三种
①叶子节点,没有孩子
②只有一个孩子
③有两个孩子
2.4.1删除逻辑大框架
删除的传参是一个值,所以仍然需要先走查找的逻辑,对树的结构进行修改也仍然需要父节点,所以双指针也是需要的。
在我们查找过程中,只有找到了才要进行删除的逻辑,所以大框架可以得出来
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
{
//走删除的逻辑,成功删除的话返回true
}
}
return false;//没成功删除
}
2.4.2删除逻辑前两个情况
接下来就要看删除的逻辑了,我们总结一下三种情况的应对方案会发现:因为②需要直接改变父节点指向,使其指向该节点的孩子再delete该节点,而确定该节点的方式是通过左为空或者右为空,所以①的情况属于是该节点两个孩子都为空,走哪一个逻辑都可以达到目标,
所以①可以当作一种特殊的②
此时可以得出(当删除的节点为根节点,会没有父节点使用,需要添加两个逻辑)
if (cur->_left == nullptr)
{
if (parent == nullptr)
{
cur = _root;
_root = _root->_right;
delete cur;
return true;
}
if (parent->_left == cur)
{
parent->_left = cur->_right;
}
else
{
parent->_right = cur->_right;
}
delete cur;
return true;
}
else if (cur->_right == nullptr)
{
if (parent == nullptr)
{
cur = _root;
_root = _root->_left;
delete cur;
return true;
}
if (parent->_left == cur)
{
parent->_left = cur->_left;
}
else
{
parent->_right = cur->_left;
}
delete cur;
return true;
}
else
{
//两边都不为空的删除逻辑
}
2.4.3删除逻辑第三种情况
我们要删除一个有两个孩子的节点,可以根据二叉树特性来找方法
左子树的最右右节点,右子树最左节点其实都可以满足这个节点的要求:
大于所有左子树键值,小于所有右子树键值
因此我们可以用其中之一来替换这个节点,再delete更换后的左子树的最右右节点/右子树最左节点
位置
//此时cur的左右两边都不为空,我们需要找cur右子树的最左来替代
Node* PrightMin = cur;
Node* rightMin = cur->_right;
//找到cur右子树的最左
while (rightMin->_left)
{
PrightMin = rightMin;
rightMin = rightMin->_left;
}
cur->_key = rightMin->_key;
//有可能rightMin直接就是最小,此时他其实是父节点的右
if (rightMin == PrightMin->_left)
PrightMin->_left = rightMin->_right;
else
PrightMin->_right = rightMin->_right;
delete rightMin;
return true;
2.5析构
搜索二叉树的析构可以考虑使用递归,可以大大减少代码书写量
因为析构函数不能传参,我们可以选择用子函数来解决这一问题
public:
~BSTree()
{
_Destroy(_root);
_root = nullptr;
}
private:
void _Destroy(Node* root)
{
if (root == nullptr)
{
return;
}
_Destroy(root->_left);
_Destroy(root->_right);
delete root;
}
在递归进行析构的过程中,我们必须要使用后序进行析构,否则可能会出现无法找到子节点的问题导致程序崩溃
2.6拷贝构造
对于二叉搜索树的拷贝构造来说,值拷贝一定不行
因为创建了一个新的二叉树,更改了其中节点的值会影响原来的二叉树
我们必须要显示实现:此处依旧可以考虑递归,但必须前序,否则可能会出现无法确定根节点的问题,显示实现过程中需要深拷贝,我们用new来申请新节点
public:
BSTree(const BSTree<K,V>& bs)
{
_root = _BSTree(bs._root);
}
private:
Node* _BSTree(Node* root)
{
if (root == nullptr)
{
return nullptr;
}
Node* newnode = new Node(root->_key, root->_value);
_BSTree(root->_left);
_BSTree(root->_right);
return newnode;
}
三、二叉搜索树的特性
3.1二叉搜索树可以完成的事
①查找
②去重
③排序+去重(受二叉搜索树特性限制,不可重复)
3.2二叉搜索树的增删查时间复杂度
它的增删查时间复杂度是O(N),可既然是树,那么为什么不是O(logN)呢?
原因在于搜索二叉树并无完全二叉树的性质,他是有可能退化为链表之类的格式的,所以最优为logN,但是最差为N/2
但这一缺点其实有补充方案,那就是平衡二叉搜索树(包括AVL树,红黑树)
3.2补:二叉搜索树默认不支持修改,只有它的变种才可能支持
四、当前阶段的搜索方法
4.1暴力查找(循环遍历)
本身效率就比较低,非必要不使用
4.2排序+二分查找
插入与删除的效率比较低(常为O(N)的时间复杂度)
4.3搜索树
二叉搜索树O(N)->平衡二叉搜索树O(log(N))->多叉平衡搜索树(B树系列)
4.4哈希思想
哈希表
五、key模型和key/value模型
5.1二者定义
①key模型:检查在不在,如门禁系统
②key/value模型:通过一个值(key)去找另外一个值(value),如英汉字典,超市的商品价格等
5.2特殊场景中的模型
有时一些特殊场景也可以转化为这两种模型,如
①检查英文小说中是否有拼写错误的单词,可以转化为key模型(搜索树存英文词库,利用平衡二叉搜索树的logN时间复杂来找)
②统计英文小说中单词出现的次数,可以转化为key/value模型(遍历小说单词,key对应单词,value对应次数)
六、多组输入相关问题
我们在进行多组输入的时候,如
string str;
while(cin >> str)
{}
只有ctrl+Z再换行可以结束程序,为什么?(ctrl+C结束属于强制中断程序,暂时不讨论)
我们知道,cin >> str本质上调用的是string中重载的运算符
istream& operator>> (istream& is, string& str);
也就是operator>>(cin,str)
他的返回值类型实际上是istream&,那么问题来了,istream&怎么做条件判断呢?
原来,在istream中有一个重载
explicit operator bool() const;
属于自定义类型重载内置类型,本质上是重载了隐式类型转换,
正常情况下进行强制类型转换需要(bool),我们要重载的是(),但是很明显,()的重载已经被仿函数占用了,所以只能转变写法operator bool()了
而观察这一声明,我们发现没有返回值类型,但实际上他的返回值类型正是bool。
回到之前的问题,要做条件判断需要通过与不通过两种情况,而函数中有四个标志
good(默认),fail,failbit,badbit,默认情况下good会返回true
如果输入了ctrl+Z,会把good替换成fail,标志改变后返回会变成false,即出循环