♥♥♥~~~~~~欢迎光临知星小度博客空间~~~~~~♥♥♥
♥♥♥零星地变得优秀~也能拼凑出星河~♥♥♥
♥♥♥我们一起努力成为更好的自己~♥♥♥
♥♥♥如果这一篇博客对你有帮助~别忘了点赞分享哦~♥♥♥
♥♥♥如果有什么问题可以评论区留言或者私信我哦~♥♥♥
✨✨✨✨✨✨ 个人主页✨✨✨✨✨✨
这篇博客,我们将开始一个不太一样的数据结构——二叉搜索,树准备好了吗~我们发车去探索C++的奥秘啦~🚗🚗🚗🚗🚗🚗
目录
什么是二叉搜索树?
二叉搜索树是一种特殊的树形数据结构,它看起来像一棵普通的树,但每个节点都有一些特定的规则:
根节点:这是树的起点,所有的节点都是从这里开始生长的(当然,如果树是空的,那就没有根节点了)。
左子树和右子树:每个节点都可以有左子树和右子树。就像人的左手和右手一样,左子树在节点的左边,右子树在节点的右边。
排序规则:
- 如果左子树存在,那么左子树上的所有节点的值都比根节点的值要小或者相等(但通常在标准的二叉搜索树中,我们约定左子树上的值都严格小于根节点的值)。
- 如果右子树存在,那么右子树上的所有节点的值都比根节点的值要大或者相等(同样地,在标准的二叉搜索树中,我们约定右子树上的值都严格大于根节点的值)。
递归规则:这个规则不仅适用于根节点,还适用于根节点的所有子节点,以及子节点的子节点,以此类推。也就是说,每一棵左子树和右子树也都要满足二叉搜索树的定义。
相等的值:对于二叉搜索树来说,是否允许插入相等的值取决于具体的实现。有些实现允许,有些则不允许。
现在,让我们来看看一些常见的容器,它们底层就是用了二叉搜索树来实现:
map和set:这两个容器不允许插入相等的值。也就是说,如果你试图在map中插入一个已经存在的键值对,或者在set中插入一个已经存在的值,那么插入操作会失败或者覆盖原来的值(取决于具体的实现)。
multimap和multiset:这两个容器则允许插入相等的值。也就是说,你可以在同一个multimap中有多个相同的键,也可以在同一个multiset中有多个相同的值。
比如下面这棵树就是二叉搜索树:
同时,我们也可以发现二叉搜索树中序遍历的结果是有序的,这里是升序~这也就是二叉搜索树的奇妙之处~
二叉搜索树的效率问题
在最优情况下,二叉搜索树呈现为完全二叉树(或接近完全二叉树)的形态,此时其高度为 log₂N,时间复杂度也就是二叉树的高度log₂N,这使得搜索、插入和删除操作都能达到较高的效率。
但是,在最差情况下,二叉搜索树可能会退化为单支树(或类似单支的形态),此时其高度达到 N,导致这些操作的时间复杂度激增到 O(N)
因此,综合来看,二叉搜索树在增删查改方面的平均时间复杂度可能并不理想。为了满足我们对高效数据存储和搜索的需求,我们需要进一步探索二叉搜索树的变形,如平衡二叉搜索树中的 AVL 树和红黑树,这些平衡树能够在保持 O(log₂N) 级别查找效率的同时,有效避免树的高度过高(在后面的博客中,我们会进行讲解)
另外值得一提的是,我们以前说到的二分查找算法虽然也能实现 O(log₂N) 级别的查找效率,但它存在两大局限性:首先,二分查找需要数据存储在支持下标随机访问的结构中,并且数据必须是有序的;其次,由于数据存储在固定位置,插入和删除操作通常需要移动大量数据,导致效率极低。
这就凸显了平衡二叉搜索树的价值所在。它们不仅保持了二分查找的高效查找效率,还通过动态调整树的结构来确保插入和删除操作的高效性,因此,平衡二叉搜索树在实际应用中具有更广泛的适用性~
如何定义一个二叉搜索树?
知道了二叉搜索树的概念以及它的效率问题,接下来我们来看看一个怎么定义一个二叉搜索树呢?
结合前面数据结构的经验,定义一个二叉搜索树我们首先需要定义它的结点,一个个结点才可以组成我们想要的二叉搜索树~
根据二叉搜索树的概念,我们可以知道二叉搜索树的结点应该保存三个数据:
1、当前结点保存的数据
2、当前结点的左子树地址
3、当前结点的右子树地址
知道了这样一个概念,我们定义一个二叉搜索树的结点就手到擒来了~
template<class T>
struct BSTreeNode//定义一个树结点
{
T _key;
BSTreeNode<T>* _left;
BSTreeNode<T>* _right;
}
这里我们还使用了模板参数定义一个泛型模板结构体BSTreeNode
,专为构建二叉搜索树(BST)的节点而设计。通过模板参数T
,该结构体能够灵活地存储各种数据类型。每个BSTreeNode
节点包含一个键值(_key
),其类型为T
,以及两个指针(_left
和_right
),分别指向其左子节点和右子节点。
通过前面C++的学习,我们知道struct关键字也是在定义一个类,所以我们这里也可以写一个我们想要的构造函数~
template<class T>
struct BSTreeNode//定义一个树结点
{
T _key;
BSTreeNode<T>* _left;
BSTreeNode<T>* _right;
BSTreeNode(const T& key)//构造
:_key(key),
_left(nullptr),
_right(nullptr)
{
}
}
这样一个完整的结点定义就完成了~使用一个个的结点将他们联系起来,我们就可以实现一个二叉搜索树~
我们这里还需要给一个二叉搜索树的操作实现,私有成员为我们的根节点~接下来的结点就可以一个个链接起来了~
//二叉搜索树的实现
template<class T>
class BSTree
{
typedef BSTreeNode<T> Node;
private:
//根节点
Node* _root = nullptr;
public:
};
二叉搜索树的插入
对于二叉搜索树的插入,我们需要进行一下情况讨论~
插入过程
- 树为空的情况:
- 如果树是空的(即没有节点),那么直接创建一个新的节点,并将其赋值给
root
指针。此时,这个新节点就是整棵树的根节点。
- 如果树是空的(即没有节点),那么直接创建一个新的节点,并将其赋值给
- 树不空的情况:
- 如果树不为空,那么根据二叉搜索树的性质,从根节点开始比较要插入的值与当前节点的值:
- 如果插入的值比当前节点的值大,那么应该向右子树移动(即递归地检查当前节点的右子节点)。
- 如果插入的值比当前节点的值小,那么应该向左子树移动(即递归地检查当前节点的左子节点)。
- 重复这个过程,直到找到一个空位置(即没有左子节点也没有右子节点的节点位置),然后将新节点插入到这个空位置上。
- 如果树不为空,那么根据二叉搜索树的性质,从根节点开始比较要插入的值与当前节点的值:
- 支持插入相等值的情况:
- 如果二叉搜索树的设计允许插入值相等的节点,那么当插入的值与当前节点的值相等时,可以选择向右子树移动或向左子树移动来寻找空位置插入新节点。
- 重要的是,无论选择向左还是向右移动,要保持逻辑一致性。也就是说,对于相等的值,不应该有时选择向右移动,有时又选择向左移动。这通常通过设计算法时明确指定策略来实现,比如总是选择向右移动或者总是选择向左移动。
代码实现
我们这里实现一个不支持插入相等数据的二叉搜索树~
//二叉搜索树的实现
template<class T>
class BSTree
{
typedef BSTreeNode<T> Node;
private:
//根节点
Node* _root = nullptr;
public:
bool insert(const T& key)
{
//判断数是否为空
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
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 已经走到为空
cur = new Node(key);
//与自己的父节点进行比较
if (key > parent->_key)
{
parent->_right = cur;
}
else if (key < parent->key)
{
parent->_left = cur;
}
return true;
}
};
测试
接下来,对我们上面的代码进行测试
通过测试我们也就可以看到,完成了二叉搜索树的插入操作~
二叉搜索树的中序遍历打印
前面我们提到了二叉搜索树中序遍历结果是有序的,这里我们就写一段代码进行中序遍历二叉搜索树,看看是不是我们想要的效果~这里我们使用递归打印来进行实现~
void _InPrint(Node* root)
{
if (root == nullptr)
{
return;
}
_InPrint(root->_left);
cout << root->_key << " ";
_InPrint(root->_right);
}
这里需要一个参数root,我们知道根节点是我们的私有成员,我们也不希望外部访问我们的根节点,所以我们这里还需要使用一个函数进行包装一下~
void InPrint()
{
_InPrint(_root);
cout << endl;
}
void _InPrint(Node* root)
{
if (root == nullptr)
{
return;
}
_InPrint(root->_left);
cout << root->_key << " ";
_InPrint(root->_right);
}
果不其然,结果就是我们以前推测的结果~这也就进一步验证二叉搜索树中序遍历结果是有序的~
二叉搜索树的查找
我们应该怎么进行查找呢?别急,我们一起来看看~
查找过程
- 从根开始比较:
- 查找操作从树的根节点开始,将待查找的值
x
与根节点的值进行比较。
- 查找操作从树的根节点开始,将待查找的值
- 根据比较结果决定搜索方向:
- 如果
x
大于根节点的值,则继续在根节点的右子树中查找x
。 - 如果
x
小于根节点的值,则继续在根节点的左子树中查找x
。 - 这个过程会递归地进行,直到找到值等于
x
的节点,或者到达一个空节点(意味着树中没有值等于x
的节点)。
- 如果
- 查找次数与树的高度有关:
- 在最坏情况下,查找操作可能需要遍历树的高度次数的节点(从根到叶节点的最长路径)。
- 如果遍历到空节点还没有找到
x
,则说明树中不存在值等于x
的节点。
- 处理相等值的情况:
- 如果二叉搜索树不支持插入相等的值,那么一旦找到值等于
x
的节点,就可以立即返回该节点。 - 如果二叉搜索树支持插入相等的值,那么树中可能有多个值等于
x
的节点。在这种情况下,通常要求查找中序遍历中的第一个(或根据具体需求可能是最后一个)值等于x
的节点。(中序遍历会按照升序访问树中的所有节点,因此,在中序遍历中遇到的第一个值等于x
的节点将是所有值等于x
的节点中最左边的一个)
- 如果二叉搜索树不支持插入相等的值,那么一旦找到值等于
代码实现
Node* Find(const T& x)
{
if (_root == nullptr)
{
return nullptr;
}
Node* cur = _root;
//遍历查找
while (cur)
{
if (x < cur->_key)
//如果比当前节点数小,说明在当前节点的左子树
{
cur = cur->_left;
}
else if (x > cur->_key)
//如果比当前节点数大,说明在当前节点的右子树
{
cur = cur->_right;
}
else
//如果相等就找到了
{
return cur;
}
}
//走到空也没有找到
return nullptr;
}
测试
二叉搜索树的删除
删除过程
- 查找元素:
- 首先,在二叉搜索树中查找要删除的元素。
- 如果元素不存在于树中,则返回
false
,表示删除操作失败。
- 元素存在时的处理:
- 如果元素存在于树中,则根据要删除的节点N的子节点情况,采取不同的删除策略。
- 四种子节点情况:
- 情况1:节点N没有左子节点也没有右子节点(叶子节点)。
- 情况2:节点N有右子节点但没有左子节点。
- 情况3:节点N有左子节点但没有右子节点。
- 情况4:节点N既有左子节点也有右子节点。
- 对应的解决方案:
- 情况1:可以直接删除节点N,将其父节点的对应子节点指针设为
null
。这种情况也可以视为情况2或情况3的特例,效果相同。 - 情况2:将节点N的父节点的对应子节点指针指向N的右子节点,然后删除N。
- 情况3:将节点N的父节点的对应子节点指针指向N的左子节点,然后删除N。
- 情况4:不能直接删除节点N,因为N的两个子节点无法直接安置。此时,需要找到N的左子树中的最大值节点R(即最右节点)或N的右子树中的最小值节点R(即最左节点),用R的值替换N的值,然后将问题转化为删除R节点(此时R节点符合情况2或情况3),可以直接删除。
- 情况1:可以直接删除节点N,将其父节点的对应子节点指针设为
- 替换法的详细解释:
- 在情况4中,由于直接删除N会破坏二叉搜索树的性质,因此需要找到N的一个“替代者”。
- 这个替代者可以是N左子树中的最大值节点(即左子树中最右边的节点),也可以是N右子树中的最小值节点(即右子树中最左边的节点)。
- 这两个节点中的任何一个,放到N的位置,都能保持二叉搜索树的性质不变。
- 替换操作实际上是交换N和R的值,然后删除R节点(此时R节点变成了叶子节点、只有右子节点或只有左子节点的情况,可以直接删除)。
通过这个过程,可以在保持二叉搜索树性质的前提下,有效地删除树中的任意节点~我们这里需要特别注意的是情况4使用替换方法这一种巧妙方式~
代码实现
代码1:
bool erase(const T& key)
{
//处理根节点为空的情况
if (_root == nullptr)
{
exit(1);
}
//遍历查找元素
Node* cur = _root;
Node* parent = _root;
while (cur)
{
if (key < cur->_key)
{
parent = cur;
cur = cur->_left;
}
else if (key > cur->_key)
{
parent = cur;
cur = cur->_right;
}
else
{
break;
}
}
//如果找到空,说明没有相应结点
if (cur == nullptr)
return false;
//分情况处理
//1、节点cur没有左子节点也没有右子节点
if (cur->_left == nullptr && cur->_right == nullptr)
{
//相应的父节点的子节点置为空
if (cur->_key > parent->_key)
{
parent->_right = nullptr;
}
else if (cur->_key < parent->_key)
{
parent->_left = nullptr;
}
delete cur;
return true;
}
//2、节点cur有右子节点但没有左子节点
else if (cur->_left == nullptr)
{
if (cur == _root)
{
_root = cur->_right;
}
if (cur->_key > parent->_key)
{
parent->_right = cur->_right;
}
else if (cur->_key < parent->_key)
{
parent->_left = cur->_right;
}
delete cur;
return true;
}
//3、节点cur没有右子节点但有左子节点
else if (cur->_right == nullptr)
{
if (cur == _root)
{
_root = cur->_left;
}
if (cur->_key > parent->_key)
{
parent->_right = cur->_left;
}
else if (cur->_key < parent->_key)
{
parent->_left = cur->_left;
}
delete cur;
return true;
}
//4、节点cur既有左子节点也有右子节点
//替换法
else
{
//找左子树的最大结点——往左子树右边找
Node* Lmax = cur->_left;
Node* replaceParent = cur;
while (Lmax->_right)
{
replaceParent = Lmax;
Lmax = Lmax->_right;
}
swap(cur->_key, Lmax -> _key);//交换数据
if (replaceParent->_left == Lmax)
replaceParent->_left = Lmax->_left;
else
replaceParent->_right = Lmax->_left;
delete Lmax;//Lmax与cur进行了交换
return true;
}
}
事实上,情况一已经包含在了情况二和情况三中,我们也可以不写情况一的代码~
我们也可以在while循环里面进行操作~我们这里就把代码进行优化~
代码2:
bool erase(const T& key)
{
//处理根节点就为空的情况
if (_root == nullptr)
{
return false;
}
//遍历查找元素
Node* cur = _root;
Node* parent = _root;
while (cur)
{
if (key < cur->_key)
{
parent = cur;
cur = cur->_left;
}
else if (key > cur->_key)
{
parent = cur;
cur = cur->_right;
}
else
{
//while循环里面进行删除
//1、节点cur有右子节点但没有左子节点
if (cur->_left == nullptr)
{
if (cur == _root)
{
_root = cur->_right;
}
else if (cur == parent->_left)
{
parent->_left = cur->_right;
}
else if (cur == parent->_right)
{
parent->_right = cur->_right;
}
delete cur;
}
//2、节点cur有左子节点但没有右子节点
else if (cur->_right == nullptr)
{
if (cur == _root)
{
_root = cur->_left;
}
else if (cur == parent->_left)
{
parent->_left = cur->_left;
}
else if (cur == parent->_right)
{
parent->_right = cur->_left;
}
delete cur;
}
//3.节点cur有左右子节点
else
{
//这里我们找找右边节点的最小值进行替换
Node* replaceParent = cur;
Node* Rmin = cur->_right;//找右边节点的最小值
while (Rmin->_left)
{
replaceParent = Rmin;
Rmin = Rmin->_left;
}
swap(Rmin->_key, cur->_key);
if (replaceParent->_left == Rmin)//Rmin结点就是最小的,所以当前结点肯定没有左结点
//需要处理Rmin节点的右结点
{
replaceParent->_left = Rmin->_right;
}
else if (replaceParent->_right == Rmin)
{
replaceParent->_right = Rmin->_right;
}
delete Rmin;
}
return true;
}
}
//如果找到空都没有找到进行返回,说明没有相应结点
return false;
}
测试
代码1测试:
代码2测试:
两段代码都达到了我们想要的效果,我们可以根据自己习惯来进行选择~
二叉搜索树的析构
二叉搜索树涉及到了内存分配,所以我们最好自己显示写一份析构函数~
//析构函数
~BSTree()
{
Destory(_root);
_root = nullptr;
}
void Destory(Node* root)
{
if (root == nullptr)
{
return;
}
else
{
//递归释放
Destory(root->_left);
Destory(root->_right);
delete root;
}
}
♥♥♥本篇博客内容结束,期待与各位优秀程序员交流,有什么问题请私信♥♥♥
♥♥♥如果这一篇博客对你有帮助~别忘了点赞分享哦~♥♥♥
✨✨✨✨✨✨个人主页✨✨✨✨✨✨