目录
1 二叉搜索树的基本概念
2 二叉搜索树的构建
2.1 二叉搜索树的结点
2.2 搜索树类的结构
3 成员函数
3.1 插入
3.2 查找
3.3 删除(重点)
3.4 默认成员函数的辅助函数
4 普通的二叉搜索树的效率
1 二叉搜索树的基本概念
1. 若是它的左子树不为空,那么左子树的所有节点值都小于根节点的值。
2. 若是它的右子树不为空,那么右子树的所有节点值都大于根节点的值。
3. 它的左右子树也符合二叉搜索树的特征。
下图则是二叉搜索树的形象化展示:
如图可以直观的感受到,当一棵树为二叉搜索树的时候,它的结构特征都满足我之前所描述的概念,并且,通过我箭头的访问方式,也可以看到,最后它的访问结果一定是一个升序的序列,这也是为什么二叉搜索树相比较于普通的二叉树来书,具有了实际的意义。
所以在此基础上,我们才能对这个树的结点进行有意义的比较,插入,删除等操作。
2 二叉搜索树的构建
2.1 二叉搜索树的结点
template<class K>
struct BinaryNode
{
K _key;
BinaryNode<K>* _left;
BinaryNode<K>* _right;BinaryNode<K>(const K& key)
:_key(key),_left(nullptr),_right(nullptr){}
};
作为一个二叉树,那么它必然的就需要有自己的结点,在这里博主采用了最简单的二叉链的方式为大家展示,也就是普通的数据、左节点、右节点,加上一个有参构造。
对于博主而言,我是不太支持对于搜索树添加一个无参构造的方式,因为作为二叉搜索树,其每一个结点都是有自己独立的意义的,如果添加无参构造,那么生成的结点对于我们来说有什么实际的意义呢?不如直接对外不开放这功能,让用户感受到他编写时,实际的问题所在。
然后又因为我们要进行范式编程,那么必然的,就需要用到模板相关的知识,而对于普通的二叉树结构而言,需要的无非就是存储的数据,或则是它的比较方式需要添加模板。例如有部分的数据是一个结构,像是pair之类的,但是博主这里默认他就是一个普通的单个变量,毕竟才刚开始,博主并不打算增加你们的负担。
2.2 搜索树类的结构
template<class K>
class BSTree
{
typedef BinaryNode<K> Node;
public:
BSTree()
{_root = nullptr;
}
BSTree(const BSTree<K>& copy)
{
_root = _copy(copy);
}BSTree<K>& operator=(BSTree<K>& copy)
{
swap(_root, copy._root);
return *this;
}~BSTree()
{
destroy(_root);
}private:
Node* _root;
};
对于搜索树的结构这部分博主并不打算作解释,里面就是关于它的默认函数以及有一个根节点指针所组成,也没有必要讲解,默认成员函数里面有一些实际的功能,博主打算在下一节为大家讲解。
3 成员函数
3.1 插入
//插入
bool insert(const K& key)
{
//第一次插入
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
//之后的插入,要保证之后的连接
Node* cur = _root;
while (cur)
{
//大于往右走
if (key > cur->_key)
{
if (cur->_right == nullptr)
{
cur->_right = new Node(key);
return true;
}
cur = cur->_right;
}
//小于往左走
else if (key < cur->_key)
{
if (cur->_left == nullptr)
{
cur->_left = new Node(key);
return true;
}
cur = cur->_left;
}
//已经有了该数据,插入失败
else
{
return false;
}
}
//不可能走到的位置
return false;
}
普通的二叉搜索树的插入十分的简单,因为它的特性保证了,我们插入时的高效性,如下:
如果我们的二叉树是一个链式结构,那么势必会让我们的每一次插入变为O(N)的时间复杂度,但是由于它是搜索树,根据搜索树的特性,在理想情况下,我们每一次比较都能够排除一半的选项,这也就代表了,插入的效率变为了O(log2_N)了,当然一般是没有这么好的结构的,我们之后再解释。
回过头看代码,首先我们得保证时候为第一次插入,也就是开始时,我们搜索树对象的根在初始时为空,我们需要修改它,保证它的有效性,否则在之后访问根结点时会出现解引用根节点导致程序崩溃的问题。
之后就是找插入位置的问题了,通过循环比较当前结点与插入结点key值的关系,然后再考虑向左移动还是向右移动,当检测到下一步移动的结点为空就表示了,找到了需要插入的位置,通过new结点的方式在这个位置连接上去。
可能之前就有朋友会问了,如果插入了值我们已经有了应该怎么办呢?很明显,在我们的这个树结构里面没有很好的方式去解决它,不过也不是不能解决,学习库容器multimap也能够解决,也就是在这个结点的位置规定它的左节点,或则是右节点的位置像是链表的方式一样插入就好,其余的结构不作改变。博主并不想这么解决,所以所幸就直接返回一个插入失败就行了,简单粗暴。
3.2 查找
//查询
bool find(const K& key) const
{
//树内还没有数据
if (_root == nullptr)
{
return false;
}
//查找
Node* cur = _root;
while (cur)
{
if (key > cur->_key)
{
cur = cur->_right;
}
else if (key < cur->_key)
{
cur = cur->_left;
}
else
{
return true;
}
}
return false;
}
对于查找就更简单了,博主也不想作解释,与插入方式基本一致。
3.3 删除(重点)
问题:
看了上面的两段代码,我相信大家心中肯定会认为“这就是搜索树?感觉也没什么难度吗,我上我也行。”如果大家真是这么想的,那只能是大家小看它了,请大家自己思考以下,如果我们要删除一个结点应该怎么删除呢?
我给大家一点提示,删除叶子结点,删除只有一个孩子的结点,删除有两个孩子的结点,删除根节点,总的也就是这几种情况,看看大家能不能想到办法解决这个问题呢?
看到上面的这一棵搜索树,比如说我要删除42号结点、45号结点、15号结点、30号结点。
讲解:
我们先从简单的问题开始入手,删除一个叶子结点应该是怎么样的呢?
首先,叶子结点就是一个没有孩子的结点,向上也只有父节点连接这它,那么它的删除,只会影响到谁?那就是它的父亲的子节点,与其余的结点有关系吗?没有,那么删除之后就会变为下图:
很轻松,也没有任何的问题,那么开始下一个问题,删除45应该怎么办?
删除45也就是删除一个有一个孩子的结点,删除它本身并不重要,重要的是,我们如何让它的孩子还在我们的这个结构当中呢?这个时候我们就需要找一个人来帮忙管理了,谁有资格?当然是父亲结点,如果你在父亲的左边,那么连带你和你的子节点都会比父节点小,反之则是大。
还有在连接的时候,需要判断是哪一个结点需要需要被管理,是父节点的那边代为管理。那么最终就会为我们呈现出如下的图:对结构也是没有任何影响的。
最后就是删除15或则是30位置的结点应该怎么办呢?我们的父节点有没有能力帮助我们管理好两个孩子。所以这个时候就需要去聘请一个保姆咯,那么什么样的人可以作为我们的保姆呢?我也不买关子了,很简单,保姆只能是左子树最大的那个结点,或则是右子树最小的那个结点,一旦满足了这个条件,那么对应的,保姆就只能是只有一个孩子或则是没有孩子,我们对于没有孩子和有一个孩子的结点删除有方法吗?有!刚刚才讲嘛。
那么为什么用左最大或则是右最小结点就能够当保姆了呢?请观察下图:
如上图所示,我们将这两个结点的数据去覆盖原来删除位置的数据,是不是表示我们已经删除了这个结点了?毕竟结点本身没有意义,有意义的是他的数据,还有,我们替换了之后,他还是不是二叉搜索树了?是的,那么新的删除位置我们有能力直接删吗?有能力。那么为什么能这么做呢?因为左子树的最大结点作根一定满足小于右子树,大于左子树,同理右子树的最小结点也是一样的。
所以这样的方式,就能够让我们实现对于一个有两个孩子的结点的删除,并且因为两个孩子的逻辑需要重复包含删除一个结点和删除两个结点,那么书写顺序就让其写在最前面,如果是拆分功能成为函数,则不需要这么考虑,直接调用即可。
代码:
//删除
bool erase(const K& key)
{
//为空不能删除
if (_root == nullptr)
{
return false;
}
//找到需要删除的那个结点位置
//保留父节点
Node* prev = nullptr;
Node* cur = _root;
while (cur)
{
if (key > cur->_key)
{
prev = cur;
cur = cur->_right;
}
else if (key < cur->_key)
{
prev = cur;
cur = cur->_left;
}
else
{
break;
}
}
//没有找到
if (cur == nullptr) return false;
//找到了需要分多种情况,没有孩子,有一个孩子,有两个孩子
if (cur->_left != nullptr && cur->_right != nullptr)
{
//一定有左结点
prev = cur;
Node* sub = cur->_left;
//找左子树的最大结点,在左子树的最右位置
while (sub->_right != nullptr)
{
//找到的数据一定只有一个孩子或者是没有孩子
prev = sub;
sub = sub->_right;
}
//用值去覆盖,然后更换新的删除目标
cur->_key = sub->_key;
cur = sub;
}
//没有孩子
if (cur->_left == nullptr && cur->_right == nullptr)
{
//需要去掉父节点的指向
if (prev != nullptr)
{
if (prev->_left == cur) prev->_left = nullptr;
else prev->_right = nullptr;
}
delete cur;
return true;
}
//有一个孩子,需要用父节点来帮忙管理
else if ((!cur->_left && cur->_right) || (cur->_left && !cur->_right))
{
//父节点为空,表示需要删除的位置是根节点,那么此时直接把子节点放上来就行
if (prev == nullptr)
{
//更新根节点为它的不为空的那一个孩子
_root = cur->_left == nullptr ? cur->_right : cur->_left;
}
//左节点不为空
else if (cur->_left != nullptr)
{
//判断需要父节点的哪一个孩子结点去接收
if (prev->_left = cur)
prev->_left = cur->_left;
else
prev->_right = cur->_left;
}
//右节点不为空
else
{
//判断该节点连接到父节点的哪一个位置
if (prev->_left = cur)
prev->_left = cur->_right;
else
prev->_right = cur->_right;
}
}
delete cur;
return true;
}
3.4 默认成员函数的辅助函数
代码:(本身逻辑比较简单,博主不讲解)
Node* _copy(Node* root)
{
if (root == nullptr)
{
return nullptr;
}
Node* newNode = new Node(root->_key);
newNode->_left = _copy(root->_left);
newNode->_right = copy(root->_right);
return newNode;
}
void destroy(Node* root)
{
if (root == nullptr)
return;
destroy(root->_left);
destroy(root->_right);
delete root;
root = nullptr;
}
4 普通的二叉搜索树的效率
相信大家也看到了,搜索二叉树的效率,相比于我们的链表来说是更优秀的,但是他不稳定,为什么?因为他有可能成为下方的这种情况:
这是不是二叉搜索树?是,但是它的搜索效率是多少?O(N),这不扯淡吗,我费半天力气,写了个这完蛋玩意出来,普通的二叉搜索树,并不可靠,那么也就证明了容器map、set使用的底层结构并不是它,而是它的升级版,有些是AVL树,有些是红黑树,但是主流的写法都是红黑树。本篇文章,博主不打算对这两个数据结构进行讲解,将会在之后分享给大家。
以上就是博主对于普通的搜索二叉树的全部理解了,希望能够帮助到大家。