文章目录
- 📖 前言
- 1. 二叉搜索树
- 2. 二叉搜索树的模拟实现
- 2.1 结点的声明
- 2.2 基本的几个成员函数
- 非递归版本
- (1)查找:
- (2)插入:
- (4)删除:(重点)
- 递归版本
- (1)查找:
- (2)插入:(重点)
- (3)删除:
📖 前言
- 从本章起,我们开始深入学习二叉树,学习其更高端的应用,然后将学习STL中比较重要的两个容器set和map。
- 学习二叉搜索树也是为以后学习和实现set和map做铺垫。
1. 二叉搜索树
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树
也就是说一棵二叉搜索树的任一个根节点,它的左子树所有节点的值都是小于根节点的值的,它的右子树所有结点的值都是大于根节点的值的。
二叉搜索树查找的时间复杂度:
- 根据二叉搜索树的性质
- 我们大多数人认为其搜索的一个值的速度是为树的高度次
- 树的高度次的话,很多人就会认为是log2N次
- 但是事实并不是,正确得查找时间复杂度是〇(N)
只有当是满二叉树或者是完全二叉树时间复杂度才是〇(logN)!!
解释:
当出现单边树的情况时,就是〇(N)的情况。
此时树的高速就是结点的个数,同时如果数据量过大,而且是递归查找的话,很有可能会有爆栈的风险!!
在以后我们会学习平衡二叉树,就是为了解决上述情况的问题。
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 基本的几个成员函数
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node;
private:
//没有参数是不能递归的
void DestroyTree(Node* root)
{
if (root == nullptr)
return;
DestroyTree(root->_left);
DestroyTree(root->_right);
delete root;
}
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:
//强制编译器自己生成构造函数 -- C++11
BSTree() = default;
/*BSTree()
:_root(nullptr)
{}*/
//前序遍历递归拷贝
BSTree(const BSTree<K>& t)
{
_root = CopyTree(t._root);
}
//t1 = t2; -- 任何赋值重载都可以用现代写法
BSTree<K>& operator=(BSTree<K> t)
{
swap(_root, t._root);
return *this;
}
~BSTree()
{
DestroyTree(_root);
_root = nullptr;
}
构造函数:
- 这里我们可以采用传统的方法
- 直接初始化成员变量
- 也可以用C++11的语法default
- 强制编译器自己生成构造函数
拷贝构造:
- 这里我们用了递归的方式进行拷贝
- 采用根 - 左 - 右 的前序遍历的递归方式对整个二叉树拷贝
- 最后将跟结点返回
析构函数:
- 析构函数我们这里也是采用递归的方式进行一个一个结点析构
- 同样的我们再嵌套一个子函数
- 也是采用类似前序遍历的方法将整个二叉树释放掉
采用递归方式的缺点就是如果数的结点个数足够多的时候,就会有爆栈的风险!!
非递归版本
(1)查找:
在二叉搜索树中找某个值:
bool Find(const K& key)
{
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;
}
根据二叉搜索树的性质,查找规则很简单:
- 从根节点开始找起
- 要找的值如果比根节点的值大,则在根节点的右子树中找
- 要找的值如果比根节点的值小,则在根节点的左子树中找
- 再在子树中重复上述操作,最终找到要找的值
所以再没有平衡二叉搜索树的情况下,查找的时间复杂度为〇(N)
(2)插入:
bool Insert(const K& key)
{
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
else
{
parent = cur;
cur = cur->_left;
}
}
cur = new Node(key);
if (parent->_key < key)
{
parent->_right = cur;
}
else
{
parent->_left = cur;
}
return true;
}
根据搜索二叉树的特性,插入规则如下:
- 上述过程也是一个查找的过程
- 根据要插入值的大小,定位其在树中合适的位置
- 找到合适位置之后,直接插入即可
(4)删除:(重点)
二叉搜索树的删除,是一件非常麻烦的事情
- 要删除结点,就要理清楚父子节点的链接关系(一不留神就把关系理乱了)
- 要求删过之后的二叉树还是一棵搜索二叉树(相当困难,普通直接删除做不到)
分析问题:
- (1)当没有孩子或者只有一个孩子时
- 可以直接删除,孩子托管给父亲 — (托孤)
以删除14这个结点为例:
- 该结点比10这个结点(父结点)大,在其右子树
- 那么该右子树的所有的值都比10这个结点大
- 所以要链接在10这个结点的右边
以删除7这个结点为例:
- 该结点比6这个结点(父结点)大,在其右子树
- 因为7这个结点没有孩子
- 直接删除,将父节点(6结点)的右指向空
- (2)当有两个孩子时
- 没办法给父亲,父亲养不了,要找个人替代我养孩子
核心步骤:
- 要找到 【左子树的最大值节点,或者右子树的最小值节点】
- 找到之后,将要删除的结点和找到的结点的值进行交换(这里我们暂时用的是值交换)
- 再将被交换过之后的值的结点删除
- 一般被交换的结点都是末尾的叶子结点(按照上述的没有孩子的结点删除方式删除)
代码如下:
bool Erase(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
else
{
//找到了就分三种情况
//该结点有一个孩子 -- 左为空 or 右为空(托孤)
//该结点有两个孩子 -- 替换法
//第一种情况:该结点有一个孩子且该结点的左为空
if (cur->_left == nullptr)
{
//当删除的是根节点的时候
//if(parent == nullptr)
if (cur == _root)
{
_root = cur->_right;
}
else
{
if (cur == parent->_left)
{
parent->_left = cur->_right;
}
else
{
parent->_right = cur->_right;
}
}
delete cur;
}
//第二种情况:该结点有一个孩子且该结点的右为空
else if (cur->_right == nullptr)
{
if (cur == parent)
{
_root = cur->_left;
}
else
{
if (cur == parent->_left)
{
parent->_left = cur->_left;
}
else
{
parent->_right = cur->_left;
}
}
delete cur;
}
//两个孩子都不为空(替换法删除)
else
{
//我们这里统一找右树最左结点(最小)
//右子树的最小结点替代
//minParent一开始不能给空,因为右子树的跟一开始就可能是minRight
//Node* minParent = nullptr; -- 循环直接不能进去
Node* minParent = cur;
//从右子树的根开始
Node* minRight = cur->_right;
//找最左结点(最小)
while (minRight->_left)
{
minParent = minRight;
minRight = minRight->_left;
}
//交换
swap(minRight->_key, cur->_key);
//**return Erase(key); -- 这是错的,因为这里已经不符合搜索树的规则了
//递归过程中找不到想要想要删除的数(交换到后头的数)
//直接赋值
//cur->_key = minRight->_key;
//删除
//找到最小结点,此结点一定是该结点父亲结点的左孩子
//此结点一定没有左孩子(一定是左为空),有可能有右孩子,也可能没有右孩子
//此时只需要将父亲的左指向该结点的右孩子即可
//删除完成
if (minParent->_left == minRight)
{
minParent->_left = minRight->_right;
}
else if (minParent->_right == minRight)
{
minParent->_right = minRight->_right;
}
delete minRight;
}
return true;
}
}
return false;
}
代码解释,如下图:
- 第一种情况:该结点有一个孩子且该结点的左为空
- 第二种情况:该结点有一个孩子且该结点的右为空
- 第三种情况:两个孩子都不为空(替换法删除)
左子树的最大值节点,或者右子树的最小值节点
- 根据二叉搜索树的特性
- 任何一个结点的左子树所有结点的值都比根小
- 任何一个结点的右子树所有结点的值都比根大
找要删除结点的左子树的最大值节点:
- 那么找左子树的最右边结点
- 那么该结点一定比根结点的右子树中所有的值都小
- 但是该结点在根结点的左子树中是最大的
- 让其和根结点的值交换
- 将被交换的结点删除后,整棵树仍保持是一棵二叉搜索树
同理,找右子树的最小值节点也是一样的道理
递归版本
递归版本理解起来就相对与非递归版本更好理解了,直接看代码
(1)查找:
bool FindR(const K& key)
{
return _FindR(_root, key);
}
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;
}
}
逐层递归查找即可…
(2)插入:(重点)
bool InsertR(const K& key)
{
return _InsertR(_root, key);
}
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;
}
}
该如何链接上树呢?
- 可以在递归的参数中多一个父亲结点,每次递归都更新一下Parent,然后再带到下一层递归
- 显然这样在学过C++之后就麻烦了
用了一个指针的引用就解决了问题
- 因为root的值此时是空,但是root同时是这个结点里的_left这个指针的别名
- 相当于当前结点的父节点的左指针的别名
- 意味着此时再去给root赋值就是去给该结点父亲结点的_left赋值
- 那么此时就链接起来了
(3)删除:
bool EraseR(const K& key)
{
return _EraseR(_root, key);
}
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;
//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;
}
swap(root->_key, minRight->_key);
return _EraseR(root->_right, key);
//转换成在root->_right(右子树)中去删除key
//这里删除这个key一定会走左为空的场景(找最小)
}
delete del;
return true;
}
}
相等时就开始删除了(递归只是用来查找要删除的数的位置)
- root是要删除结点的左结点 / 右结点的别名
分三种情况删除:
- 要删除的结点左为空
- 要删除的结点右为空
- 要删除的结点左右都为空(替换法)
总的来说递归版本比非递归版本更容易理解,删除过程参考非递归删除过程……(有异曲同工之妙)