二叉搜索树又叫二叉排序树,它可以是一颗空树,或者它是一颗满足以下性质的二叉树:
1、若它的左子树不为空,则左子树所有结点的值都小于等于根结点的值
2、若它的右子树不为空,则右子树所有结点的值都大于等于根结点的值
3、它的左右子树也分别为二叉搜索树
二叉搜索树的底层结构就是之前学习过的二叉树,只不过给普通的二叉树添加了一些性质而已,通过这个性质,让我们能够更好的查找二叉树里存储的数据。最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),此时搜索的效率为:O(log2 N)。在最差情况下,二叉搜索树退化为单支树(或者类似单支树),此时的搜搜效率为:O( N/2 )。综合来看二叉搜索树的增删查改的时间复杂度为:O(N)。
一、二叉搜索树的构建
二叉搜索树的每一个存储数据的结点都是一个链表的结点,我们不能确定存储的数据时什么类型的所以需要用到模版。
struct BSTNode
{
K _key;
BSTNode<K>* _left;
BSTNode<K>* _right;
BSTNode(const K& key)
:_key(key)
, _left(nullptr)
, _right(nullptr)
{}
};
二叉搜索树的结构:我们本质上通过这个树的根结点就能找到这棵树,所以二叉搜索树里只要存储根节点的指针即可,为了方便后续的操作我们把结点的类型typedef成了Node。
template<class K>
class BSTree
{
typedef BSTNode<K> Node;
private:
Node* _root = nullptr;
};
结点的插入:当根结点为空时,我们把新结点赋值给根结点即可;当根节点不为空的时候,我们可以通过一个指针对结点的值进行比较,找到最终要插入的那个位置,但是我们只能向下寻找,不能向上返回,所以我们在寻找插入的位置的时候,要多用一个指针来记录最终位置的父结点,只有这样才能进行插入操作,这里我们实现的是不允许插入相同值的数据的,所以如果发现二叉搜索树内已经有这个数据,就会插入失败。
bool Insert(const K& key)
{
//当根节点为空时
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
//通过cur找到要插入的位置,再通过parent进行插入操作
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);
//判断要插入的位置是在父亲结点的左边还是右边
if (parent->_key > key)
parent->_left = cur;
else
parent->_right = cur;
return true;
}
二、节点的寻找
结点的寻找就按照插入寻找最终插入位置的方式去寻找即可,如果找到了就返回true,如果没找到返回false即可。
bool Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_key > key)
cur = cur->_left;
else if (cur->_key < key)
cur = cur->_right;
else
return true;
}
return false;
}
三、结点删除
结点的删除是几种操作中最复杂的。
首先查找元素是否在二叉搜索树里,如果不存在要返回false。
如果查找元素存在则要分四种情况分别处理:(假设要删除的结点为N)
· 1、要删除结点N左右孩子均为空
此时只要把N结点的父亲对应孩子的指针指向空,直接删除N结点即可。
2、要删除的结点N左孩⼦位空,右孩⼦结点不为空
此时只要把N结点的父亲对应孩子的指针指向N的右孩子,然后直接删除N结点。(这里可以直接把第一种情况结合到这里一起处理)
3、要删除的结点N右孩⼦位空,左孩⼦结点不为空
此时只要把N结点的父亲对应孩子的指针指向N的左孩子,然后直接删除N结点。
4、要删除的结点N左右孩子均不为空
这个时候无法直接删除N结点,因为删除N结点以后,N结点的两个孩子无法确定要放到什么位置,所以要采用替换法删除。找N左子树的值最大结点或者右子树的值最小结点,因为把这两个结点中任意一个放到N的位置,都不会破坏这棵树原本的性质。
bool Earse(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
//通过相同的方法寻找要删除的结点
if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
else
{
//待删除结点左为空及左右都为空
if (cur->_left == nullptr)
{
//如果要删除的结点为根结点 且没有左孩子 则直接把右孩子赋给根结点再删除原本的根结点即可
if (cur == _root)
{
_root = cur->_right;
}
else
{
//这里可能判断的是要删除的结点是父亲结点的左孩子还是右孩子
//把待删除结点的右子树接到父亲结点的对应位置即可
if (parent->_left == cur)
{
parent->_left = cur->_right;
}
else
{
parent->_right = cur->_right;
}
}
delete cur;
}
//待删除结点右为空
else if (cur->_right == nullptr)
{
//如果要删除的结点为根结点 且没有右孩子 则直接把左孩子的赋给根结点再删除原本的根结点即可
if (cur == _root)
{
_root = cur->_left;
}
else
{
//判断的是要删除的结点是父亲结点的左孩子还是右孩子
if (parent->_left == cur)
{
parent->_left = cur->_left;
}
else
{
parent->_right = cur->_left;
}
}
}
//待删除结点的两个孩子结点都不为空
else
{
//这里采用的是寻找右子树中的最小结点
//也就是利用一个循环到待删除结点的右子树中 找到值最小的结点以及它的父亲结点
Node* replaceParent = cur;
Node* replace = cur->_right;
while (replace->_left)
{
replaceParent = replace;
replace = replace->_left;
}
cur->_key = replace->_key;
//但是也存在没有不会进入上面循环的情况
//当右子树的根结点就是最小值结点的时候,不会进入上面的循环,此时replace结点是父亲结点的右孩子
if (replaceParent->_left == replace)
{
replaceParent->_left = replace->_right;
}
else
{
replaceParent->_right = replace->_right;
}
delete replace;
}
return true;
}
}
return false;
}
四、打印二叉搜索树
二叉搜索树再次一定程度上可以提高搜索的效率,同时如果我们对这棵树进行中序遍历,我们可以发现,打印出来的内容是按照升序排列好的,但是这里有个问题,我们在遍历这棵树的时候要用到根节点,但是根节点作为private修饰的数据在类外我们是拿不到的。为了解决这个问题,我们可以对这样函数多进行一层包装,先写一个私有的中序遍历的函数,然后对开发一个共有的接口,在这个共有函数的内部调用这个私有的函数就能实现遍历的功能了。
public:
void InOrder()
{
_InOrder(_root);
cout << endl;
}
private:
void _InOrder(Node* root)
{
if (root == nullptr)
return;
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
五、析构函数
在使用二叉搜索树的过程中我们new了很多的结点,这些结点都需要我们主动去释放,所以系统自动生成的析构函数是不够用的。利用递归的方式来删除结点是很方便的,但是析构函数本身并不能写成递归的方式,所以我们可以利用和遍历一样的方式,先编写一个私有的函数让析构函数来调用它,这样就能很轻松的完成结点的清除了。
public:
~BSTree()
{
Destory(_root);
_root = nullptr;
}
private:
void Destory(Node* root)
{
if (root == nullptr)
return;
Destory(root->_left);
Destory(root->_right);
delete root;
}
六、拷贝构造和赋值重载
默认生成的拷贝构造函数只会完成浅拷贝,如果两个对象指向同一片空间,在调用析构函数之后会对同一块空间进行重复的释放,这样是不行的。我们要用先序的方式对二叉树中的每一个结点都进行复制。
赋值重载就更简单了,我们在传参的时候,把参数传给一个形参,这个形参就会自动完成拷贝构造,我们只要把形参的根结点交换给我们this指针即可,在这个函数结束的时候,形参会自动销毁,直接就把我们要销毁空间也一起回收了。
public:
BSTree() = default;
BSTree(const BSTree& t)
{
_root = Copy(t._root);
}
BSTree& operator=(BSTree tmp)
{
swap(_root, tmp._root);
return *this;
}
private:
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;
}