目录
前言
一、二叉搜索树的基本概念
二、二叉搜索树相关操作的实现
1、基本框架
2、中序遍历
3、查找非递归版
4、查找递归版
5、插入非递归版
6、插入递归版
7、删除非递归版
8、删除递归版
9、构造函数
10、拷贝构造
11、赋值重载
12、析构函数
三、Key模型与Key_Value模型
1、Key模型应用场景
2、Key_Value模型
四、二叉搜索树的性能分析
五、总结
关于数据结构中的树,想必大家都不会很陌生,今天,主要给大家一起学习搜索二叉树(BST);前面,我们掌握学习过二叉树的三种遍历方式(前序,中序,后序的递归版本),以及我们实现过顺序结构的树(堆),前面的树在储存数据方面,并没有多大的意义,而今天所说的二叉搜索树在数据存储方面就有了很大的意义;
二叉搜索树又称二叉排序树,它主要有以下三条特性;
1、若根的左子树不为空,则它左子树上的所有节点都小于根结点;
2、若根的右子树不为空,则它柚子树上的所有结点都大于根结点;
3、他的左右子树也分别为二叉搜索树;
总结:也就是说,对于任何一个结点,若左子树存在,左子结点必小于该结点,若右子树存在,则右子结点必大于该结点;(记住,对于任何结点来说,这里的小于是左子树上所有结点都小于,大于也是如此)
二叉搜索树主要操作为查找,插入,删除以及遍历;说到遍历,这里不得不提,二叉搜索树另一特点,二叉搜索树的中序遍历是升序的;接下来我们一一实现二叉搜索树相关接口;
我们后面在来实现二叉搜索树的默认构造,拷贝构造,赋值重载以及析构函数,首先我们要清楚二叉搜索树是由一个一个结点组成,我们将这个结点也用类封装起来,二叉搜索树类中存放根节点的地址;
namespace KeyModel
{
template
// 结点
struct BSTreeNode
{
// 结点的构造
BSTreeNode(const Key& key)
:_key(key)
,_left(nullptr)
,_right(nullptr)
{}
// 结点的数据成员
Key _key;
BSTreeNode* _left;
BSTreeNode* _right;
};
// 二叉搜索树
template
class BSTree
{
public:
typedef BSTreeNode Node;
// 相关结构(待实现)...
private:
// 根节点
Node* _root = nullptr;
};
}
提供这个接口可以让我们清楚的观察到我们实现的树是否是一棵二叉搜索树,因为二叉搜索树的中序遍历是升序的;对于中序遍历,想必大家都轻车熟路,这里不做特殊讲解,不过这里需要特别注意的是,由于中序遍历的递归版本一般是需要传根节点这一参数的,而类外无法访问私有成员,因此这里做了一个很巧妙地设计,后面许多递归都是使用这个巧妙设计的;
public:
void inorder()
{
_inorder(_root);
std::cout << std::endl;
}
private:
void _inorder(Node* root)
{
if (root == nullptr)
return;
_inorder(root->_left);
std::cout << root->_key << " ";
_inorder(root->_right);
}
这里在套一层的思想非常巧妙,这样就不用提供类似getRoot这样的接口了;
查找的逻辑非常简单,如果查找的值比当前节点的值小就去左子树找,如果查找的值比当前节点大,就去右子树查找;如果相等就是找到了,返回true,如果循环结束还没找到,就是没找到,返回false;如下图所示,我们想找key为4的这个节点;
public:
// 非递归版本
bool find(const Key& key)
{
// 空树则无论找什么都找不到
if (_root == nullptr)
return false;
Node* cur = _root;
while (cur)
{
if (key < cur->_key)
{
cur = cur->_left;
}
else if (key > cur->_key)
{
cur = cur->_right;
}
else
{
return true;
}
}
return false;
}
查找递归版也是大概的思路;具体代码如下;
// 递归版本
public:
bool findR(const Key& key)
{
return _findR(_root, key);
}
private:
bool _findR(Node* root, const Key& key)
{
if (root == nullptr)
return false;
if (key == root->_key)
return true;
else if (key < root->_key)
return _findR(root->_left, key);
else
return _findR(root->_right, key);
}
要想插入一个数据,我们就必须得找到插入的位置,我们依旧使用查找的方法,若插入的值小于当前节点,就去当前结点的左子树去找插入位置,若插入的值大于当前节点,就去当前节点的右子树去找插入位置;若相等,则插入失败,因为二叉搜索树不允许数据的冗余,如果树中有了插入结点的值,则无需插入,返回false;如果循环退出,则找到了插入的位置,进行数据的插入;
找插入位置了,我们就要链接上新插入的结点,因此我们必须记录一个父节点,始终能找到当前插入的父节点的位置;
// 非递归版本
bool insert(const Key& key)
{
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
// 这里的父节点置空,不会引起后面的空指针解引用
// 因为走到这里root结点不可能为空,必进循环,
// 进循环parent值要么改变,要么返回false
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (key < cur->_key)
{
parent = cur;
cur = cur->_left;
}
else if (key > cur->_key)
{
parent = cur;
cur = cur->_right;
}
else
{
return false;
}
}
// 找到插入位置了
cur = new Node(key);
// 我们无法知道新结点是父节点的左孩子还是右孩子,因此需要判断
if (parent->_key > key)
{
parent->_left = cur;
}
else
{
parent->_right = cur;
}
return true;
}
会写插入非递归版本,递归版本也不会很难理解,不过这里使用了一种新玩法,我们参数传根节点的引用,这样我们找到插入的位置后,可以直接赋值,代码如下;
public:
bool insertR(const Key& key)
{
return _insertR(_root, key);
}
private:
bool _insertR(Node*& root, const Key& key)
{
// 找到了
if (root == nullptr)
root = new Node(key);
// 在左子树
if (key < root->_key)
{
return _insertR(root->_left, key);
} // 在右子树
else if (key > root->_key)
{
return _insertR(root->_right, key);
}
else
{
// 相等不需要插入
return false;
}
}
这几个方法中,就属删除的操作最麻烦,也最难以实现,需要考虑很多;删除有如下几种情况,我们一 一分析;
情况一:删除结点无孩子
情况二:删除结点无右孩子,却有左孩子
情况三:删除结点无左孩子,却有右孩子
情况四:删除结点既有左孩子,也有右孩子;这种情况是比较麻烦的,因此删除结点有两个孩子,无法将两个孩子都托孤给父亲,因为父亲只能管一边;这时我们采取两种方案,要么选择将删除节点的左子树中最大结点(左子树的最右结点)拿上来,替代删除结点的位置,要么选择将删除节点右子树的最小结点(右子树的最左节点)拿上来,替代删除结点的位置;
但是情况四中,右包含了三种特殊情况(我们采用拿左子树的最大结点替代进行问题的分析,如果你采用拿右边的最小结点来替代可以,只要进行同样的分析即可),分别为,替代的结点无左孩子,替代的结点有左孩子,替代的结点就是删除结点的左节点(因为替代的孩子结点就是左子树的最右结点,因此无需考虑其会存在右孩子);
是不是感觉非常复杂,小编也是这么认为,接下来,我们来看代码是如何实现的吧,代码中也存在很多细节;
bool erase(const Key& key)
{
// 根节点为空直接返回false,空树没得删
if (_root == nullptr)
return false;
// 同插入,这里下面的循环肯定会进去,但是后面需考虑这个为空的问题
Node* parent = nullptr;
Node* cur = _root;
// 查找删除结点位置
while (cur)
{
if (key < cur->_key)
{
parent = cur;
cur = cur->_left;
}
else if (key > cur->_key)
{
parent = cur;
cur = cur->_right;
}
else
{
Node* del = cur;
// 删除结点只有右节点或没有节点(情况一与情况三结合)
if (cur->_left == nullptr)
{
// _root == cur意味着整棵树没有左子树,特殊处理
if (_root == cur)
{
_root = cur->_right;
}
else
{
// 判断删除结点孩子应该放在父亲左边还是右边
if (cur == parent->_left)
{
parent->_left = cur->_right;
}
else
{
parent->_right = cur->_right;
}
}
} // cur只有左节点(情况二)
else if (cur->_right == nullptr)
{
// 同上,整棵树可能没有右子树,特殊处理
if (_root == cur)
{
_root = cur->_left;
}
else
{
if (cur == parent->_left)
{
parent->_left = cur->_left;
}
else
{
parent->_right = cur->_right;
}
}
}
else
{
// cur左右结点都有(情况四)
Node* pmaxLeft = cur; // 这里不能设置为空,防止特殊情况3
Node* maxLeft = cur->_left;
// 找替代结点(左子树的最右结点)
while (maxLeft->_right)
{
pmaxLeft = maxLeft;
maxLeft = maxLeft->_right;
}
// 拿替代节点的值给删除结点重新赋值
cur->_key = maxLeft->_key;
// 特殊情况3
if (pmaxLeft == cur)
{
pmaxLeft->_left = maxLeft->_left;
}
else
{
// 特殊情况1和2的结合
pmaxLeft->_right = maxLeft->_left;
}
del = maxLeft;
}
delete del;
return true;
}
}
return false;
}
整个代码逻辑合起来将近100行,而是非常的恐怖的一段代码,稍有不慎,一种情况没注意到都会出错;
有了非递归版本,这里的递归版本也比较容易实现了,可以使用我们刚才的传结点指针的引用的思路,问题也就迎刃而解,同样也是情况四比较复杂;代码如下;
public:
bool eraseR(const Key& key)
{
return _eraseR(_root, key);
}
private:
bool _eraseR(Node*& root, const Key& key)
{
if (root == nullptr)
return false;
if (key < root->_key)
{
return _eraseR(root->_left, key);
}
else if (key > root->_key)
{
return _eraseR(root->_right, key);
}
else
{
Node* del = root;
if (root->_left == nullptr)
{
root = root->_right;
}
else if (root->_right == nullptr)
{
root = root->_left;
}
else
{
// 写法一(使用非递归版本相同写法)
//Node* pmaxLeft = root;
//Node* maxLeft = root->_left;
//while (maxLeft->_right)
//{
// pmaxLeft = maxLeft;
// maxLeft = maxLeft->_right;
//}
//root->_key = maxLeft->_key;
//if (root == pmaxLeft)
//{
// pmaxLeft->_left = maxLeft->_left;
//}
//else
//{
// pmaxLeft->_right = maxLeft->_left;
//}
//del = maxLeft;
// 写法二(复用递归版)
Node* maxLeft = root->_left;
while (maxLeft->_right)
{
maxLeft = maxLeft->_right;
}
// 注意这里是交换,交换以后去左子树递归删除
std::swap(root->_key, maxLeft->_key);
_eraseR(root->_left, key);
return true;
}
delete del;
return true;
}
}
构造函数没什么可写的,我们只要将根设置为空即可;
// 构造
BSTree()
:_root(nullptr)
{}
拷贝构造可是必须的,编译器给我们实现拷贝构造是浅拷贝,这里需要深拷贝,具体思路还是使用递归,用前序遍历的思想来拷贝构造;如果不大理解可以画递归展开图进行理解;
public:
// 拷贝构造
BSTree(const BSTree& bst)
{
_root = construct(bst._root);
}
private:
Node* construct(Node* root)
{
if (root == nullptr)
return nullptr;
Node* newnode = new Node(*root);
newnode->_left = construct(root->_left);
newnode->_right = construct(root->_right);
return newnode;
}
赋值重载,我们同样选择深拷贝,这里我们选择使用现代写法,复用拷贝构造函数;
// 赋值重载
BSTree operator=(BSTree bst)
{
std::swap(_root, bst._root);
return *this;
}
析构函数这里也是使用递归的思路,我们用后序遍历的方法进行析构;
public:
// 析构函数
~BSTree()
{
destructor(_root);
}
private:
void destructor(Node*& root)
{
if (root == nullptr)
return;
destructor(root->_left);
destructor(root->_right);
delete root;
root = nullptr;
}
前面,我们实现的二叉搜索树是Key模型,一个结点存放一个值,这种key类型的模型应用于哪里呢?实际上,这种key模型通常用来解决在不在的问题;
例如:我们想查看一篇文章的单词是否都拼写正确;这时我们使用在不在的思路,我们要将文章中每个单词在词库中查找,查看是否存在,以下我们便写了一个Key模型的使用;
void TestKey()
{
KeyModel::BSTree dict;
dict.insert("left");
dict.insert("right");
dict.insert("computer");
dict.insert("insert");
dict.insert("erase");
// 模拟检查每个单词是否拼写正确
while (true)
{
std::string str;
std::cin >> str;
bool is_exist = dict.find(str);
if (is_exist)
std::cout << "在" << std::endl;
else
std::cout << "不在" << std::endl;
}
}
对以上二叉搜索树进行简单的改造,即可成为Key_Value模型,由于代码太长,导致文章篇幅增大,所以代码我己经提交到gitee上了,可点击下方链接获取;
源码链接
关于Key_Value模型,通常主要是通过一个值查找另一个值,是一种一 一对应的关系,一个key对应一个value;
例如:我们想实现一个词典,如下代码所示;
// 查找中文的对应翻译
void TestKeyValue1()
{
KeyValueModel::BSTree dict;
dict.insert("left", "左边");
dict.insert("right", "右边");
dict.insert("area", "区域");
dict.insert("insert", "插入");
dict.insert("apple", "苹果");
std::string str;
while (std::cin >> str)
{
auto pos = dict.find(str);
if (pos != nullptr)
{
std::cout << pos->_val << std::endl;
}
else
{
std::cout << "查找单词不存在" << std::endl;
}
}
}
例如:我们想统计某样物品的个数;如下代码所示;
// 统计水果个数
void TestKeyValue2()
{
KeyValueModel::BSTree fruits;
std::string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜","苹果", "香蕉", "苹果", "香蕉" };
for (auto e : arr)
{
auto ret = fruits.find(e);
// 如果存在则++
if (ret != nullptr)
{
ret->_val++;
}
else
{
// 如果不存在,则添加新品种水果
fruits.insert(e, 1);
}
}
fruits.inorder();
}
正常情况下,二叉搜索树想要查找一个值的时间复杂度为树的高度,为O(log N);而实际上,有一种极端场景不可避免,那就是歪脖子树,此时,查找效率由原来的O(log N)退化到O(N);极端场景如下图所示;
这其实也跟插入顺序有关,因为我们的插入顺序不同,二叉搜索树的形状也就不同,因此,为了解决这个问题,后面我们接着会学习AVL树与红黑树解决这个问题;
关于我们的二叉搜索树,我们就介绍到了这里,关于更多后序知识,请持续关注小编,同时也感谢你们的关注,慢慢一起见证小编的成长,看到这里,请给一个免费的关注、点赞与收藏吧;谢谢大家;