二叉搜索树又称二叉排序树,它可以是一棵空树,或者是具有以下性质的二叉树:
显然二叉搜索树与二叉树一样也是递归定义的!
根据二叉搜索树的定义,我们可以推导出一个结论:二叉搜索树的中序遍历必定是升序的。
因为中序遍历是先遍历左子树再遍历根节点,最后遍历右子树,而二叉搜索树按照从左到右这个顺序刚好是一个升序。
和普通的二叉树一样,二叉搜索树也只不过是在普通二叉树的基础上增加了一些数据存储的位置规则罢了。
对于二叉搜索树我们需要左右孩子两个指针,以及一个存储数据的变量,节点可以定义为:
// 搜索二叉树的节点
template <class T>
struct BSTreeNode
{
// 构造函数
BSTreeNode(T x)
:_val(x)
, _left(nullptr)
, _right(nullptr)
{}
// 存储的数据
T _val;
// 指针
BSTreeNode<T>* _left;
BSTreeNode<T>* _right;
};
对于二叉搜索树的完整结构我们只需要用一个类去封装根节点就行了,在类中我们可以实现一些操作二叉搜索树的函数。
// 搜索二叉树的结构
template<class T>
class BSTree
{
public:
typedef BSTreeNode<T> Node;
//构造函数, 默认对于根节点给nullptr值
BSTree(Node* root = nullptr)
:_root(root)
{}
// 搜索二叉树的插入
bool Insert(const T& x);
// 搜索二叉树的查找
bool Find(const T& x);
// 搜索二叉树的删除
bool Erase(const T& x);
//析构函数
~BSTree();
private:
void Destory(Node*& root);
Node* _root;
};
我们先来观察一下下面这颗二叉搜索树是怎么进行插入的:
注意观察插入之前原始位置的特点:
- 插入 8,8是根节点,直接赋值插入
- 插入 3, 3小于8,插入在8的左边
- 插入1,1小于8,进入左子树3,1又小于3,则1为3的左子树
- 插入10,10大于8,插入8的右边
- 插入6,6小于8,进入左子树3,6又大于3,则6为3的右边
- 插入14,14大于8,进入右子树10,14又大于10,则14为10的右边
- 插入4,由于4小于8,进入左子树3,4又大于3,进入右子树6,4还小于6,则4为6的左边
- 插入7,由于7小于8,进入左子树3,7又大于3,进入右子树6,7还大于于6,则7为6的右边
- 插入13,由于13大于8,进入右子树10,又13大于10,进入右子树14,13小于14,则13为14的左边
仔细观察我们会发现,当我们插入一个新节点时是遇到nullptr
才进行插入的。于是我们便可以根据下面三个特点进行编写代码了。
- 左孩子的的值一定小于根节点的值。
- 右孩子的值一定大于根节点的值。
- 遇到
nullptr
才能进行插入。
// 搜索二叉树的插入
template<class T>
bool BSTree<T>::Insert(const T& x)
{
// 如果根节点为nullptr
if (_root == nullptr)
{
_root = new Node(x);
return true;
}
// 定义两个指针,一个是当前节点,一个是当前节点的父节点
Node* cur = _root;
Node* parent = nullptr;
// 当前节点走到nullptr我们才能进行链接插入
while (cur)
{
// 按照二叉搜索树的规则进行比较寻找插入位置
if (cur->_val > x)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_val < x)
{
parent = cur;
cur = cur->_right;
}
else
{
return false;
}
}
// 当前指针cur为nullptr,我们可以进行插入了,但需要判断cur是parent的左孩子还是右孩子。
if (parent->_val > x)
{
parent->_left = new Node(x);
}
else
{
parent->_right = new Node(x);
}
return true;
}
二叉搜索树的查找操作很简单,刚才我们进行二叉搜索树的插入操作中其实已经用到了查找操作了,我们进行二叉搜索树的插入时需要寻找nullptr
节点的位置,这其实就是查找操作,我们先看一下在下面的树中我们是怎样进行查找的。
true
nullptr
还没有找到就返回false
可以看出上面上面 9 个值我们只用了 4 次就找到了,这要比在无序数组中挨着查找快的多,特别是当数据很多的时候。
有了上面的理解我们就可以写出下面的代码了。
// 搜索二叉树的查找
template<class T>
bool BSTree<T>::Find(const T& x)
{
// 定义当前节点
Node* cur = _root;
while (cur)
{
// 按照二叉搜索树的规则进行比较查找
if (cur->_val > x)
{
cur = cur->_left;
}
else if (cur->_val < x)
{
cur = cur->_right;
}
else
{
return true;
}
}
// 走到这说明遇到了nullptr
return false;
}
二叉树的删除是二叉搜索树的难点,二叉搜索树要求删除完节点以后还要保持二叉搜索树的特征,这导致我们在删除时要处理较为复杂的情况。
① 当删除的节点是叶子节点时
这种情况最为简单,由于是叶子节点,我们可以直接找到该节点删除然后将相应位置的指针置空就行了。
叶子节点可以直接删除,删除后对结构没有影响!
② 当删除的节点是单亲节点时
当我们要删除的节点是单亲节点时,我们不能直接删除该节点,因为我们删除了当前的单亲节点就会导致该节点的孩子无法访问了。
对于这种情况我们需要将父节点的指向单亲节点的指针指向单亲节点的孩子节点,然后删除单亲节点就行了。
例如下面删除6 和 14
因为一个父节点最多可以有两个子节点,而要删除的节点只有一个子节点,因此可以将被删除节点的子节点给被删除节点的父节点(相当于爷爷带孙子),这样结构就不会被破坏了。
在实际中经常会将①②两种情况合并,①其实是②的一种特殊情况,例如我们下面我们可以将4(叶子节点)当成左为空的的单亲节点,这样我们可以先让让父节点的指针指向4的右子树,然后将4进行删除。
③ 当删除的节点是双亲节点时
这种情况最为复杂,但是想清楚了就会变得很简单。
对于下面的二叉搜索树我们尝试去删除 8,很显然8并不能直接删除但是可以在此树中找一个数据x与8位置的数据进行交换,然后再去删除原始的x位置。
为了满足这样的需求就要在此树中找一个与8最为相近的数来进行代替8,这样才能确保当我们删除完一个双亲节点以后树的结构不受到影响。
根据二叉搜索树的特点,与8最为相近的数是8节点的左子树的最右节点,或者是8的右子树的最左节点。
我们可以让8的左子树的最右节点7来代替8,或者是8的右子树的最左节点10来代替8(这里8的右子树的最左节点为空,因此8的右子树的最左节点就是根节点)。
可以看到,当我们进行替代以后,删除一个双亲节点的问题就转换为了删除叶子节点或者单亲节点的问题了,对于这样的问题我们按照处理叶子节点或者处理单亲节点的方法继续处理就行了。
处理双亲节点的方法是替换法:
- 在被删除节点的左子树中找最右节点来替换它或者是在被删除节点的右子树中找最左节点来替换它。
- 如果最左节点或者最右节点不存在,就用左子树或右子树的根节点进行替换
- 此时就将一个复杂的删除问题转化为一个简单的删除问题了。
代码示例:
template<class T>
bool BSTree<T>::Erase(const T& x)
{
// 如果当前没有节点,则删除失败
if (_root == nullptr)
{
return false;
}
// 定义两个指针变量 cur记录当前位置,parent记录的是cur的父指针
Node* cur = _root;
Node* parent = cur;
// 寻找要删除的节点
while (cur)
{
if (cur->_val > x)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_val < x)
{
parent = cur;
cur = cur->_right;
}
else
{
// 找到了要删除的节点
// 左为空,使用托孤法开始链接
if (cur->_left == nullptr)
{
// 初始值 parent == cur,如果进入了循环parent还是等于cur 说明cur没有变过,即cur是根
// 删除的是根节点。
if (parent == cur)
{
_root = cur->_right;
}
// cur是父节点的左孩子
else if (parent->_left == cur)
{
parent->_left = cur->_right;
}
// cur是父节点的右孩子
else
{
parent->_right = cur->_right;
}
// 删除当前节点
delete cur;
}
// 右为空,使用托孤法开始链接
else if (cur->_right == nullptr)
{
// 删除的是根节点。
if (parent == cur)
{
_root = cur->_left;
}
// cur是父节点的左孩子
else if (parent->_left == cur)
{
parent->_left = cur->_left;
}
// cur是父节点的右孩子
else
{
parent->_right = cur->_left;
}
delete cur;
}
else
{
// 使用替换法:用左子树的最右节点进行替换
Node* prev = cur;
Node* maxLeft = cur->_left;
// 当左子树的根就是最右节点时
if (maxLeft->_right == nullptr)
{
prev->_val = maxLeft->_val;
prev->_left = maxLeft->_left;
}
else
{
while (maxLeft->_right)
{
prev = maxLeft;
maxLeft = maxLeft->_right;
}
cur->_val = maxLeft->_val;
prev->_right = maxLeft->_left;
}
delete maxLeft;
}
return true;
}
}
return false;
}
二叉树的销毁我们可以利用后序遍历进行销毁。
// 析构函数
template<class T>
BSTree<T>::~BSTree()
{
Destory(_root);
}
template<class T>
void BSTree<T>::Destory(typename BSTree<T>::Node*& root)
{
if (root == nullptr)
{
return;
}
// 先销毁左
Destory(root->_left);
// 再销毁右
Destory(root->_right);
// 最后销毁根
delete root;
// 将当前节点置空
root = nullptr;
}
由于二叉搜索树的插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为: l o g 2 N log_2 N log2N
最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为: N 2 \frac{N}{2} 2N
如果退化成单支树,二叉搜索树的性能就失去了,为了让二叉搜索树的性能都能达到最优,那么我们后续可以学习的AVL树和红黑树来对二叉搜索树进行优化,使其性能达到最优。