C/C++数据结构(十)—— 二叉查找树
文章目录
- 1. 二叉查找树的概念
- 2. 二叉查找树的实现
- 🍑 定义节点
- 🍑 函数接口总览
- 🍑 构造函数
- 🍑 拷贝构造
- 🍑 赋值重载
- 🍑 析构函数
- 🍑 查找操作
- 🍅 动图演示
- 🍅 非递归实现
- 🍅 递归实现
- 🍑 插入操作
- 🍅 动图演示
- 🍅 非递归实现
- 🍅 递归实现
- 🍑 删除操作
- 🍅 非递归实现
- 🍅 递归实现
- 🍑 中序遍历
- 3. 二叉查找树的性能分析
1. 二叉查找树的概念
还记得我们之前学过的二叉树吗?
二又树是树的一种特殊形式,每个节点最多有 2 个孩子节点,下图就是一棵典型的二叉树:
那什么是二叉查找树呢?
二叉查找树(Binary Search Tree),也称二叉排序树或二叉搜索树,顾名思义,是用来查找数据的,它在二叉树的基础上,增加了几个规则:
- 如果左子树不为空,则左子树上所有节点的值均小于根节点的值。
- 如果右子树不为空,则右子树上所有节点的值均大于根节点的值。
- 左、右子树也都是二叉搜索树。
下图就是一棵标准的二叉查找树:
这样一棵树,如何进行查找呢?
比如我们要查找的值是 6,查找过程如下:
(1)访问根节点 8,发现 6 < 8。
(2)访问根节点 8 的左孩子节点 3,发现 6 > 3。
(3)访问节点 3 的右孩子节点 6,发现正是要查找的节点。
对于一个节点分布相对平衡的二叉查找树,如果节点总数是 n,那么查找节点的时间复杂度就是 O ( l o g n ) O(logn) O(logn),和树的深度成正比。
另外,二叉查找树不仅可以用于查找,还有一个重要功能:维持节点的有序性。
我们来给这棵二叉树做一个中序遍历,先访问左子树,再访问根节点,最后访问右子树。
因此,对于上面例子中的二叉查找树,中序遍历的访问顺序(节点旁边的数字)如下:
按顺序输出,结果为:1, 3, 4, 6, 7, 8, 10, 13, 14
输出结果完全按照升序排列,二叉查找树保持了有序性!
正是这个原因,二叉查找树也被称为二叉排序树(Binary Sort Tree)。这样的二叉树无论进行多少次插入,删除操作,都始终保持有序。
2. 二叉查找树的实现
前面的二叉树是用 C 语言来实现的,那么这次的二叉查找树我们选择用 C++ 来实现。
🍑 定义节点
首先构建一个二叉树查找树的节点类,并且实现一个构造函数
// 节点类
template<class K>
struct BSTreeNode
{
K _key; // 节点值
BSTreeNode<K>* _left; // 左指针
BSTreeNode<K>* _right; // 右指针
// 构造函数
BSTreeNode(const K& key)
:_key(key)
,_left(nullptr)
,_right(nullptr)
{}
};
🍑 函数接口总览
有了节点以后,我们就可以来实现二叉查找树了,下面是所有的接口函数:
//二叉搜索树
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node; // 把节点重定义成Node
public:
// 构造函数
BSTree();
// 拷贝构造
BSTree(const BSTree<K>& t);
// 赋值重载
BSTree<K>& operator=(BSTree<K> t);
// 析构函数
~BSTree();
// 插入函数
bool Insert(const K& key);
// 删除函数
bool Erase(const K& key);
// 查找函数
bool Find(const K& key);
// 中序遍历
void InOrder();
private:
Node* _root; //指向二叉搜索树的根结点
};
🍑 构造函数
因为我们待会儿要实现拷贝构造,而在 C++ 里面,不管是拷贝构造,还是构造,只要是显示的写了,那么编译器就不会默认生成,所以我们可以用一个关键字 default
强制编译器自己生成构造函数。
public:
// 构造函数(强制编译器自己生成)
BSTree() = default;
🍑 拷贝构造
如果我们不实现拷贝构造的话,编译器会去调用默认的拷贝构造,而默认的拷贝构造是浅拷贝(也就是值拷贝),会引发析构两次的问题,所以我们需要自己去实现一个拷贝构造完成深拷贝。
private:
Node* CopyTree(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; // 返回拷贝的树
}
public:
// 拷贝构造(深拷贝)
BSTree(const BSTree<K>& t)
{
_root = CopyTree(t._root);
}
这里的深拷贝其实就是前序遍历递归创建的过程。
那么为什么我们要 CopyTree
函数并且封装在私有域里面呢?
如果我们递归调用需要传 _root
,在 C 语言中可以直接传递,但是 C++ 涉及到封装,根变成了私有,怎么传呢?
所以需要写一个子函数,然后去调用。
🍑 赋值重载
赋值重载函数的实现很简单,比如 BSTree<int> copy = t
。
我们知道函数传参如果不是引用而是对象的话就会调用拷贝构造函数,所以我们只需将这个拷贝构造出来的对象 copy
与 this
指向的对象 t
进行交换,就相当于完成了赋值操作,而拷贝构造出来的对象 copy
会在该赋值运算符重载函数调用结束时自动析构。
public:
// 赋值重载
BSTree<K>& operator=(BSTree<K> t)
{
swap(_root, t._root);
return *this;
}
🍑 析构函数
我们析构可以用递归的方式来实现,那么就要去写一个 Destroy
子函数,采用后序遍历的方式去释放每一个节点,当树中的结点被全部释放完以后,将对象当中指向二叉查找树的指针及时置空。
private:
// 递归销毁函数
void DestroyTree(Node* root)
{
// 如果是空树,直接返回空
if (root == nullptr)
return;
DestroyTree(root->_left); // 递归释放左子树中的节点
DestroyTree(root->_right); // 递归释放右子树中的节点
delete root; // 删除根结点
}
public:
// 析构函数
~BSTree()
{
DestroyTree(_root); // 释放二叉查找树中的节点
_root = nullptr; // 把根节点置为空
}
注意:析构函数没有参数,那么就不能递归,所以这里套了一层子函数
🍑 查找操作
二叉查找树的查找操作与二分查找非常相似:
- 如果要查找的树为空树,则直接返回空。
- 如果不为空树,则从根节点开始比较,查找。
- 若查找的值比根大,则往根的右子树查找
- 若查找的值比根小,则往根的左子树查找。
- 若查找的值等于当前节点的值,则查找成功,返回对应节点的地址。
- 最多查找高度次,走到空,还没找到,这个值不存在。
现在我们要在下面这棵树中查找值为 13 的节点。
第一步:访问跟节点 8
第二步:根据二叉查找树的左子树均比根节点小,右子树均比根节点大的性质, 13 > 8 ,因此值为 13 的节点可能在根节点 8 的右子树当中,我们查看根节点的右子节点 10 :
第三步:与第二步相似, 13 > 10 ,因此查看节点 10 的右孩子 14 :
第四步:根据二叉查找树的左子树均比根节点小,右子树均比根节点大的性质, 13 < 14 ,因此查看 14 的左孩子 13 ,发现刚好和要查找的值相等:
🍅 动图演示
在下面动图中,假设要查找值为 50 的节点:
对于二叉查找树的查找代码,这里给出两个版本供大家参考:递归和非递归。
🍅 非递归实现
二叉查找树的查找函数非递归实现:
public:
// 查找函数
bool Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_key < key) // 如果key值大于当前节点的值
{
cur = cur->_right; // 就去当前节点的右子树当中查找
}
else if (cur->_key > key) // 如果key值小于当前节点的值
{
cur = cur->_left; // 就去当前节点的左子树当中查找
}
else // 当前节点的值等于key值
{
return true; // 说明找到了,直接返回true
}
}
// 树为空或查找失败,返回false
return false;
}
🍅 递归实现
二叉查找树的查找函数递归实现(递归涉及到传参的问题,所以需要写一个子函数):
private:
// 查找函数(递归实现的子函数)
bool _FindR(Node* root, const K& key)
{
// 如果是空树,那么直接返回空
if (root == nullptr)
return false;
if (root->_key < key) // 如果key值大于根节点的值
{
return _FindR(root->_right, key); // 就去当根节点的右子树当中查找
}
else if (root->_key > key) // 如果key值小于根节点的值
{
return _FindR(root->_left, key); // 就去当根节点的左子树当中查找
}
else // 如果key值等于根节点的值
{
return true; // 查找成功,返回true
}
}
public:
// 查找函数(递归实现)
bool FindR(const K& key)
{
return _FindR(_root, key); // 去调用查找的子函数
}
🍑 插入操作
二叉查找树在插入新节点的时候,都必须遵守原有的规则(二叉查找树的规则)。
对于任意一个待插入的元素 x 都是插入在二叉排序树的叶子结点,问题的关键就是确定插入的位置,从根结点开始进行判断,直到到达叶子结点,则将待插入的元素作为一个叶子结点插入即可。
- 如果是空树,则直接将插入节点 x 作为二叉查找树的根结点。
- 如果不是空树,按二叉查找树性质,从根结点开始进行判断插入位置。
- 若插入节点的值小于根节点的值,则需要将该节点插入到根节点的左子树当中。
- 若插入节点的值大于根节点的值,则需要将该节点插入到根节点的右子树当中。
- 注意:如果插入的值等于当前节点的值,那么也是插入失败的(因为二叉查找树中不存在有两个相同节点的值)
假设我们现在要插入值为 0 的节点
第一步:访问根结点 8
第二步:根据二叉排序树的左子树均比根节点小,右子树均比根节点大的性质, 0 < 8 ,因此值为 0 的节点应该插入到根节点 8 的左子树当中,我们查看根节点的左子节点 3 :
第三步:根据二叉排序树的左子树均比根节点小,右子树均比根节点大的性质, 0 < 3 ,因此值为 0 的节点应该插入到根节点 3 的左子树当中,我们查看根节点的左子节点 1 :
第四步:根据二叉排序树的左子树均比根节点小,右子树均比根节点大的性质, 0 < 1 ,因此值为 0 的结点应该插入到节点 1 的左子树当中,访问节点 1 的左孩子,发现为空,则将 0 作为 1 号节点的左孩子插入。
🍅 动图演示
假设我们现在要插入值为 63 的节点:
有没有发现,插入操作和查找操作的原理是一样滴?
同样,我这里给大家提供递归和非递归两个版本。
🍅 非递归实现
使用非递归方式实现时,需要定义一个 cur
指针记录当前节点,还需要定义一个 parent
指针记录 cur
的父节点。当 cur
指向空时,通过 parent
判断将 key
插入到左边还是右边。
二叉查找树的插入函数非递归实现:
// 插入函数(非递归实现)
bool Insert(const K& key)
{
// 第一次插入,根节点为空
if (_root == nullptr)
{
_root = new Node(key); // 直接把key节点作为树的根结点
return true; // 插入成功,返回true
}
// 第二次插入的时候
Node* parent = nullptr; // 记录cur的父节点
Node* cur = _root;
while (cur)
{
if (cur->_key < key) // key大于当前节点,cur往右边走
{
parent = cur;
cur = cur->_right;
}
else if (cur->_key > key) // key小于当前节点,cur往左边走
{
parent = cur;
cur = cur->_left;
}
else // key等于当前节点的值
{
return false; // 插入失败,返回false
}
}
// 当循环结束,说明cur找到了空的位置
cur = new Node(key);
if (parent->_key < key) // 如果key值大于当前parent节点的值
{
parent->_right = cur; // 就把key连接到parent的右边
}
else // 如果key值小于当前parent节点的值
{
parent->_left = cur; // 就把key连接到parent的左边
}
// 插入成功,返回true
return true;
}
🍅 递归实现
二叉查找树的查找函数递归实现(递归涉及到传参的问题,所以需要写一个子函数):
private:
// 插入函数(递归实现的子函数)
bool _InsertR(Node*& root, const K& key) // 引用传参,root是_root的别名
{
if (root == nullptr) // 如果是空树
{
root = new Node(key); // 直接把key节点作为树的根结点
return true; // 插入成功,返回true
}
// 如果不是空树
if (root->_key < key) // 如果key大于当前节点的值
{
return _InsertR(root->_right, key); // 把key插入到右子树当中
}
else if (root->_key > key) // 如果key小于当前节点的值
{
return _InsertR(root->_left, key); // 把key插入到左子树当中
}
else // 如果key等于当前节点的值
{
return false; // 插入失败,直接返回false
}
}
public:
// 插入函数(递归实现)
bool InsertR(const K& key)
{
return _InsertR(_root, key);
}
🍑 删除操作
删除操作与查找和插入操作不同,首先查找元素是否在二叉搜索树中,如果不存在,则返回 nullptr
,否则要删除的结点可能分以下三种情况进行处理:
- 待删除的节点
x
是叶子节点(也就是x
无左右孩子节点)。 - 待删除的节点
x
有一个孩子(左孩子或者右孩子)。 - 待删除的节点
x
有两个孩子(即左右孩子都存在)。
那么就这三种情况,我们来分类讨论一下:
情况一:被删除的节点 x
是叶子节点,那么直接从二叉排序树当中移除即可,也不会影响树的结构
动图演示:删除值为 5 的节点
情况二:被删除的节点 x
仅有一个孩子
-
如果只有左孩子,没有右孩子,那么只需要把要删除节点的左孩子链接到要删除节点
x
的父亲节点,然后直接删除x
节点; -
如果只有右孩子,没有左孩子,那么只需要把要删除节点的右孩子链接到要删除结点
x
的父亲节点,然后直接删除x
节点;
假设我们要删除值为 14 的结点,其只有一个左孩子结点 13 ,没有右孩子 。
第一步:先找到 14 的父节点 10,让其父节点 10 指向其左孩子 13。
第二步:删除释放 14 节点即可。
我们再以删除结点 10 为例,再看一下没有左孩子,只有一个右孩子的情况。
第一步:先找到 10 的父节点 8,让其父节点 8 指向其右孩子 14。
第二步:删除释放 10 节点即可。
动图演示:删除值为 71 的节点,该节点只有一个左孩子
动图演示:删除值为 7 的节点,该节点只有一个右孩子
情况三:被删除结点的左右孩子都存在
对于这种情况就复杂一些了,我们以下面这颗二叉查找树为例进行说明:
对于上面的二叉查找树的中序遍历结果如下所示:
现在我们先不考虑二叉排序上的删除操作,而仅在得到的中序遍历结果上进行删除操作。我们以删除中序遍历结果当中的根节点 8 为例进行说明:
当删除中序遍历结果中的 8 之后,哪一种方式不会改变中序遍历结果的有序性呢?
很简单,我么可以用 7 或者 9 来填充 8 的位置,都不会影响整个数组的有序性。
那么此时就相当于删除 根节点 8,然后根节点 左子树当中的最大元素 7 来替换根节点 8 的位置,或者用根节点的 右子树当中最小元素 9 来替换根节点 8 的位置。
所以删除操作要用替换的方式来进行,也就是替换 左子树的最大值节点 或者 右子树的最小值节点。
下面就来看删除左右孩子都存在的结点是如何实现的,依旧以删除根节点 8 为例。首先我们先用根节点 8 的左子树当中值最大的结点 7 来替换根节点的情况。
第一步:获得待删除节点 8 的左子树当中值最大的节点 7(这一步可以通过从删除节点的左孩子 3 开始,一个劲地访问右子结点,直到叶子结点为止获得,因为左子树的最大值一定在最右边):
第二步:将删除节点 8 的值替换为 7 ;
第三步:删除根节点左子树当中值最大的节点(这一步可能左子树中值最大的节点存在左子节点,而没有右子节点的情况,那么删除就退化成了第二种情况,递归调用即可):
我们再来看一下使用根节点 8 的右子树当中值最小的节点 9 来替换根节点的情况。
第一步:查找删除节点 8 的右子树当中值最小的节点,即 9 (先访问删除节点的右子节点 10,然后一直向左走,直到左子结点为空,则得到右子树当中值最小的节点,因为右子树的最小值一定在最左边)。
第二步:将删除结点 8 的值替换为 9 ;
第三步:删除根节点右子树当中值最小的节点。
动图演示:删除值为 15 的节点,该节点左右孩子都存在
以上就是删除二叉查找树的三种情况的分析。
这里还是提供两个版本给大家参考:递归和非递归。
🍅 非递归实现
我这里采用 右子树的最小值节点 来和待删除节点的值进行替换:
- 定义
minParent
用来记录待删除节点的右子树当中最小值节点的父节点。 - 定义
minRight
用来记录待删除节点的右子树当中最小值节点。
二叉查找树的删除操作非递归实现:
public:
// 删除函数(非递归实现)
bool Erase(const K& key)
{
Node* parent = nullptr; // 记录待删除节点的父节点
Node* cur = _root; // 记录删除节点
while (cur) // 先找到要删除的节点
{
if (cur->_key < key) // key大于当前节点的值,就往该节点的左子树查找
{
parent = cur;
cur = cur->_right;
}
else if (cur->_key > key) // key小于当前节点的值,就往该节点的右子树查找
{
parent = cur;
cur = cur->_left;
}
else // 找到了要删除的节点,分三种情况讨论
{
if (cur->_left == nullptr) // 如果待删除节点的左子树为空
{
if (cur == _root) // 如果要删除的cur是根节点(此时parent为nullptr)
{
_root = cur->_right; // 那么直接把二叉查找树的根节点改为cur的右孩子即可
}
else // 如果要删除的cur不是根节点(此时parent不为nullptr)
{
if (cur == parent->_left) // 如果待删除节点(cur)是其父节点(parent)的左孩子
{
parent->_left = cur->_right; // 那么就让父节点(parent)的左指针(left)指向删除节点(cur)的右子树
}
else // 如果待删除节点(cur)是其父节点(parent)的右孩子
{
parent->_right = cur->_right; // 那么就让父节点(parent)的右指针(right)指向删除节点(cur)的右子树
}
}
delete cur; // 释放待删除节点
}
else if (cur->_right == nullptr) // 如果待删除节点的右子树为空
{
if (cur == _root) // 如果要删除的cur是根节点(此时parent为nullptr)
{
_root = cur->_left; // 那么直接把二叉查找树的根节点改为cur的左孩子即可
}
else // 如果要删除的cur不是根节点(此时parent不为nullptr)
{
if (cur == parent->_left) // 如果待删除节点(cur)是其父节点(parent)的左孩子
{
parent->_left = cur->_left; // 那么就让父节点(parent)的左指针(left)指向待删除节点(cur)的左子树
}
else // 如果待删除节点(cur)是其父节点(parent)的右孩子
{
parent->_right = cur->_left; // 那么就让父节点(parent)的右指针(left)指向待删除节点(cur)的左子树
}
}
delete cur; // 释放待删除节点
}
else // 如果待删除节点的左右子树都不为空
{
// 这里选择用右子树的最小值节点替换(左子树当中最大值也可以)
Node* minParent = cur; // 记录待删除节点右子树当中值最小节点的父节点
Node* minRight = cur->_right; // 记录待删除节点右子树当中值最小的节点
while (minRight->_left) // 寻找待删除节点右子树当中值最小的节点
{
minParent = minRight;
minRight = minRight->_left;
}
// 循环结束,说明找到了
cur->_key = minRight->_key; // 把待删除节点的值替换成minRight的值
if (minParent->_left == minRight) // 如果minRight是其父节点的左孩子
{
minParent->_left = minRight->_right; // 就让父节点的左指针(left)指向minRight的右子树即可
}
else // 如果minRight是其父节点的右孩子
{
minParent->_right = minRight->_right; // 就让父节点的右指针(right)指向minRight的右子树即可
}
delete minRight; // 释放minRight节点
}
return true; // 删除成功,返回true
}
}
return false; // 没有找到待删除节点,即删除失败,返回false
}
🍅 递归实现
二叉查找树的删除操作递归实现思路如下:
- 定义
minParent
用来记录待删除节点的右子树当中最小值节点的父节点。 - 定义
minRight
用来记录待删除节点的右子树当中最小值节点。
当找到根节点的右子树当中最小值节点 minRight
时,先把根节点的值和 minRight
进行交换,然后再重新调用递归删除函数从当前根节点的右子树开始,删除右子树当中的 minRight
。
private:
// 删除函数(递归实现的子函数)
bool _EraseR(Node*& root, const K& key)
{
if (root == nullptr) // 如果是空树
return false; // 删除失败,直接返回false
if (root->_key < key) // 如果key大于根节点的值
{
return _EraseR(root->_right, key); // 那么待删除节点在根的左子树当中
}
else if (root->_key > 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* minRight = root->_right; // 记录根节点右子树当中值最小的节点
while (minRight->_left) // 寻找根节点右子树当中值最小的节点
{
minRight = minRight->_left; // 右子树当中值最小的节点一定是在最左边,所以一直往左查找
}
// 找到以后,把根节点的值和minRight的值交换
swap(root->_key, minRight->_key);
return _EraseR(root->_right, key); // 此时,就转换成在根的右子树当中去删除这个key,这里删除这个key一定会走作为空的场景。
}
delete del; // 释放根节点
return true; // 删除成功,返回true
}
}
public:
// 删除函数(递归实现)
bool EraseR(const K& key)
{
return _EraseR(_root, key);
}
🍑 中序遍历
中序遍历和二叉树的中序实现一样,只不过因为中序是递归遍历,涉及到传参,所以需要写一个子函数。
private:
// 中序遍历(递归实现的子函数)
void _InOrder(Node* root)
{
if (root == nullptr) // 如果是空树,直接返回空
return;
_InOrder(root->_left); // 递归遍历左子树
cout << root->_key << " "; // 打印每个节点的值
_InOrder(root->_right); // 递归遍历右子树
}
public:
// 中序遍历(递归实现)
void InOrder()
{
_InOrder(_root);
cout << endl;
}
3. 二叉查找树的性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
二叉查找树的插入和查找、删除操作的最坏时间复杂度为 O ( h ) O(h) O(h),其中 h 是二叉查找树的高度。最极端的情况下,我们可能必须从根节点访问到最深的叶子节点,斜树的高度可能变成 n,插入和删除操作的时间复杂度将可能变为 O ( n ) O(n) O(n)。
下图就是两颗斜树(就相当于单链表)。这也是二叉查找树在进行多次插入操作后可能发生的不平衡问题,也是二叉查找树的缺陷所在,但这依旧不妨碍其作为一个伟大的数据结构。
对有 n 个节点的二叉查找树,若每个元素查找的概率相等,则二叉查找树平均查找长度是节点在二叉查找树的深度的函数,即节点越深,则比较次数越多。
但对于同一个关键码 key 集合,如果各关键码 key 插入的次序不同,可能得到不同结构的二叉查找树。
- 最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为: l o g 2 N log_2 N log2N
- 最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为: N 2 \frac{N}{2} 2N
如果退化成单支树,二叉搜索树的性能就失去了。那么后面的 AVL 树和红黑树就可以上场了。