目录
前言
本篇章我们讲解的是二叉搜索树,它与普通的二叉树有什么区别呢?普通的二叉树通常是用来存储数据的并没有什么特殊的功能,而今天我们要讲的二叉搜索树呢它能够帮助我们来快速查找key是不是在这颗树中,另外它也被叫做二叉排序树,因为它的中序遍历的结果为一个升序,下面我们就来一起学习它吧!!
一、二叉搜索树
1.1 二叉搜索树概念
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值。
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值。
- 它的左右子树也分别为二叉搜索树。
- 二叉搜索树的左子树所有节点的键值比根节点的小,右子树所有节点的键值比根节点的大,所以在查询一个数是不是在树中时我们的效率会非常高,相当于折半查找,如果树的高度为h,那么在一般情况下时间复杂度为O(h),但是后面我们也会讲到最坏的情况其实是达到了O(N).
- 二叉搜索树被称为二叉排序树的原因是:二叉搜索树中序遍历的结果是升序的。
- 二叉搜索树中的键值是不允许修改的,如果可以修改的话,就有可能不再是二叉搜索树了,严格的按照二叉搜索树性质去构建树!!
2.2 二叉搜索树的模拟实现
2.2.1 节点的定义
// 键值可以是多种类型的,我们把树的节点定义为模板类
template <class K>
struct BSTreeNode
{
BSTreeNode<K>* _left;
BSTreeNode<K>* _right;
K _key;
BSTreeNode(const K& key)
: _left(nullptr)
, _right(nullptr)
, _key(key)
{}
};
2.2.2 查找操作(非递归)
查找操作的逻辑很简单,具体步骤如下:从根开始比较查找,比根大则往右边走查找,比根小则往左边走查找。走到到空,还没找到,则这个值不存在。
bool Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_key > key) // key比当前结点大,就往右找
{
cur = cur->_left;
}
else if (cur->_key < key) // key比当前结点小,就往左找
{
cur = cur->_right;
}
else
{
return true;
}
}
return false;
}
2.2.3 插入操作(非递归)
插入操作与查找的逻辑一致,我们先进行查找看该结点到底插入到哪个位置,然后再链接到树中。
bool Insert(const K& key)
{
// 如果树中还没有结点的话就直接new一个节点作为根节点
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
// 这里需要一个prev指针作为cur的根节点,这是为了保证在查找插入位置后, 将插入结点与树链接起来, 如果不记录cur的根节点的话那么就找不到它的根节点也就无法链接到树中了
Node* prev = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_key < key)
{
prev = cur;
cur = cur->_right;
}
else if (cur->_key > key)
{
prev = cur;
cur = cur->_left;
}
else // 等于该结点直接返回false插入失败
{
return false;
}
}
// 此时cur走到尽头了,说明到插入的位置了,我们只需new一个节点将其链接到树中即可, 另外我们还需比较它与根节点的值即可知道它插在左还是右
cur = new Node(key);
if (prev->_key > cur->_key) // 当小于根节点时将其链接在根节点的左侧
{
prev->_left = cur;
}
else // 当大于根节点时将其链接在根节点的右侧, 因为前面已经进行了查找操作所以这里不可能出现等于根节点的情况
{
prev->_right = cur;
}
return true;
}
2.2.4删除操作(非递归)
二叉搜索树的删除稍微有些复杂,首先我们查找元素是否在二叉搜索树中,如果不存在,则返回false,否则要删除的结点可能分下面四种情
况:
- a. 要删除的结点无孩子结点
- b. 要删除的结点只有左孩子结点
- c. 要删除的结点只有右孩子结点
- d. 要删除的结点有左、右孩子结点
下面我们就来逐个对这四种情况进行分析,这里我先说明一下几个变量代表的含义,方便大家根据下图来进行理解。
cur:表示待删除的节点 prev:表示待删除结点的根节点 maxLeft:表示左子树的最大节点 pmaxLeft:表示左子树最大节点的根节点 minRight:表示右子树的最小节点 pminRight:表示右子树最小节点的根节点。
情况一:待删除结点为叶子节点(无孩子节点)
情况二:待删除结点只有右孩子(左孩子为空)
情况三:待删除结点只有左孩子(右孩子为空)
上述三种是比较简单的,直接删除结点再进行链接即可,并且我们发现情况一是可以被归类于情况二三的,我们见下图分析:
另外上述三类情况都忽略了一种特殊情况:
所以我们这里一定要做一下特殊处理,将根节点进行更新。
情况四:待删除结点左右孩子都有
那么前三种情况都比较好处理,第四种情况处理起来是有些复杂的,那么我们该如何下手呢?下面我们从删除根节点来进行入手是最容易理解这种情况的:
当删除根节点8后,那么此时二叉搜索树的根节点为谁呢?
当然必定要满足当前key大于左子树的所有节点, 并且小于所有右子树的节点!!在这颗树中7和10都是可以满足的,那么我们的解决方法就是找到7or10然后替换删除结点作为根节点,这种方法也叫做伪删除法,并不是真正的将8号节点删除了,而是替换值删除本来应该作为根节点的节点!!那么下面的问题就变成了如何找到7和10号节点?通过分析我们可以知道7为左子树中的最大节点(左子树中的最右结点),而10为右子树中的最小节点(右子树中的最左节点)。
下面我们就来分析如何找到7or10号节点(注意下面我用的浮点数的节点仅仅是为了方便我们分析):
下面给出我们分析的代码:
bool Erase(const K& key)
{
Node* prev = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_key < key)
{
prev = cur;
cur = cur->_right;
}
else if (cur->_key > key)
{
prev = cur;
cur = cur->_left;
}
else
{
// 开始删除
if (cur->_left == nullptr) // 待删除节点没有左孩子
{
if (cur == _root) // 待删除节点为根节点
{
_root = cur->_right;
}
else
{
if (cur == parent->_left) // 删除节点在父节点的左边
prev->_left = cur->_right;
else // 删除节点在父节点的右边
prev->_right = cur->_right;
}
delete cur;
}
else if (cur->_right == nullptr) // 待删除节点没有右孩子
{
if (cur == _root) // 待删除节点为根节点
{
_root = cur->_left;
}
else
{
if (cur == prev->_left) // 删除节点在父节点的左边
prev->_left = cur->_left;
else // 删除节点在父节点的右边
prev->_right = cur->_left;
}
delete cur;
}
else // 待删除节点有左右孩子(替换法删除)
{
// 方案一: 找到左子树的最右结点
Node* pmaxLeft = cur;
Node* maxLeft = cur->_left;
// 寻找最右节点
while (maxLeft->right)
{
pmaxLeft = maxLeft;
maxLeft = maxLeft->_right;
}
// 找到之后进行值交换
cur->_key = maxLeft->_key;
// 判断在根节点的左边还是右边进行链接
if (pmaxLeft->_left == maxLeft)
{
pmaxLeft->_left = maxLeft->_left;
}
else
{
pmaxLeft->_right = maxLeft->_left;
}
// 删掉该结点
delete maxLeft;
// 方案二: 找到右子树的最小节点进行替换
/*Node* pminRight = cur;
Node* minRight = cur->_right;
while (minRight->_left)
{
pminRight = minRight;
minRight = minRight->_left;
}
swap(cur->_key, minRight->_key);
if (pminRight->_left == minRight)
pminRight->_left = minRight->_right;
else
pminRight->_right = minRight->_right;
delete minRight;*/
}
return true;
}
}
return false; // 删除失败
}
2.2.5 查找操作(递归)
bool Find(Node* root, const K& key)
{
if (root == nullptr)
return false;
if (root->_key < key)
Find(root->_right, key);
else if (root->_key > key)
Find(root->_left, key);
else
return true;
}
2.2.6 插入操作(递归)
插入操作跟查找操作逻辑类似,当前节点值比key小时递归去左找,比key大时递归去右找,当递归到空时说明已经找到了插入位置,此时我们new一个节点,再将其链接即可,如何来链接呢?找到根节点对吧,我们可以再传一个节点参数,每次记录下上一次访问的节点即可,但是这里我们有一个非常秀的操作那就是给root节点取引用,那么为什么它能办到自动将结点链接起来呢?
我们每次进行递归,root都是其父亲的左孩子或孩子的别名,当递归到最后一层时,我们见下图分析:
bool Insert(Node*& root, const K& key)
{
if (root == nullptr)
{
root = new Node(key);
return true;
}
if (root->_key < key)
{
return Insert(root->_right, key);
}
else if (root->_key > key)
{
return Insert(root->_left, key);
}
else
{
return false;
}
}
2.2.7 删除操作(递归)
递归删除操作跟非递归删除操作的逻辑都是一样的,只是我们需要处理删除结点后与根节点的链接关系罢了,通过上述插入操作的参数节点取引用操作,我们知道节点之间的链接关系是一直存在的,只需要修改对应节点的值我们就能自动完成链接操作!!
bool Erase(Node* root, const K& key)
{
if (root == nullptr)
{
return false;
}
if (root->_key < key)
{
return Erase(root->_right, key);
}
else if (root->_key > key)
{
return Erase(root->_left, key);
}
else
{
Node* del = root;
if (root->_left == nullptr) // 当左孩子为空时
{
root = root->_right; // 直接将root值改为root->right, 就完成了直接删除root节点链接root右孩子的操作
}
else if (root->_right == nullptr) // 当右孩子为空时
{
root = root->_left; // 直接将root值改为root->left, 就完成了直接删除root节点链接root左孩子的操作
}
else
{
Node* min = root->_right; // 找右子树的最左(小)节点
while (min->_left)
{
min = min->_left;
}
swap(min->_key, root->_key); // 交换值
// 此时我们重新从根节点开始进行递归操作来删除该结点 why?
// 因为交换后我们的根节点变化了, 所以必须重新进行子操作查找"替罪羊"
return Erase(root->_right, key);
}
delete del;
}
}
2.3 完整代码展示
#pragma once
template <class K>
struct BSTreeNode
{
BSTreeNode<K>* _left;
BSTreeNode<K>* _right;
K _key;
BSTreeNode(const K& key)
: _left(nullptr)
, _right(nullptr)
, _key(key)
{}
};
template <class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
BSTree()
{}
BSTree(const BSTree<K>& t)
{
_root = _Copy(t._root);
}
BSTree<K>& operator=(BSTree<K> t)
{
swap(_root, t._root);
return *this;
}
~BSTree()
{
_Destory(_root);
}
bool Insert(const K& key)
{
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
Node* prev = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_key < key)
{
prev = cur;
cur = cur->_right;
}
else if (cur->_key > key)
{
prev = cur;
cur = cur->_left;
}
else
{
return false;
}
}
cur = new Node(key);
if (prev->_key > cur->_key)
{
prev->_left = cur;
}
else
{
prev->_right = cur;
}
return true;
}
bool InsertR(const K& key)
{
return _InsertR(_root, key);
}
bool Find(const K& key)
{
if (_root == nullptr)
return false;
Node* cur = _root;
while (cur)
{
if (cur->_key < key)
cur = cur->_right;
else if (cur->_key > key)
cur = cur->_left;
else
return true;
}
return false;
}
bool FindR(const K& key)
{
return _FindR(_root, key);
}
bool Erase(const K& key)
{
Node* prev = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_key < key)
{
prev = cur;
cur = cur->_right;
}
else if (cur->_key > key)
{
prev = cur;
cur = cur->_left;
}
else
{
if (cur->_left == nullptr)
{
if (cur == _root)
{
_root = cur->_right;
}
else
{
if (cur == parent->_left)
prev->_left = cur->_right;
else
prev->_right = cur->_right;
}
delete cur;
}
else if (cur->_right == nullptr)
{
if (cur == _root)
{
_root = cur->_left;
}
else
{
if (cur == prev->_left)
prev->_left = cur->_left;
else
prev->_right = cur->_left;
}
delete cur;
}
else
{
// 方案一: 找到左子树的最右结点
Node* pmaxLeft = cur;
Node* maxLeft = cur->_left;
while (maxLeft->right)
{
pmaxLeft = maxLeft;
maxLeft = maxLeft->_right;
}
cur->_key = maxLeft->_key;
if (pmaxLeft->_left == maxLeft)
{
pmaxLeft->_left = maxLeft->_left;
}
else
{
pmaxLeft->_right = maxLeft->_left;
}
delete maxLeft;
// 方案二: 找到右子树的最小节点进行替换
/*Node* pminRight = cur;
Node* minRight = cur->_right;
while (minRight->_left)
{
pminRight = minRight;
minRight = minRight->_left;
}
swap(cur->_key, minRight->_key);
if (pminRight->_left == minRight)
pminRight->_left = minRight->_right;
else
pminRight->_right = minRight->_right;
delete minRight;*/
}
return true;
}
}
return false; // 删除失败
}
bool EraseR(const K& key)
{
return _EraseR(_root, key);
}
void Inorder()
{
_Inorder(_root);
cout << endl;
}
private:
Node* _root = nullptr;
void _Inorder(Node* root)
{
if (root == nullptr)
return;
_Inorder(root->_left);
cout << root->_key << " ";
_Inorder(root->_right);
}
bool _InsertR(Node*& root, const K& key)
{
if (root == nullptr)
{
root = new Node(key);
return true;
}
if (root->_key < key)
return _InsertR(root->_right, key);
else if (root->_key > key)
return _InsertR(root->_left, key);
else
return false;
}
bool _FindR(Node* root, const K& key)
{
if (root == nullptr)
return false;
if (root->_key < key)
return _FindR(root->_right, key);
else if (root->_key > key)
return _FindR(root->_left, key);
else
return true;
}
bool _EraseR(Node*& root, const K& key)
{
if (root == nullptr)
return false;
if (root->_key < key)
return _EraseR(root->_right, key);
else if (root->_key > key)
return _EraseR(root->_left, key);
else
{
Node* del = root;
if (root->_left == nullptr)
root = root->_right;
else if (root->_right == nullptr)
root = root->_left;
else
{
Node* min = root->_right;
while (min->_left)
{
min = min->_left;
}
swap(min->_key, root->_key);
return _EraseR(root->_right, key);
}
delete del;
return true;
}
}
// 左根右销毁搜索二叉树, 根节点不能先删除了
void _Destory(Node*& root)
{
if (root == nullptr)
return;
_Destory(root->_left);
_Destory(root->_right);
delete root;
root = nullptr;
}
Node* _Copy(Node* root)
{
if (root == nullptr)
return nullptr;
// 前序遍历拷贝二叉搜索树
Node* copyRoot = new Node(root->_key);
copyRoot->_left = _Copy(root->_left);
copyRoot->_right = _Copy(root->_right);
return copyRoot;
}
//Node* _Copy(Node* root)
//{
// if (root == nullptr)
// return nullptr;
// // 后序遍历拷贝二叉搜索树
// Node* left = _Copy(root->_left);
// Node* right = _Copy(root->_right);
// Node* copyRoot = new Node(root->_key);
// copyRoot->_left = left;
// copyRoot->_right = right;
// return copyRoot;
//}
};
2.4 二叉搜索树的应用
- K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。
- KV模型:每一个关键码key,都有与之对应的值Value,即<Key, Value>的键值对。该种方式在现实生活中非常常见:比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文<word, chinese>就构成一种键值对;再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是<word, count>就构成一种键值对。
总之KV模型的适用范围更广,我们将上述的K模型再添加一个Value值就能改造成我们的KV模型,这里由于复用性很强,我就不进行深入的讲解了,大家自己下来去改造一下就行。
2.5 二叉搜索树的性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为:
l
o
g
2
N
log_2 N
log2N
最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为:
N
2
\frac{N}{2}
2N
Q:如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插
入关键码,二叉搜索树的性能都能达到最优?
那么我们后续章节学习的AVL树和红黑树就可以上场了。