博客主页:Morning_Yang丶
欢迎关注点赞收藏⭐️留言
本文所属专栏:【C++拒绝从入门到跑路】
作者水平有限,如果发现错误,敬请指正!感谢感谢!
普通的二叉树单纯用来存储数据意义不大,不如用数组和链表。
普通数组和链表,面对一些需要频繁查找、插入、删除的场景,也很麻烦。
所以这里引入了[二叉搜索树]。
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
二叉搜索树的结构特点带来的好处:
注意:二叉搜索树的时间复杂度是O(N),比如只有右子树或者左子树的情况。只有当树的形状接近完全二叉树或者满二叉树,才能达到 logN。所以,实际中搜索二叉树在极端情况下没办法保证效率,要对他的特性拓展延申: AVL Tree、红黑树。他们对搜索二叉树的左右高度提出要求,非常接近完全二叉树,所以他们的效率可以达到 logN
上面的数据结构一般用于内存中查找
当数据在磁盘中时,对树高度进一步提出了要求:进一步衍生了B树系列,适合查找存储
他们都是搜索树基础上演变出来的,各有特点,适用于不同的场景
定义二叉搜索树节点类模板:
#include
using namespace std;
// 定义二叉搜索树节点
template<class K>
struct BSTreeNode
{
BSTreeNode<K>* _left;
BSTreeNode<K>* _right;
K _key;
BSTreeNode(const K& key)
:_left(nullptr)
,_right(nullptr)
,_key(key)
{}
};
定义二叉搜索树类模板:
// 定义二叉搜索树
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node; // 重命名树节点类名
private:
Node* _root = nullptr; // 根节点
public:
// 构造函数
BSTree();
// 拷贝构造函数
BSTree(const BSTree<K>& tree); // 引用
// 赋值运算符重载函数
BSTree<K>& operator=(BSTree<K> tree); // 传值
// 析构函数
~BSTree();
// 插入元素key
bool Insert(const K& key); // 常引用:减少传参时的拷贝,保护形参不会被更改
// 查找元素key,查找到了返回节点地址,否则返回nullptr
Node* Find(const K& key);
// 删除元素key
bool Erase(const K& key);
// 插入元素key(递归版本)
bool InsertR(const K& key);
// 查找元素key(递归版本)
Node* FindR(const K& key);
// 删除元素key(递归版本)
bool EraseR(const K& key);
// 中序遍历
void InOrder();
private:
// 拷贝构造子函数
Node* _copy(Node* root);
// 析构子函数
void _Destroy(Node* root);
// 插入元素key子函数(递归版本)
bool _InsertR(Node*& root, const K& key); // 形参为引用
// 查找元素key子函数(递归版本)
Node* _FindR(Node* root, const K& key);
// 删除元素key子函数(递归版本)
bool _EraseR(Node*& root, const K& key); // 形参为引用
// 中序遍历子函数
void _InOrder(Node* _root);
};
//...
public:
// 构造函数
BSTree()
:_root(nullptr)
{ }
//...
//...
public:
// 拷贝构造函数
BSTree(const BSTree<K>& tree)
{
// 深拷贝,用已存在的树tree去拷贝一个新树,然后返回新树的根
_root = _copy(tree._root);
}
private:
// 拷贝构造子函数
Node* _copy(Node* root)
{
// 树为空
if (root == nullptr)
{
return nullptr;
}
// 树不为空,开始递归拷贝构建新的树,按照根-左-右的顺序拷贝构造
Node* copyNode = new Node(root->_key);
copyNode->_left = _copy(root->_left);
copyNode->_right = _copy(root->_right);
// 返回当前拷贝的新树
return copyNode;
}
//...
//...
public:
// 赋值运算符重载函数
BSTree<K>& operator=(BSTree<K> tree) // 传值
{
// 现代写法
// 比如 t1 = t2,tree是t2的深拷贝,tree就是t1想要的,
// 所以t1和tree换个头(根节点地址),但不换身体(整颗树),t1就指向了tree整棵树,然后返回
// tree是临时变量,出函数作用域自动销毁
std::swap(_root, tree._root);
return *this;
}
//...
//...
public:
// 析构函数
~BSTree()
{
_Destroy(_root);
_root = nullptr;
}
private:
// 析构子函数
void _Destroy(Node* root)
{
// 根节点不为空
if (root)
{
// 建议使用后序遍历,左-右-根
_Destroy(root->_left);
_Destroy(root->_right);
delete root;
root = nullptr;
}
}
//...
如果根节点为空,返回 nullptr
如果根节点不为空,从根节点开始,查找 key:
// 查找元素key,查找到了返回节点地址,否则返回nullptr
Node* Find(const K& key)
{
// 树为空
if (_root == nullptr)
{
return nullptr;
}
// 树不为空,从根节点开始查找元素key
Node* cur = _root;
while (cur) // 当cur为空,停止循环,说明没找到
{
if (cur->_key > key)//key比节点小,向左找
{
cur = cur->_left;
}
else if (cur->_key < key)//key比节点大,向右找
{
cur = cur->_right;
}
else//说明已经存在该值,false
{
return cur;
}
}
// 没有找到
return nullptr;
}
分而治之的思想:
每一级递归时,在我们眼中,当前树就是这样的,只有 root
、left
、right
三个节点。
递归算法思路:
public:
// 查找元素key(递归版本)
// 调用函数需要传递树的根,根是私有成员,所以套一层函数_FindR来间接调用,从而保护根
Node* FindR(const K& key)
{
return _FindR(_root, key);
}
private:
// 查找元素key子函数(递归版本)
Node* _FindR(Node* root, const K& key)
{
// 递归出口(终止条件),当前树的根节点为空
if (root == nullptr)
{
return nullptr; // 没找到,返回nullptr
}
// 当前树的根节点不为空
if (root->_key < key)
{
return _FindR(root->_right, key);
}
else if (root->_key > key)
{
return _FindR(root->_left, key);
}
else
{
return root;
}
}
}
代码如下:
// 插入元素key
bool Insert(const K& key) // 常引用:减少传参时的拷贝,保护形参 不会被更改
{
// 树为空
if (_root == nullptr)
{
_root = new Node(key); // 直接插入新节点
return true;
}
else
{
Node* parent = nullptr;// 记录cur的父节点,因为新节点最终会插入在cur的父节点左右孩子的位置
Node* cur = _root;// 树不为空,从根节点开始,先查找到插入key的位置
while (cur)// 当cur为空,说明找到插入key的位置了
{
if (cur->_key > key)//key比节点小,向左找
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key)//key比节点大,向右找
{
parent = cur;
cur = cur->_right;
}
else//说明已经存在该值,false
{return false; }
}
//cur走到空,判断key和parent的关系
if (parent->_key > key)
{
parent->_left = new Node(key);// key比父节点小,链接在左边
}
else
{
parent->_right = new Node(key);// key比父节点大,链接在右边
}
// 插入成功,返回true
return true;
}
}
注意一点:
插入元素的顺序不同,树的结构也会不同,但中序遍历的结果是一样的。
分而治之的思想:
每一级递归时,在我们眼中,当前树就是这样的,只有 root
、left
、right
三个节点。
递归算法思路:
如果当前树的根节点为空,则直接插入;
如果当前树的根节点不为空:
public:
// 插入元素key(递归版本)
// 调用函数需要传递树的根,根是私有成员,所以套一层函数InsertR来间接调用,从而保护根
//如果树中存在key,返回false
bool InsertR(const K& key)
{
return _InsertR(_root, key);
}
private:
// 插入元素key子函数(递归版本)
bool _InsertR(Node*& root, const K& key) // 形参是根节点的引用,这里很巧妙
{
// 当前树的根节点为空
if (root == nullptr)
{
root = new Node(key); // 插入新节点
return true; // 返回true
}
// 当前树的根节点不为空
if (key > root->_key)
{
// 去往当前树的右子树中插入
return _InsertR(root->_right, key);
}
else if (key < root->_key)
{
// 去往当前树的左子树中插入
return _InsertR(root->_left, key);
}
else
{
// 二叉搜索树不允许数据冗余,返回false
return false;
}
}
}
【拓展】
函数形参是根节点的引用 bool _InsertR(Node*& root, const K& key);
,这里很巧妙,我们在函数体内就不用定义一个变量来保存要插入的节点的父节点了,这样就能直接更换上一层的节点的左右指针。
我们以插入节点 10 为例:
这样一来,通过改变 root,从而控制 root 父节点(节点 9)右指针的指向。
注意一点:
我们在类外面,用对象调用函数时需要传递树的根,但根是私有成员,只能再去写一个 GetRoot 接口来传递树的根,但这样根又被暴露出去了,所以我们在这里,套一层无参函数 InOrder()
来调用有参函数 _InOrder(Node* _root)
,从而保护了根节点。
public:
// 中序遍历
// void InOrder(Node* _root)
// 调用函数需要传递树的根,根是私有成员,所以套一层无参函数InOrder()来间接调用,从而保护根
void InOrder()
{
_InOrder(_root); // 调用中序遍历子函数
cout << endl;
}
private:
// 中序遍历子函数
void _InOrder(Node* root)
{
if(root == nullptr)
{return; }
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
二叉搜索树的删除比较复杂,要分情况讨论:
首先查找元素 key 是否在二叉搜索树中,如果不存在,则返回 false,否则删除结点,分下面几种情况:
情况1和2:要删除的结点「无孩子」结点(叶子节点),或者要删除的结点「只有左孩子」结点
- 先判断被删除节点 cur 是父节点 parent 的 左孩子 还是 右孩子。
- 让父结点 parent 的左 / 右指针指向被删除节点的 左孩子 (我被删除了,我的父亲要帮我接管左孩子)
- 然后删除该节点。
【注意】:
还有一种情况需要考虑到,删除的是根节点,cur 没有父节点,所以直接把 cur 的左孩子变为根:
情况3:要删除的结点「只有右孩子」结点
先判断被删除节点 cur 是父节点 parent 的 左孩子 还是 右孩子。
让父结点 parent 的左 / 右指针指向被删除节点的 右孩子。(我被删除了,我的父亲要帮我接管右孩子)
然后删除该节点。
这里就不详细画图演示了,和上面的类似。
【注意】:
还有一种情况需要考虑到,删除的是根节点,cur 没有父节点,所以直接把 cur 的右孩子变为根:
情况4:要删除的结点「有左右孩子」结点
有两个孩子,不好直接删除,所以我们用替代法删除:
找一个「替代节点」,比被删除节点的左孩子值大,比被删除节点右孩子的值小。
即被删除节点左子树中的最大节点或者右子树中的最小节点。
- 左子树中的最大节点 --> 即左子树的最右侧节点(它的右孩子一定为空)
- 右子树中的最小节点 --> 即右子树的最左侧节点(它的左孩子一定为空)
「替代节点」找到后,将替代节点中的值赋给「要的删除节点」,转换成删除替代节点。
以找被删除节点 左子树中的最大节点 作为替代节点为例,删除思路如下:
【注意】:
在第 3 步:
- 先要判断一下最大节点 maxleft 是父节点 maxleft_parent 的 左孩子 还是 右孩子。
- 让父结点 maxleft_parent 的左 / 右指针指向被删除节点的 左孩子 (我被删除了,我的父亲要帮我接管左孩子,因为左子树的最大节点没有的右孩子)
代码如下:
bool Erase(const K& key)
{
// 树为空,删除失败
if (_root == nullptr)
{
return false;
}
// 树不为空,从根节点开始,查找元素key
Node* cur = _root;// 记录元素key的位置
Node* parent = cur;// 记录cur的父节点
//先遍历找这个节点
while (cur) // 如果cur为空,说明没有找到元素key的位置
{
if (cur->_key > key)//key比节点小,向左找
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key)//key比节点大,向右找
{
parent = cur;
cur = cur->_right;
}
else//说明找到了
{
break;
}
}
//为空就是没找到,就false
if(!cur){return false; }
//处理第一、二、三种情况,只有一个子节点或者没有子节点
if (cur->_left == nullptr)//左边为空交右边
{
if (cur == _root)//cur是根节点
{
_root = cur->_right;
}
else
{
if (parent->_left == cur)//cur是左节点,右边交给parent的左
{
parent->_left = cur->_right;
}
else
{
parent->_right = cur->_right;
}
}
delete cur;
cur = nullptr;
}
else if(cur->_right == nullptr)//右边为空就交左边
{
if (cur == _root)
{
_root = cur->_left;
}
else
{
if (parent->_left == cur)//cur是左节点,将左边交给parent的左
{
parent->_left = cur->_left;
}
else
{
parent->_right = cur->_left;
}
}
delete cur;
cur = nullptr;
}
else//左右都不为空
{
Node* minRight = cur->_right;//去找右子树里面的最小值
Node* minParent = cur;//右子树里面最小值的父节点
while (minRight->_left)//它的左节点最后一定为空
{
minParent = minRight;
minRight = minRight->_left;
}
cur->_key = minRight->_key;//把值替换过去
if (minParent->_left == minRight)//待删除节点是父节点的左边
{
minParent->_left = minRight->_right;
}
else//待删除节点是父节点的右边,出现这种情况,说明 minRight就是cur节点的右子树根节点
{
minParent->_right = minRight->_right;
}
delete minRight;
minRight = nullptr;
}
return true;
}
分而治之的思想:
每一级递归时,在我们眼中,当前树就是这样的,只有 root
、left
、right
三个节点。
递归算法思路:
public:
// 删除元素key(递归版本)
bool EraseR(const K& key)
{
return _EraseR(_root, key);
}
private:
// 删除元素key子函数(递归版本)
bool _EraseR(Node*& root, const K& key)
{
if (root == nullptr)//走到空都找不到返回false;
{
return false;
}
if (root->_key < key)
{
return _EraseR(root->_right, key);
}
else if (root->_key > key)
{
return _EraseR(root->_left, key);
}
else
{
if (root->_left == nullptr)//左子树为空
{
Node* del = root;//root是上一层左(右)指针的别名
root = root->_right;//root接收右子树
delete del;//删掉对应的节点
}
else if (root->_right == nullptr)//右子树为空
{
Node* del = root;
root = root->_left;
delete del;
}
else//左右子树都不为空
{
//迭代
//去找右子树的最小节点
Node* minRight = root->_right;
Node* minParent = root;
while (minRight->_left)//它的左节点最后一定为空
{
minParent = minRight;
minRight = minRight->_left;
}
root->_key = minRight->_key;
if (minParent->_left == minRight)
{
//说明 minRight不是root的右节点
minParent->_left = minRight->_right;
}
else
{
//minRight是root的右节点
minParent->_right = minRight->_right;
}
delete minRight;
minRight = nullptr;
/*递归写法
Node* minRight = root->_right;
while (minRight->_left)//它的左节点最后一定为空
{
minParent = minRight;
minRight = minRight->_left;
}
K Min = minRight->_key;//记下他的值
_EraseR(root->_right, Min);//从root的右树里面删除Min
//删除Min的情况一定不是左右子树都不为空
root->_key = Min;//替换值
*/
}
return true;
}
}
二叉搜索树的查找,根据「二叉搜索树性质」来找某节点
二叉搜索树的插入,
二叉搜索树的删除,
对于上述接口的递归写法,一般能用循环(非递归)就用非递归,有些递归好是好,也容易让人理解,但是对于深度高的树,建立栈帧也是一笔不小的开销,有可能会导致栈溢出。
K (Key) 模型:确定一个值在不在一个集合中,K 模型即只有 Key 作为关键码,二叉搜索树结构中只需要存储 Key 即可,关键码即为需要搜索到的值。
举个例子1:
BSTree stuNumSet;
举个例子2:给一个单词 word,判断该单词是否拼写正确,具体方式如下:
上面实现的二叉搜索树就是 K 模型!
KV (Key/Value) 模型:每一个关键码 Key,都有与之对应的值 Value,即 < Key, Value > 的键值对。
该种方式在现实生活中非常常见:
注意:
KV模型中,二叉搜索树的每个节点不仅要存放 key,还要存放 value,但是在插入、删除的时候,还是按照 key 值来查找到该节点,对其进行插入、删除操作。
所以我们要对上面的二叉搜索树进行改造,主要是这几个改动:1、节点类模板 2、树类模板中的插入节点函数、中序遍历函数。而查找、删除函数无需改动。
以下是完整的KV版本的代码
namespace KV//key-value的版本
{
template<class K, class V>
struct BSTreeNode
{
BSTreeNode<K, V>* _left;
BSTreeNode<K, V>* _right;
K _key;
V _value;
BSTreeNode(const K& key,const V& value)
:_left(nullptr)
, _right(nullptr)
, _key(key)
,_value(value)
{
}
};
template<class K,class V>
class BSTree
{
typedef BSTreeNode<K, V> Node;
private:
Node* _FindR(Node* root, const K& key)
{
if (root == nullptr)//走到空没找到就返回空
{
return nullptr;
}
if (root->_key < key)
{
return _FindR(root->_right, key);
}
else if (root->_key > key)
{
return _FindR(root->_left, key);
}
else
{
return root;
}
}
bool _InsertR(Node*& root, const K& key, const V& value)
{
if (root == nullptr)//走到空,在空的位置插入
{
root = new Node(key, value);
return true;
}
if (root->_key < key)
{
return _InsertR(root->_right, key, value);
}
else if (root->_key > key)
{
return _InsertR(root->_left, key, value);
}
else//已经存在了,返回false
{
return false;
}
}
bool _EraseR(Node*& root, const K& key)
{
if (root == nullptr)//走到空都找不到返回false;
{
return false;
}
if (root->_key < key)
{
return _EraseR(root->_right, key);
}
else if (root->_key > key)
{
return _EraseR(root->_left, key);
}
else
{
if (root->_left == nullptr)
{
Node* del = root;
root = root->_right;//root是上一层左(右)指针的别名
delete del;
}
else if (root->_right == nullptr)
{
Node* del = root;
root = root->_left;
delete del;
}
else
{
Node* minRight = root->_right;
Node* minParent = root;
while (minRight->_left)//它的左节点最后一定为空
{
minParent = minRight;
minRight = minRight->_left;
}
root->_key = minRight->_key;
if (minParent->_left == minRight)
{
minParent->_left = minRight->_right;
}
else
{
minParent->_right = minRight->_right;
}
delete minRight;
minRight = nullptr;
/*递归写法
Node* minRight = root->_right;
while (minRight->_left)//它的左节点最后一定为空
{
minParent = minRight;
minRight = minRight->_left;
}
K min = minRight->_key;
_EraseR(root->_right, min);//从root的右树里面删除min
root->_key = min;//替换值
*/
}
return true;
}
}
void Destory(Node* root)
{
if (root == nullptr)
{
return;
}
Destory(root->_left);
Destory(root->_right);
delete root;
root = nullptr;
}
Node* _copy(Node* root)
{
if (root == nullptr)
{
return nullptr;
}
Node* copyNode = new Node(root->_key, root->_value);
copyNode->_left = _copy(root->_left);
copyNode->_right = _copy(root->_right);
return copyNode;
}
public:
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()
{
Destory(_root);
_root = nullptr;
}
//如果树中存在key,返回false
bool InsertR(const K& key, const V& value)
{
return _InsertR(_root, key, value);
}
Node* FindR(const K& key)
{
return _FindR(_root, key);
}
//如果树中不存在key,返回false
bool EraseR(const K& key)
{
return _EraseR(_root, key);
}
void _InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << ":" << root->_value << endl;
_InOrder(root->_right);
}
void InOrder()
{
_InOrder(_root);
cout << endl;
}
private:
Node* _root = nullptr;
};
}
实现一个简单的英汉词典 dict,可以通过英文找到与其对应的中文,具体实现方式如下:
void Test_KV_Tree()
{
// KV模型 -- 英汉词典,通过英文找到与其对应的中文
KV::BSTree<std::string, std::string> dict;
// 插入 < 单词,中文含义 > 构建二叉搜索树
dict.InsertR("insert", "插入");
dict.InsertR("erase", "删除");
dict.InsertR("left", "左边");
dict.InsertR("string", "字符串");
// 查找单词对应中文含义
std::string str;
while (cin >> str)
{
if (str == "q") // 输入q退出查找
{
cout << "quit!" << endl;
break;
}
// 查找该单词
auto ret = dict.FindR(str);
if (ret)
{
cout << str << ":" << ret->_value << endl;
}
else
{
cout << "单词拼写错误" << endl;
}
}
}
统计 str 中每个单词出现的次数,方法如下:
void TestTree3()
{
std::string strs[] = { "苹果", "西瓜", "苹果", "樱桃", "苹果", "樱桃", "苹果", "樱桃", "苹果" };
// 统计水果出现的次
KV::BSTree<std::string, int> countTree;
// 遍历strs
for (auto& str : strs)// 传引用,避免string深拷贝
{
// 先检查当前准备插入的单词,是否已经在二叉搜索树tree中了
auto ret = countTree.FindR(str);
if (ret == NULL)// 不在树中
{
// 插入 < 单词,单词出现次数 >
countTree.InsertR(str, 1);// 当前次数是1次
}
else// 在树中
{
ret->_value++; // 修改value,出现次数+1
}
}
// 打印每个 < 单词,单词出现次数 >
countTree.InOrder();
}
因为二叉搜索树的特性,插入字符串的时候就排好序了,中序遍历出来的结果也是有序的
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有 n 个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是与树的深度有关,即树越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的顺序不同,可能得到不同结构的二叉搜索树:
二叉搜索树的查找是很快的,但是它很依赖于树的形状:
最优情况下,有 n 个结点的二叉搜索树为完全二叉树,其平均比较次数为:O(log2N)
最差情况下,有 n 个结点的二叉搜索树退化为单支树,其平均比较次数为:O(N)
留一个问题:
如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插入关键码,都可以是二叉搜索树的性能最佳?这就要引出 AVL 树了。