c++进阶——BinarySearchTree(无相同值)的简单实现
文章目录
- BinarySearchTree——二叉搜索树
- 二叉搜索树的定义
- 二叉搜索树的性能分析
- 二叉搜索树的实现
- 树的结构
- 插入操作
- 查找操作
- 删除操作
- 节点的分类
- 代码实现
- 默认成员函数
- 二叉搜索树key和key/value使用场景
- key_value模式代码实现
- key/key_value搜索场景
- key模式
- key_value模式
BinarySearchTree——二叉搜索树
今天这篇文章,我们来重点看一下二叉搜索树的定义和实现。
二叉搜索树的定义
在c语言数据结构的学习中已经见识过二叉树,并且也对二叉树的一些接口进行了实现。但是之前说过,单纯的整一个二叉树是没有什么意义的。二叉树需要添加一些性质才有意义。
我们先来看对二叉树最简单的“升级”,即普通的线索二叉树:
二叉搜索树⼜称二叉排序树,它或者是⼀棵空树,或者是具有以下性质的二叉树:
• 若它的左子树不为空,则左⼦树上所有结点的值都⼩于等于根结点的值
• 若它的右子树不为空,则右⼦树上所有结点的值都⼤于等于根结点的值
• 它的左右子树也分别为二叉搜索树
• ⼆叉搜索树中可以支持插⼊相等的值,也可以不支持插⼊相等的值,具体看使用场景定义,后续我
们学习map/set/multimap/multiset系列容器底层就是⼆叉搜索树,其中map/set不⽀持插⼊相等
值,multimap/multiset⽀持插⼊相等值
简单来说,搜索二叉树其实就是对二叉树进行了一个简单规定,即左子树的全部值小于等于根节点的值,右子树的全部值大于等于根节点的值。
但是有些二叉树支持插入相同的值,有些不支持。这需要根据具体的场景进行匹配实现。本篇文章实现的是第一种,不插入相同的值。
当然,STL库中也是会有依据二叉搜索树来实现的一些容器的。但是它们的底层并不是完全的简单的二叉搜索树,而是需要进一步改造的(因为单纯的二叉搜索树会有性能退化的问题)。这些我们放在性能分析那一块进行讲解。
我们来看一个标准的二叉搜索树:
如图所示,这是一个不带有相同值的二叉搜索树。
我们对它进行中序遍历,发现竟然是一个有序的序列:{1,3,4,6,7,8,10,13,14}
在这里得到一个结论:对二叉搜索树进行中序遍历,得到的序列是升序的。
二叉搜索树的性能分析
我们现在需要分析一下这个二叉搜索树的一些性能。
还是依据上面那个图,这个树的高度很明显是Log2N次。比如我们要搜索4这个数据是否存在,可以怎么找呢?很简单,比根节点就行。比它大就往右走,比它小就往左走。相等就返回真。依次类推下去,我们只需要执行高度次的搜索次数就能找到某数据,那搜索的时间复杂度就是O(logN)了嘛?
我们这是非常理想情况下的搜索二叉树,应对这种情况当然是正确的。
但是如果现在有一个这样的二叉树呢:
其实我们发现这个树里面的内容和上一张图中的是一样的,但是如果在构建一个二叉树的时候,顺序比较接近正序或者倒序就很容易出现成上面这个情况,这个树的高度一下子就退化到N了,就是一个链表,对链表得搜索的时间复杂度是O(N),很明显,这个性能的退化还是蛮严重的。所以这种简单的性质的二叉树还是有其先天性的不足的。
基于此,STL中的那些以二叉搜索树为结构的容器底层其实还需要对二叉树进行一些改进。比如熟知的AVL树,红黑树等。这些我们当前了解一下即可。因为现在需要先行学习简单的版本,这些更难的后续也会学的。
二叉搜索树的实现
对于二叉树的一些性质其实早在c语言部分就已经介绍过了,在这里我们并不进行过多的赘述。这部分最重要的内容还是对这个二叉树进行一下实现,完成一些基本的接口。比如增、删、查等。这里需要注意的是,二叉搜索树一般是不支持修改的,所以就不进行实现了。
再次注意:前文已经提及,本篇文章中实现的是无相同值的版本。
树的结构
这是树的节点的结构,注意这也是一个类模板。而且在树这一块更习惯称内部存储的数据是key,所以模板参数写成K。
//节点结构
template<class K>
struct BSTreeNode {K _key;BSTreeNode<K>* _left;BSTreeNode<K>* _right;BSTreeNode(const K& key):_key(key),_left(nullptr),_right(nullptr){}
};
这里已经写了一个构造函数,所以编译器并不会生成默认构造函数。我这里并没有写,这是因为构造树的节点的时候,大部分情况下,内部的存储的数据都是已知的,所以直接写这个版本的构造即可。
当然想要生成默认构造也可以,可以自己写,也可以根据c++11的特性进行强制生成:
BSTreeNode() = default;
这是可以直接进行强制生成的。(即在我们不写任何的构造的情况下编译器默认生成的版本)。
对于二叉搜索树这个类就简单了,里面的成员就只有一个指向节点的指针,其余的都是一些接口即可:
//基本结构
template<class K>
class BSTree {
public: using Node = BSTreeNode<K>; //等同于typedefBSTree() = default;//c++11特性 强制生成默认构造函数//中序遍历void InOrderTraverse() {_InOrderTraverse(_root);cout << endl;}private://中序遍历void _InOrderTraverse(Node* root) {if (root == nullptr) return;_InOrderTraverse(root->_left);cout << root->_key << " ";_InOrderTraverse(root->_right);}Node* _root = nullptr;
};
我们来看using这个关键字的新用法,和typedef一样,可以进行对类型的重命名,方式为:using 别名 = 类型名。
我们来看这里的中序遍历为何如此奇怪,为什么要套一层?
因为递归遍历是需要传入一个开始位置的,即整个树的根节点的指针。但是,如果在外界来调用这个中序遍历,是没办法传入根节点指针的,因为一个树的根节点指针被放在了 private域中,外界是不能访问的。方法可以是通过一个成员函数getTreeRoot()来获取树根。但是这样比较麻烦,我们更希望的是在外界能够直接调用这个接口就能达到这个效果。那就只能让这里套一层,因为在成员函数内还是可以使用类内的东西的。将需要传根的函数放在私域中,防止外界调用即可(本身外界也不可用这个函数)。
插入操作
插入操作我们定义为insert,我们来缕一缕插入的思路:
假设当前已经有一个树了,想要插入一个数据。我们使用cur指针进行遍历,进行比根操作,比根大往右走,反之往左走。不会出现相同的值。然后走到空了就可以停下来插入了。
但是有几个点需要注意:
- 如果cur走到空,是直接让cur = 新节点的地址嘛?
很明显不能是,因为如果是这样的那么这个节点没有和原来的那个树进行连接,这个cur又是在局部域开辟的一个指针变量,出了作用域就被销毁了。虽然原来那个节点是开辟在堆区,但是没有连接回那个树,变成了一个孤立的节点了。所以必须在cur遍历的同时,始终能让另一个指针curParent始终指向的是cur指向节点的父节点。 - 需要确定cur指向节点是在curParent指向节点的左边还是右边。这需要我们判断一下。很多人会不假思索的写成这样:
Node* newnode = new Node(val);
if(curParent->_left == cur) curParent->_left == newnode;
else curParent->_right == newnode;
这样写绝对是有问题的。如果出现这种情况呢:
此时cur肯定是在右边的。但是注意一下,此时cur的值是nullptr,curParent->_left的值也是nullptr,这就出问题了。此时curParent->_left == cur为真,那14直接被插入左边去了。这肯定不对,所以不能拿指针来判断。必须拿插入的值和curParent指向节点的值比较,大于插入右边,小于插入左边。代码实现等下展示。
- 由于规定了不能插入相同的值,所以需要进行规避。我们规定插入成功返回true,反之返回false。当遇到相同的值得到时候直接返回false。
- 我们刚刚的全部假设都是树已存在(根不为空),但是根可能也会是空的,所以需要特殊处理一下。
最后我们来看看插入操作的代码:
bool insert(const K& key) {if (_root == nullptr) {_root = new Node(key); return true;}Node* cur = _root, *curParent = _root;while (cur) {if (key > cur->_key)cur = cur->_right;else if (key < cur->_key)cur = cur->_left;elsereturn false;if (cur) curParent = cur;}//插入逻辑Node* newnode = new Node(key); //不能用指针判断if (key < curParent->_key) curParent->_left = newnode; else curParent->_right = newnode; return true;
}
查找操作
查找操作我们设为find,找到就返回节点地址,找不到就返回nullptr。
查找操作十分简单,我们直接看代码就好了:
Node* find(const K& key) {Node* cur = _root;while (cur) {if (key < cur->_key)cur = cur->_left;else if (key > cur->_key)cur = cur->_right;else return cur;}return nullptr;
}
但是需要注意的是,这是针对于没有相同值的代码。如果有相同值,这个代码是不可行的。
因为规定是:在二叉搜索树中,如果出现了相同值,那就要找到的是出现在中序遍历中最前面的那个。当然这里我们并没有这样实现。所以了解一下即可。
删除操作
真正在二叉树中最麻烦的部分是删除节点。面试或者笔试经常问的也就是这些。所以我们来重点看一下对于二叉搜索树是如何进行删除节点操作的。
节点的分类
我们还是看那个很标准的二叉树:
很明显节点可以被分成四类:
- 删除叶子节点:直接删除,并且让父节点指向叶子节点的指针置空即可。如删除1。
- 删除节点的左边为空,右边不为空:需要让父亲节点指向这个被删除节点的指针指向被删除节点的右边。如删除10。
- 删除节点的左边不为空,右边为空:需要让父亲节点指向这个被删除节点的指针指向被删除系欸但的左边。如删除14.
- 删除节点的左右均非空:这种就不能使用上面那三种方法了。需要使用替换法。因为只需要把这个节点删了,并且让剩下的节点构成新的二叉搜索树就可以了。
我们需要针对于第四个情况重点讲解,怎么样使用替换法呢?如我们想删除8这个节点,我们就需要找到左子树最大的值,或者右子树最小的值,把这个值赋值给被删除节点的值,然后再删除被替换的那个节点。即我们可以把7或者10赋值给8那个删除节点。然后删除7或者10对应节点即可。我后面的实现选择找右子树最小值。
为什么要规定这样替换?因为要保证以被删除节点位置为根节点的子树仍是二叉搜索树。替换上来的值还要让左边小于它,右边大于它。那就只能选择左大右小这样的替换方式了。
那替换完后应该如何删除呢?因为我们找的是右子树最小值,其实也就是右子树的最左边的节点(该节点左边一定为空)。所以直接从上到下找到这个被节点,另外让一个指针指向该节点的父节点,然后让父节点指向它的指针指向被删除节点的右边即可(因为左边为空)。如果是找左子树最大值那这个逻辑就得反过来。
然后就是针对于这四种情况进行分别删除,但是写四个分支太麻烦了,然后我们发现叶子节点其实可以合并到第2或者第3点去,我选择1,2两点进行合并。
代码实现
我们来直接看看代码实现:
bool erase(const K& key) {//大致分为四类//1.删叶子节点//2.节点左空右不空//但是可以把1 2 分为一类//3.节点左不空右空//4.节点左右均不空Node* cur = _root, *curParent = cur;while (cur) {if(key > cur->_key)cur = cur->_right;else if(key < cur->_key)cur = cur->_left;else {//找到指定数据 进行删除//1.叶子节点 和 节点左空右不空if (cur->_left == nullptr) {//注意如果是根节点需要特殊处理if (cur == _root) _root = _root->_right;else {if (cur->_key < curParent->_key)curParent->_left = cur->_right;else curParent->_right = cur->_right;}delete cur;}//2.左不空右空else if (cur->_right == nullptr) {if (cur == _root) _root = _root->_left;else {if (cur->_key < curParent->_key) curParent->_left = cur->_left;else curParent->_right = cur->_left;}delete cur;}//3.非叶子节点——替换法else {//找左子树最大的或者右子树最小的那个替换到cur位置去//然后删除被替换的那个位置即可//这里选择找右子树最小的(最左边的)Node* replace = cur, *replaceParent = replace;replace = replace->_right;while (replace->_left) {replaceParent = replace; replace = replace->_left; }cur->_key = replace->_key;//此时replace的左边一定是没有值的if (replace->_key < replaceParent->_key) replaceParent->_left = replace->_right;else replaceParent->_right = replace->_right;delete replace; }return true;}if (cur && cur->_key != key) curParent = cur;}return false;}
原理已经讲过了,这里就简单解释一下即可。
我们先让一个cur指针和curParent指针指向的是被删除节点和被删除节点的父节点。这需要我们自行控制逻辑去完成这个目标。但是有一个问题就是,针对于第一、第二种情况,很有可能根节点出现一边空的情况。那这个时候上述的逻辑就需要特殊处理一下(因为一开始让curParent指向了根节点)。所以需要特殊处理一下。
其次,一定要判断被删除节点是父节点的左边还是右边,这个是我们没办法确定的。这里可以使用指针来判断。因为一旦有指向的节点必然不会出现上面那个情况。但是也可以用指向的值的大小来判断。这个自行选择即可。
当删除的是左右非空的节点的时候,需要使用替换法。让replace指针找到被替换的节点,并且让replaceParent指向其父节点,然后就是让父节点指向replace的右边(左边为空)。也是需要判断replace在左边还是右边的。
删除成功就返回true,反之返回false。
默认成员函数
我们来简单的写一下默认成员函数。
默认构造我们已经进行强制生成了,所以不用理会。我们也可以针对于不同形式的输入写一些对应的构造。我比较推荐写initializer_list的构造。但是由于当前这个数据结构缺陷其实蛮大的,如果传入的数据太有序是会导致性能退化。所以先不写也可以。
我们就针对于默认构造、拷贝构造、赋值重载、析构函数进行定义实现。
拷贝构造可能会想写现代写法,但其实很难。为什么string却可以呢?因为string提供了其存储内容的指针,有提功力通过字符串构造的函数。所以可以这么写。但是对于这里的树而言,只通过一个根节点指针来构造后面的也不太现实。所以还是需要我们自行一个一个插入的。插入的方法其实就是通过前序遍历复制节点过来,就和之前写的根据前序遍历构造二叉树一样,也是需要递归,那也是要套一层:
public:BSTree(const BSTree& bst) {_root = copy(bst._root);}
private:Node* copy(Node* root) {if (root == nullptr) return nullptr;Node* newRoot = new Node(root->_key);newRoot->_left = copy(root->_left);newRoot->_right = copy(root->_right);return newRoot;
}
写了拷贝构造后,我们就可以让赋值重载写成现代写法了,因为传参的时候就调用到了拷贝构造,拷贝构造又已经写好了。
public:BSTree<K>& operator=(BSTree bst) {swap(_root, bst._root);return *this;
}
析构函数其实就是销毁二叉树,这个在学习二叉树的时候早已实现过,使用后根遍历依次删除根节点即可:
public:~BSTree() {destory(_root);_root = nullptr;
private:void destory(Node* root) {if (root == nullptr) return;destory(root->_left);destory(root->_right);delete root;}
由于是递归,也需要自行套一层。
二叉搜索树key和key/value使用场景
现在来讲解一下二叉搜索树的一些使用场景:
key搜索场景:
只有key作为关键码,结构中只需要存储key即可,关键码即为需要搜索到的值,搜索场景只需要判断
key在不在。key的搜索场景实现的⼆叉树搜索树⽀持增删查,但是不⽀持修改,修改key破坏搜索树结
构了。
场景1:小区无⼈值守⻋库,⼩区⻋库买了⻋位的业主⻋才能进⼩区,那么物业会把买了⻋位的业主的
⻋牌号录⼊后台系统,⻋辆进⼊时扫描⻋牌在不在系统中,在则抬杆,不在则提⽰⾮本⼩区⻋辆,⽆
法进⼊。
场景2:检查⼀篇英文文章单词拼写是否正确,将词库中所有单词放⼊二叉搜索树,读取⽂章中的单
词,查找是否在二叉搜索树中,不在则波浪线标红提⽰。
key/value搜索场景:
每⼀个关键码key,都有与之对应的值value,value可以任意类型对象。树的结构中(结点)除了需要存
储key还要存储对应的value,增/删/查还是以key为关键字⾛二叉搜索树的规则进⾏⽐较,可以快速查
比特就业课
找到key对应的value。key/value的搜索场景实现的二叉树搜索树⽀持修改,但是不⽀持修改key,修
改key破坏搜索树性质了,可以修改value。
场景1:简单中英互译字典,树的结构中(结点)存储key(英⽂)和vlaue(中⽂),搜索时输⼊英⽂,则同
时查找到了英文对应的中文。
场景2:商场无⼈值守⻋库,⼊⼝进场时扫描⻋牌,记录⻋牌和⼊场时间,出⼝离场时,扫描⻋牌,查
找⼊场时间,用当前时间-⼊场时间计算出停⻋时⻓,计算出停⻋费用,缴费后抬杆,⻋辆离场。
场景3:统计⼀篇⽂章中单词出现的次数,读取⼀个单词,查找单词是否存在,不存在这个说明第⼀次
出现,(单词,1),单词存在,则++单词对应的次数。
根据上面两种形式发现,其实最本质的区别就是——映射关系。key模式就只需要针对于关键字样key来进行操作和使用。而key_value模式则是依据关键字样key来存储,操作的确实其对应的那个value。这是由映射关系在的。
key_value模式代码实现
其实就是在节点中多加入一个存储的变量,然后针对于部分逻辑进行修改即可:
namespace key_value {template<class K, class V>struct BSTreeNode {K _key;V _value;BSTreeNode<K, V>* _left; BSTreeNode<K, V>* _right; BSTreeNode(const K& key, const V& value):_key(key),_value(value),_left(nullptr),_right(nullptr){}};template<class K, class V>class BSTree {public:using Node = BSTreeNode<K, V>; //等同于typedefBSTree() = default;//c++11特性 强制生成默认构造函数BSTree(const BSTree& bst) {_root = copy(bst._root);}BSTree<K, V>& operator=(BSTree bst) {swap(_root, bst._root);return *this;}~BSTree() {destory(_root);_root = nullptr;}//插入bool insert(const K& key, const V& value) {if (_root == nullptr) {_root = new Node(key, value);return true;}Node* cur = _root, * curParent = _root;while (cur) {if (key > cur->_key)cur = cur->_right;else if (key < cur->_key)cur = cur->_left;elsereturn false;if (cur) curParent = cur;}//插入逻辑Node* newnode = new Node(key, value);//不能用指针判断if (key < curParent->_key) curParent->_left = newnode;else curParent->_right = newnode;return true;}//由于这个不存在重复的数据,所以很好找。//但是如果是存在相同的数据,那就要到的是在中序遍历中靠前的那一个Node* find(const K& key) {Node* cur = _root;while (cur) {if (key < cur->_key)cur = cur->_left;else if (key > cur->_key)cur = cur->_right;else return cur;}return nullptr;}bool erase(const K& key) {//大致分为四类//1.删叶子节点//2.节点左空右不空//但是可以把1 2 分为一类//3.节点左不空右空//4.节点左右均不空Node* cur = _root, * curParent = cur;while (cur) {if (key > cur->_key)cur = cur->_right;else if (key < cur->_key)cur = cur->_left;else {//找到指定数据 进行删除//1.叶子节点 和 节点左空右不空if (cur->_left == nullptr) {//注意如果是根节点需要特殊处理if (cur == _root)_root = _root->_right;else {if (cur->_key < curParent->_key) curParent->_left = cur->_right;else curParent->_right = cur->_right;}delete cur;}//2.左不空右空else if (cur->_right == nullptr) {if (cur == _root)_root = _root->_left;else {if (cur->_key < curParent->_key) curParent->_left = cur->_left;else curParent->_right = cur->_left;}delete cur;}//3.非叶子节点——替换法else {//找左子树最大的或者右子树最小的那个替换到cur位置去//然后删除被替换的那个位置即可//这里选择找右子树最小的(最左边的)Node* replace = cur, * replaceParent = replace;replace = replace->_right;while (replace->_left) {replaceParent = replace;replace = replace->_left;}cur->_key = replace->_key;//此时replace的左边一定是没有值的if (replace->_key < replaceParent->_key) replaceParent->_left = replace->_right;else replaceParent->_right = replace->_right;delete replace;}return true;}if (cur && cur->_key != key) curParent = cur;}return false;}//二叉搜索树一般是不允许修改的//中序遍历void InOrderTraverse() {_InOrderTraverse(_root);cout << endl;}private://中序遍历void _InOrderTraverse(Node* root) {if (root == nullptr) return;_InOrderTraverse(root->_left);cout << root->_key << " " << root->_value << " ";_InOrderTraverse(root->_right);}Node* copy(Node* root) {if (root == nullptr) return nullptr;Node* newRoot = new Node(root->_key, root->_value);newRoot->_left = copy(root->_left);newRoot->_right = copy(root->_right);return newRoot;}void destory(Node* root) {if (root == nullptr) return;destory(root->_left);destory(root->_right);delete root;}Node* _root = nullptr;};}
只需要稍微改动一下节点结构,插入逻辑即可。因为查找和删除逻辑的依据都是关键字样key,其余的地方也没有太大差别。
key/key_value搜索场景
现在,我们规定两个命名空间key和key_value,内部分别实现的是不同的场景。我们现在稍微展示一下二者的使用场景:
key模式
看看下一段测试代码:
int main() {key::BSTree<int> t1;int a[] = { 8, 3, 1, 10, 1, 6, 4, 7, 14, 13 };for (auto x : a) {t1.insert(x);}t1.InOrderTraverse();t1.insert(20);t1.insert(15);t1.InOrderTraverse();cout << t1.find(3) << endl;cout << t1.find(15) << endl;cout << t1.find(11) << endl;t1.erase(13);t1.InOrderTraverse();t1.erase(3);t1.InOrderTraverse();int a1[] = { 8, 3, 1, 10, 1, 6, 4, 7, 14, 13, 20, 15};cout << "数组a1: ";for (auto x : a1) {cout << x << " ";}cout << endl << endl;cout << "原本二叉树中序遍历: ";t1.InOrderTraverse();cout << endl;for (auto x : a1) {cout << "erase(" << x << "): ";t1.erase(x);t1.InOrderTraverse();}return 0;
}
输出结果:
我们发现这是能正常使用的。只要删除完还是能保证中序遍历是有序就是正确的。如果删除逻辑有问题很容易会导致进程崩溃。
key_value模式
我们来看看另外一种模式的应用。
场景1:统计水果出现次数。这种就很明显是映射关系了
int main()
{string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜","苹果", "香蕉", "苹果", "香蕉" };key_value::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 == nullptr){countTree.insert(str, 1);}else{// 修改valueret->_value++;}}countTree.InOrderTraverse();key_value::BSTree<string, int> copy = countTree;copy.InOrderTraverse();return 0;
}
输出结果是正确的。
场景2:实现简单的英文转汉字字典:
int main()
{key_value::BSTree<string, string> dict;//BSTree<string, string> copy = dict;dict.insert("left", "左边");dict.insert("right", "右边");dict.insert("insert", "插入");dict.insert("string", "字符串");string str;while (cin >> str){auto ret = dict.find(str);if (ret){cout << "->" << ret->_value << endl;}else{cout << "无此单词,请重新输入" << endl;}}return 0;
}
这里仅仅作为测试用例而已,所以选几个单词即可。
输出是正常的。但是这里是使用流作为是否循环的参数,所以想要通过输入停止下来就得输入一些异常的符号让其停下。输入ctrl c即可。
到此,这个简单的BinarySearchTree就完成实现了。