目录
前言
二叉搜索树又叫二叉排序树,是二叉树的一种特殊情况。树的每个节点的值都满足一定的规律,同样是以结构体为节点创造树。
二叉搜索树结构
节点构造
template<class K> //模板参数K
struct BSTreeNode
{
BSTreeNode(const K& key=K())//构造函数
:_left(nullptr)
,_right(nullptr)
,_key(key)
{}
BSTreeNode<K>* _left; //左子树指针
BSTreeNode<K>* _right; //右子树指针
K _key; //节点的值
};
二叉搜索树基本构造
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node; //简化
public:
BSTree() //默认构造函数,设置树为空树
:_root(nullptr)
{}
private:
Node* _root; //树的根节点(唯一的成员变量)
};
此时的我们创建树的话就是一个空二叉搜索树了。
拷贝构造函数
默认的拷贝构造函数是浅拷贝,这里一旦析构之后就会出问题,因此我们得写一个深度拷贝构造函数。
数据结构为树状,这里我们考虑使用递归构造。
BSTree(const BSTree<K>& t)
{
_root = CopyTree(t._root);
}
Node* CopyTree(const Node* root)
{
if (root == nullptr)//递归结束标志
{
return nullptr;
}
Node* copynode = new Node(root->_key); //真正实现空间开辟的地方
copynode->_left = CopyTree(root->_left); //递归左节点
copynode->_right = CopyTree(root->_right);//递归右节点
return copynode; //完成之后返回节点
}
析构函数
与拷贝构造函数相同,使用递归的方式析构。
~BSTree()
{
DestoryTree(_root);
_root = nullptr;
}
void DestoryTree(const Node* root)
{
if (root == nullptr) //递归结束标志
{
return;
}
DestoryTree(root->_left); //递归
DestoryTree(root->_right);//递归
delete root; //空间释放
}
= operator重载
🔺复用拷贝构造函数
🔺在拷贝之前,要先对原先的树进行空间释放,否则容易造成内存泄漏的问题。
BSTree<K>& operator=(BSTree<K> t)//这里的t是等式右值的深度拷贝,复用了拷贝构造函数
{
DestoryTree(_root); //先清理原空间
swap(_root, t._root); //交换指针,空间和内容也就交换了
return *this;
}
二叉搜索树的特点
1.若左子树不为空,则左子树上所有节点的值都小于根节点的值。
2.若右子树不为空,则右子树上所有节点的值都大于根节点的值。
3.左右子树也分别为二叉搜索树。
特点总结
1.对于根节点有:“左小右大”。
2.对于左右子树节点有:“左小右大”。
图例
二叉搜索树的操作
我们在知晓二叉搜索树的结构和属性之后,只有一个空树是没有意义的,因此对于树的增删查改就提上进程了。接下来我们一个个地探索,并对一些功能实现递归与非递归层面的操作。
二叉搜索树的中序遍历
根据“左小右大”的原则,只要是一个标准的二叉搜索树,中序遍历的结果一定是从小到大的排列顺序。实现方式的话,这里采用递归的方法实现。
代码:
//内嵌_InOrder函数是为了传_root指针来实现递归。
void InOrder()
{
_InOrder(_root);
cout << endl;
}
void _InOrder(const Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
测试:
我们暂时先手动生成一个二叉搜索树
void test()
{
_root = new Node(8);
_root->_left = new Node(6);
_root->_right = new Node(10);
_root->_left->_left = new Node(5);
_root->_left->_right = new Node(7);
_root->_right->_left = new Node(9);
_root->_right->_right = new Node(11);
InOrder();
}
int main()
{
BSTree<int> t;
t.test();
return 0;
}
运行结果 :
可以看出确实是一个升序输出。
图解 :
图画得有点长,这也是递归的缺点之一了,毕竟每次递归都要创建函数栈帧,树大的话容易爆栈
二叉搜索树的查找
非递归查找:Find
当前节点:cur(初始值为_root)
查找值:key
情况1:
查找值 key 比当前节点cur的_key小,说明要找的节点在此节点的左树中,因此cur重新赋值为cur的左子树节点,cur = cur->_left,继续查找。
情况2:
查找值 key 比当前节点cur的_key大,说明要找的节点在此节点的右树中,因此cur重新赋值为cur的右子树节点,cur = cur->_right,继续查找。
情况3:(找到时机)
查找值 key 等于当前节点cur的_key,说明找到了,返回ture。
情况4:
cur为nullptr,说明按照大小规则树中的所有可能节点已经被遍历过了,此时没找到就找不到了,返回false。
代码:
bool Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (key < cur->_key) //情况1
{
cur = cur->_left;
}
else if (key > cur->_key) //情况2
{
cur = cur->_right;
}
else //情况3
{
return true;
}
}
return false; //情况4
}
测试:
void test()
{
_root = new Node(8);
_root->_left = new Node(6);
_root->_right = new Node(10);
_root->_left->_left = new Node(5);
_root->_left->_right = new Node(7);
_root->_right->_left = new Node(9);
_root->_right->_right = new Node(11);
if (Find(9))
{
cout << "Find 9!" << endl;
}
if (Find(20))
{
cout << "Find 20!" << endl;
}
}
int main()
{
BSTree<int> t;
t.test();
return 0;
}
运行结果:
图解:
递归查找:FindR
当前递归节点:root(初始值为_root)
查找值:key
情况1:
查找值 key 比当前节点root的_key大,说明要找的节点在此节点的右树中,因此调用递归函数去右树中找,指针传参传root->_right。
情况2:
查找值 key 比当前节点root的_key小,说明要找的节点在此节点的左树中,因此调用递归函数去左树中找,指针传参传root->_left。
情况3:(找到时机)
查找值 key 等于当前递归节点root的_key,说明找到了,返回ture。递归结束。
情况4:
root为nullptr,说明按照大小规则树中的所有可能节点已经被遍历过了,此时没找到就找不到了,返回false。递归结束。
代码:
//内嵌_FindR函数,满足递归传参的条件(传_root指针)
bool FindR(const K& key)
{
return _FindR(_root, key);
}
bool _FindR(const Node* root, const K& key)
{
if (root == nullptr) //情况4
{
return false;
}
if (root->_key > key) //情况1
{
return _FindR(root->_left, key);
}
else if (root->_key < key) //情况2
{
return _FindR(root->_right, key);
}
else //情况3
{
return true;
}
}
测试:
void test()
{
_root = new Node(8);
_root->_left = new Node(6);
_root->_right = new Node(10);
_root->_left->_left = new Node(5);
_root->_left->_right = new Node(7);
_root->_right->_left = new Node(9);
_root->_right->_right = new Node(11);
if (FindR(9))
{
cout << "Find 9!" << endl;
}
if (FindR(20))
{
cout << "Find 20!" << endl;
}
}
int main()
{
BSTree<int> t;
t.test();
return 0;
}
运行结果:
图解:
二叉搜索树的插入
这里的插入就是在符合“左小右大”的规则的基础上创建一个新的节点,并在原树上连接起来。
注:因为要严格满足“左小右大”的规则,因此树里面是不可能出现两个或两个以上的节点值相同的情况出现的。因此如果插入的值与树中的值相同,插入操作视为失败。(就当是插入过了,覆盖之后值不变,这样理解也行。)
非递归插入:Insert
当前节点:cur(初始值为_root)
当前节点的父节点:parent(初始值为nullptr)
插入值:key
🔺记录父节点是为了等会儿插入新节点,避免出现无法找到插入位置的上一个节点进行链接。
情况1:
树为空树,直接new一个节点,并把地址赋值给_root节点。
情况2:
插入的值 key 大于当前节点 cur 的 _key,这时就更新父节点为cur,并更新 cur 为 cur 的右子树节点(cur = cur->_right ;),继续寻找合适的插入位置。这样做的原因是要满足“左小右大”规则。
情况3:
插入的值 key 小于当前节点 cur 的 _key,这时就更新父节点为cur,并更新 cur 为 cur 的左子树节点(cur = cur->_left),继续寻找合适的插入位置。这样做的原因是要满足“左小右大”规则。
情况4:
插入的值 key 等于当前节点 cur 的 _key,此时说明重复插入值,插入操作视为失败。
插入时机的出现:
随着cur的更新,会在树的结构里层层往下走,走到cur为空的时候,这时说明一路下来没有遇到重复值,key 和其他节点值_key相比不是大就是小,按照规则走到空说明虽然节点插入的位置靠后,但终于有位置可以插入了。
这里还要注意虽然找到插入的位置了,但是也得判断一下到底是上一节点的左节点还是右节点,然后再进行链接。
代码:
bool Insert(const K& key)
{
if (_root == nullptr) //情况1
{
_root = new Node(key);
return true;
}
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (key > cur->_key) //情况2
{
parent = cur;
cur = cur->_right;
}
else if (key < cur->_key) //情况3
{
parent = cur;
cur = cur->_left;
}
else //情况4
{
return false;
}
}
if (key > parent->_key) //插入情况:判断是左插入还是右插入
{
parent->_right = new Node(key);
}
else
{
parent->_left = new Node(key);
}
return true;
}
图解:
递归插入:InsertR
与非递归的思想类似,我们知道递归的思想一般都是问题层层分解,把大问题化成多个小问题,然后逐一解决。这里我们参照非递归的思路来处理同样是可以的。
当前递归函数的根节点:root(初始值为_root)
插入值:key
🔺此时的root指针传参时要引用传参(此时的root就是上一个root指针的左或右节点的别名),保证在递归的过程中发现插入位置时,对root进行赋值的同时会改变上一个root指针的左或右节点。听起来是不是有点绕?没关系,我们接下来会画图进行说明,接下来先分析各种情况再说。
情况1:(插入时机的出现)
root 为空,说明找到了插入位置,直接开辟新节点并赋值给root。(递归结束)
情况2:
key 大于 root->_key,说明插入的位置在此时根节点的右树里,这时右树节点看作新的根节点插入key,因此调用递归函数,此时根节点指针传root->_right的引用,插入值还传key。
情况3:
key 小于 root->_key,说明插入的位置在此时根节点的左树里,这时左树节点看作新的根节点插入key,因此调用递归函数,此时根节点指针传root->_left的引用,插入值还传key。
情况4:
key 等于当前节点 root 的 _key,此时说明重复插入值,插入操作视为失败。
代码:
//内嵌一个递归函数
//原因:递归函数要传指针,而插入函数只传key值。
bool InsertR(const K& key)
{
return _InsertR(_root, key);
}
bool _InsertR(Node*& root, const K& key)//划重点:Node*& root中的&!!
{
if (root == nullptr) //情况1
{
root = new Node(key);
return true;
}
if (key > root->_key) //情况2
{
return _InsertR(root->_right, key);
}
else if (key < root->_key) //情况3
{
return _InsertR(root->_left, key);
}
else //情况4
{
return false;
}
}
图解:
'&'使root变为实参,这个操作本质上改变了节点的地址,递归才得以实现,否则的话每次递归还要传父节点。
二叉搜索树的删除
遍历树,找到要删除的节点,之后删除并跳过该节点,在符合“左小右大”的规则的基础之上把上下节点重新连接起来。
非递归删除:Erase
当前节点:cur(初始值为_root)
当前节点的父节点:parent(初始值为nullptr)
删除值:key
情况1:
删除的值 key 大于当前节点 cur 的 _key,这时就更新父节点为cur,并更新 cur 为 cur 的右子树节点(cur = cur->_right ;),继续寻找合适的插入位置。这样做的原因是要满足“左小右大”规则。
情况2:
删除的值 key 小于当前节点 cur 的 _key,这时就更新父节点为cur,并更新 cur 为 cur 的左子树节点(cur = cur->_left),继续寻找合适的插入位置。这样做的原因是要满足“左小右大”规则。
情况3:(删除时机的出现)
删除的值 key 等于当前节点 cur 的 _key,说明此时的cur就是要删除的节点。这时如果直接单纯的delete掉这个节点,会造成树的结构被破坏。原因在于cur节点如果是叶子节点(左右子树节点为空)还好,可以直接删除,但若cur的左子树节点或右子树节点不为空,亦或二者同时不为空,这时就要考虑cur的父节点parent与cur下面的左子树节点或右子树节点的链接问题了!因此找到之后节点之后我们也要分三种情况来讨论:🔸1:左为空、🔸2:右为空、🔸3:左右都不为空。(左右都为空的情况被包含在前两种情况中了)
🔸1:删除节点的左子树节点为空,则删除前使父节点和cur->_right链接起来。(左空链右)
◾1:删除的节点为根节点_root,此时的parent还是nullptr。处理方法就是_root=_root->_right,直接更新_root节点。
◾2:删除的节点为非根节点,如果删除的节点cur是父节点parent的右子树节点,那么删除cur之前使parent->_right = cur->_right。如果删除的节点cur是父节点parent的左子树节点,那么删除cur之前使parent->_left = cur->_right。
◾3:delete cur,完成最后的空间释放。
🔸2:删除节点的右子树节点为空,则删除前使父节点和cur->_left链接起来。(右空链左)
◾1:删除的节点为根节点_root,此时的parent还是nullptr。处理方法就是_root=_root->_left,直接更新_root节点。
◾2:删除的节点为非根节点,如果删除的节点cur是父节点parent的右子树节点,那么删除cur之前使parent->_right = cur->_left。如果删除的节点cur是父节点parent的左子树节点,那么删除cur之前使parent->_left = cur->_left。
◾3:delete cur,完成最后的空间释放。
🔸3:删除节点的左右子树节点都为非空
这里提供一种非常巧妙的想法:既然左右子树的节点都不为空,那么我可以在左子树中找最大的值max,max会比左子树的所有值大,比右子树的所有值小,这不刚好满足“左小右大”的规则嘛!同时,我们也可以在右子树中找最小的值min,min会比左子树的所有值大,比右子树的所有值小,也刚好满足“左小右大”的规则!因此只需要交换要删除节点与右树最小节点或左树最大节点的值,并删除右树最小节点或左树最大节点就行了。此法名为“替换法”。
这里我们采用"右树找小"的思路来解析,“左树找大”的思路与其相差不大。
当前最小值节点:minright(初始值为cur->_right,“右树找小”)
当前最小值节点的父节点:minparent(初始值为cur)
◾1:在右树中层层遍历,使minparent = minright; minright = minright->_left;根据“左小右大”的规则,minright的值会越来越小。一直找到minright->_left为空(🔺重点),此时的minright的值就是右树最小值了。
◾2:交换原本要删除节点cur与现在要删除的右树最小节点minright的值。
◾3:如果现在要删除的节点minright是父节点minparent的右子树节点,那么删除minright之前使minparent->_right =minright->_right。如果删除的节点minright是父节点minparent的左子树节点,那么删除minright之前使minparent->_left =minright->_right。
◾4:delete minright,完成最后的空间释放。
情况4:
cur一直更新,最后变为nullptr,此时说明没有找到对应key的节点,删除操作失败。
代码:
bool Erase(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (key > cur->_key) //情况1
{
parent = cur;
cur = cur->_right;
}
else if (key < cur->_key) //情况2
{
parent = cur;
cur = cur->_left;
}
else //情况3
{
if (cur->_left == nullptr) //🔸1
{
if (cur == _root)
{
_root = _root->_right;
}
else
{
if (parent->_right == cur)
{
parent->_right = cur->_right;
}
else
{
parent->_left = cur->_right;
}
}
delete cur;
}
else if (cur->_right == nullptr) //🔸2
{
if (cur == _root)
{
_root = _root->_left;
}
else
{
if (parent->_right == cur)
{
parent->_right = cur->_left;
}
else
{
parent->_left = cur->_left;
}
}
delete cur;
}
else //🔸3
{
Node* minparent = cur;
Node* minright = cur->_right;
while (minright->_left)
{
minparent = minright;
minright = minright->_left;
}
swap(cur->_key, minright->_key);
if (minparent->_left == minright)
{
minparent->_left = minright->_right;
}
else
{
minparent->_right = minright->_right;
}
delete minright;
}
return true;
}
}
return false; //情况4:删除失败
}
图解:
上面我们已经给过除了🔸3情况的图解了,因此这里我直接只画出🔸3图解。
递归删除:EraseR
和非递归的讨论情况一样,实现时把问题分解化,以实现递归的操作。
当前递归函数的根节点:root(初始值为_root)
删除值:key
🔺此时的root指针传参时要引用传参。(原因在递归插入时已经解释过了,这里就不过多赘述了)
情况1:
key 大于 root->_key,说明删除的位置在此时根节点的右树里,这时右树节点看作新的根节点删除key,因此调用递归函数,此时根节点指针传root->_right的引用,删除值还传key。
情况2:
key 小于 root->_key,说明删除的位置在此时根节点的左树里,这时左树节点看作新的根节点删除key,因此调用递归函数,此时根节点指针传root->_left的引用,删除值还传key。
情况3:(删除时机的出现)
删除的值 key 等于当前节点 cur 的 _key,说明此时的cur就是要删除的节点。找到之后节点之后我们要分三种情况来讨论:🔸1:左为空、🔸2:右为空、🔸3:左右都不为空。(左右都为空的情况被包含在前两种情况中了)
🔺记录当前root节点:Node* del = root; (防止 root 改变后找不到删除节点的地址)
🔸1:删除节点的左子树节点为空,我们令 root = root->_right;
这样就好了吗?答案是的!妙就妙在root是上一次递归函数的root->_right或root->_left的别名,因此我根本不关心当前节点root的父节点是谁,也不关心当前节点root是其父节点的左节点或者是右节点来进行链接,我直接赋值就行,别名传参把上述我们应该关心的问题直接解决!最后delete del,释放空间。(递归结束)
🔸2:删除节点的右子树节点为空,我们令 root = root->_left;最后delete del,释放空间。(递归结束)
🔸3:删除节点的左右子树节点都为非空
同样采取"替换法"的“右树找小”来解决。
右子树最小值节点:minright(初始值为当前节点root->_right)
◾1:在右树中层层遍历,使 minright = minright->_left;根据“左小右大”的规则,minright的值会越来越小。一直找到minright->_left为空(🔺重点),此时的minright的值就是右树最小值了。
◾2:交换原本要删除节点root与现在要删除的右树最小节点minright的值。
◾3:(🔺)再次调用递归函数。这里可能又有人迷惑了,不知道此时调用递归函数的意义在哪?事实上,我们在完成交换值的操作后,我们就可以认为把要删除的节点挪动到了此时根节点root的右树的最左侧了(我们刚开始一直在右树里找最左节点),因此我们此时又可以这样认为:要删除的节点在右树里,且符合🔸1(毕竟是最左节点,左为空是必然的),因此再次调用递归函数时就会进入🔸1,然后完成相应操作,结束递归!
🔺可以看出: 🔸3这种情况最终会变成🔸1(右树找小)或🔸2(左树找大)。
情况4:
root 为空,说明找不到删除位置,删除失败。(递归结束)
代码:
//内嵌_EraseR函数,满足递归传参的条件(传_root指针)
bool EraseR(const K& key)
{
return _EraseR(_root, key);
}
bool _EraseR(Node*& root, const K& key)
{
if (root == nullptr) //情况4:删除失败
{
return false;
}
if (key > root->_key) //情况1
{
return _EraseR(root->_right, key);
}
else if (key < root->_key) //情况2
{
return _EraseR(root->_left, key);
}
else //情况3
{
Node* del = root;
if (root->_left == nullptr) //🔸1
{
root = root->_right;
delete del;
}
else if (root->_right == nullptr) //🔸2
{
root = root->_left;
delete del;
}
else //🔸3
{
Node* minright = root->_right;
while (minright->_left)
{
minright = minright->_left;
}
swap(root->_key, minright->_key);
return _EraseR(root->_right, key);
}
return true; //删除成功
}
}
图解:
二叉搜索树的应用
我们上面写的代码都是每个节点只有一个值:key,这种二叉搜索树被称为K模型。
而实际上二叉搜索树广泛应用的模型是KV(key & value)模型,即每个节点都存有key值和对应的value值。key值的作用是用来构成二叉搜索树或完成对树结构的更改,这与我们上面所讲的一样。而value值更多的像是节点的附带信息,节点之间key值的关系同时也映射到了value值上,与value值本身无关。因此,对于value值,我们可以依照key值之间的关系进行各种操作。
K模型:
词库检索<key>(树中存储词库,通过key值搜索树)
...
KV模型:
中英字词的对照<English, Chinese> (key:English, value:Chinese)
统计key值出现的次数<key, count> (value:count)
...
K模型
以字母词库检索为例
测试代码:
//词库为26小写英文字母
void test_BSTree()
{
BSTree<char> t;
//创建词库
for (char ch = 'a'; ch <= 'z'; ++ch)
{
t.Insert(ch);
}
//删除部分字母
t.Erase('f');
t.Erase('k');
t.Erase('v');
t.Erase('l');
t.Erase('p');
//测试词库缺失情况
for (char ch = 'a'; ch <= 'z'; ++ch)
{
if (t.Find(ch))
{
cout << ch << " ";
}
else
{
cout << '*' << " ";//以*代替缺失字符
}
}
cout << endl;
}
运行结果:
KV模型
以key值出现次数为例
这里我们要对结构体节点的成员变量、插入、中序遍历函数进行value参数的增加。
🔺要想实现对value值的更改,就要使查找函数返回节点指针(Node*)。这里我们就不再重复展示查找函数代码了。
结构体:
template<class K, class V>
struct BSTreeNode
{
BSTreeNode<K, V>* _left;
BSTreeNode<K, V>* _right;
K _key;
V _value; //增加成员变量
BSTreeNode(const K& key = K(), const V& value = V()) //同时构造函数的参数增加value
:_left(nullptr)
, _right(nullptr)
, _key(key)
, _value(value)
{}
};
非递归插入函数:Insert
bool Insert(const K& key, const V& value)
{
if (_root == nullptr)
{
_root = new Node(key, value);//创建新节点传参:key、value
return true;
}
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (key > cur->_key)
{
parent = cur;
cur = cur->_right;
}
else if (key < cur->_key)
{
parent = cur;
cur = cur->_left;
}
else
{
return false;
}
}
if (key > parent->_key)
{
parent->_right = new Node(key);
}
else
{
parent->_left = new Node(key);
}
return true;
}
递归插入函数:InsertR
//参数:key、value
bool InsertR(const K& key, const V& value)
{
return _InsertR(_root, key, value);
}
bool _InsertR(Node*& root, const K& key, const V& value)
{
if (root == nullptr)
{
root = new Node(key, value); //
return true;
}
if (key > root->_key)
{
return _InsertR(root->_right, key);
}
else if (key < root->_key)
{
return _InsertR(root->_left, key);
}
else
{
return false;
}
}
中序遍历:
void InOrder()
{
_InOrder(_root);
cout << endl;
}
void _InOrder(const Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << ':' << root->_value << " "; //输出: _key:_value
_InOrder(root->_right);
}
测试代码:
void TestBSTree()
{
//统计各色球出现的次数
string arr[] = { "红球","白球","黑球","白球","黑球" ,"白球","白球" ,"白球","红球" };
BSTree<string, int> countTree;
for (const auto& str : arr)
{
auto ret = countTree.FindR(str);
if (ret == nullptr)
{
countTree.InsertR(str, 1);//没出现过就插入树中
}
else
{
ret->_value++; // 出现过没法插入,就修改value值
}
}
countTree.InOrder();
}
运行结果:
二叉搜索树的性能
设树有N个节点。
最好情况
最好的情况就是一棵完全二叉树或接近完全二叉树的二叉搜索树。时间复杂度为O(logN)。
最坏情况
二叉搜索树退化为单支树(或者类似单支),时间复杂度为O(N)。
可见二叉搜索树的性能并不稳定,因此后续我们还需要对其进行修改完善(以后再聊😋)。
总结
呼~,总算是把二叉搜索树的主要内容讲完了,重点还在于二叉搜索树的操作那一部分,对于删除的情况分析以及递归的实现都能使我们更加了解二叉搜索树的整体结构。关于树形数据结构的知识现阶段我还在学习中,往后会继续更新相关知识的博客。👋