二叉搜索树的概念
二叉搜索树,又称二叉排序树。它具有以下性质:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值。
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值。
- 它的左右子树也分别为二叉搜索树。
如果是顺序结构插入删除排序的话,其实效率上来说,是不高的。假设插入和删除都需要排序的话,除了在尾部最后一个元素的操作,其他操作都是需要挪动数据的,我们都知道挪动数据所带来的低效的结果。
但是反观二叉搜索树,插入和查找,最多只执行层数次。而删除相对麻烦一点,但是只是我们实现上麻烦,真正在用起来的时候,查找,插入,删除这个写操作并不低,而且这些个操作并不需要挪动数据,每一个数据都是独立存储一个空间的。
二叉搜索树,虽然在很大程度上提升了效率,但是二叉搜索树的下线很低,但是最坏情况下也只是和 顺序存储当中一样 退化到 O(n):
为了解决上述的极端情况,才有了后面的 AVL树 , 红黑树 ,B树系列 等等。
二叉搜索树当中的操作实现
基本框架
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
{
public:
typedef BSTreeNode<K> Node;
BSTree()
:_node(nullptr)
{
}
void _inOrder(Node* cur)
{
if (cur == NULL)
{
return;
}
_inOrder(cur->_left);
cout << cur->_key << " ";
_inOrder(cur->_right);
}
void inOrder()
{
_inOrder(_node);
}
private:
Node* _node;
};
注意,上述实现的 inOrder ()中学查找函数,实现了一层封装,使用 inOrder()的子函数 _inOrder()来实现递归,因为递归需要传入头结点指针作为函数参数。但是我们在主函数的那种调用inOrder()这个函数的时候,因为使用了类的封装,把树的头结点指针封装到类当中了,我们拿不到,所以采用上述方式进行书写。
调用 inOrder()的例子请看 插入操作当中的 示例。
二叉搜索树的插入
非递归
学习过数据结构的小伙伴都知道,刚开始学习树的时候,我们说关于树的增删查改是没有意义的,而且相比于 链表 和 顺序表的 增删查改还有更大的消耗。
但是,在二叉搜索树当中,增删查改就有了意义,因为在树当中,每一个结点,在树当中的位置不是随便放的,而是有一定顺序的。
举个例子,如下述二叉搜索树,我们想要插入一个 值为 “11” 的结点,那么这个过程应该是这样的:
先从 根结点开始比较, 11 比 8 大,应该插入在 8 的右边,但是 8 的右边有结点了,那么就接着比较,11 比 10 大,应该插入在 10 的右边,但是 10 右边还是有结点,接着比较。11 比 14 小,应该插入在左边,左边有结点,接着比较,11 比 13 小,插入在 13 左边。所以,11 应该插入在如下图所示位置:
如果是插入 二叉搜索树当中 有的结点,比如插入 13 ,那么就插入失败,不给插入。
通过上述的过程,你就知道在搜索树当中插入一个结点,如果插入;
相比之下,二叉搜索树当中插入就有了意义,那么同样的,删查改也是同样的道理,具体我们后面再说明。
具体代码实现:
bool insert(const K& key)
{
// 首先判断 此时是不是一个空树
if (_node == nullptr)
{
_node = new Node(key); // 是就直接 new 一个空间直接给给头结点指针
return true;
}
Node* cur = _node; // 作为循环迭代指针
Node* perant = _node; // 方便查找 cur 的父亲指针
// 循环 找到合适地方插入 key 结点
while (cur)
{
// 判断 要插入结点的值大小
if (cur->_key < key)
{
perant = cur;
cur = cur->_right;
}
else if (cur->_key > key)
{
perant = cur;
cur = cur->_left;
}
else // 此时相等,不能插入
{
return false;
}
}
// 这里做 开空间,插入结点的操作
// 这里采用 再一次判断 要插入结点的值大小 的方式确认插入那一边
if (perant->_key < key)
{
perant->_right = new Node(key);
return true;
}
else
{
perant->_left = new Node(key);
return true;
}
}
示例:
void textforBSTree1()
{
int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
BSTree<int> T;
for (auto e : a)
{
T.insert(e);
}
T.inOrder();
}
输出:
1 3 4 6 7 8 10 13 14
二叉搜索树,中序遍历出来的结果是有序的。
递归
插入和寻找一样,先找到可以插入的地方,然后把结点插入即可,我们在函数开头位置,判断当前结点是否为空,为空说明这个位置就是可以插入的位置:
但是像上述是不行的,node 传入的是 形参,不能改变父亲结点的指针指向。或者是说传入的已经是父亲结点指针指向的位置了。
所以,这我们需要用 “&” 把 父亲结点当中的 _right 或者 _left 指针带到下一层递归函数栈帧当中,从而修改指针指向:
此时的 node 不在是父亲的 _right 或者 _left 指针 所指向的结点,node 现在就是 父亲结点的 _right 或者 _left 指针。
而,这种对于引用的时候效果,在循环当中,也就是非递归当中是无法实现的;之前我们在循环当中是用 cur = cur->_left 类似这样的形式来往下迭代的,但是 引用是不能这样修改的,因为引用本身不能修改指向内容,比如一个引用指向 d 对象,那么他就不能修改指向为 c 对象。
在JAVA 当中,引用可以修改指向,jAVA 当中的 引用就可以类似 cur = cur->_left 这样写。
在递归当中可以实现 类似 node= node->_left 一样的效果:
因为,每一次调用函数都会创建一个新的栈帧出来,也就是说虽然看上去每一次递归调用函数之后,引用发生了变化,向后迭代了,但是其实下一层的引用和上一层的引用不是一个引用,每一次层递归都会创建一个新的引用。而这个新的引用指向下一个结点,也就相当于是向后迭代了。
完整代码:
bool insertR(K key)
{
return _insertR(_node , key);
}
private:
bool _insertR(Node*& node, K key)
{
if (node == nullptr)
{
node = new Node(key);
return true;
}
if (node->_key < key)
{
return _FindR(node->_right, key);
}
else if (node->_key < key)
{
return _FindR(node->_left, key);
}
else
{
return false;
}
}
二叉搜索树的查找
非递归
查找也是和插入一样的,遍历查找,当给的结果只有一个值的时候,在二叉搜索树当中的搜索路径只有一个,当最后找到结尾空的时候,说明这颗二叉搜索树当中没有这个值的结点。
这里我们简单实现,直接返回 bool 值:
bool Find(K key)
{
if (_node == nullptr)
{
return false;
}
Node* cur = _node;
if (cur->_key < key)
{
cur = cur->_right;
}
else if (cur->_key > key)
{
cur = cur->_right;
}
else
{
return true;
}
}
递归
bool FindR(K key)
{
return _FindR(_node, key);
}
private:
bool _FindR(Node* node, K key)
{
if (node == nullptr)
return false;
if (node->_key < key)
{
return _FindR(node->_right, key);
}
else if (node->_key < key)
{
return _FindR(node->_left, key);
}
else
{
return true;
}
}
删除
非递归
上述两种操作都简单,这里难在删除操作。
当我们要删除某一个结点的时候,这个结点有三种情况:没有孩子,只有一个孩子,有两个孩子。
当这个结点没有孩子,或者只有一个孩子的时候,都好办;
1.如果没有孩子,说明这个结点是叶子结点,那么我们直接 delete 这个结点,然后把 这个结点的父亲 对应的指向关系置空就行。
2.如果只有一个孩子,那么,只需要把这个孩子,从他父亲开始逐一比较大小,找到合适位置插入。注意,此时要删除的这个结点的孩子的孩子可能不止一个,也就是说要删除的这个结点的后面可能是一个子树,那么不用管,直接让这个子树跟着 这个 孩子结点一起寻找合适位置插入即可。(加单来说就是,要删除结点如果是在父亲的右边,就让 其孩子(子树)在父亲的右边;反之)
3.如果有两个孩子,这个时候就要使用替换法。也就是说,从整棵树当中寻找一个结点,能替代当前要删除这个结点的位置。在这颗树当中一定是有一个结点可以替代的。而找这种结点也是有规律的,一个搜索二叉树当中,要删除的这个结点位置的 左子树最大的结点 和 右子树最小的结点 这两个结点是一定可以替换的。所以我们就要找这两个结点。
对于左子树最大的结点,就在 要删除的这个结点位置的 左子树当中的最右边一个结点 就是最大结点(从要删除的结点位置的左子树根结点开始,往 _right 方向一致遍历,知道某一结点的 _right 指针为 nullptr ,那么这个结点就是最右边的结点):
而 右子树最小的结点,就是右子树当中 最左边的结点(从要删除的结点位置的右子树根结点开始,往 _left 方向一致遍历,知道某一结点的 _left 指针为 nullptr ,那么这个结点就是最左边的结点):
我们代码采用的方式是,找到替代值之后,把根结点的值和 替代结点 的值进行替换,然后在从根结点开始找到原来替代结点位置,删除该结点。
注意,当我们交换完 根结点和 替代结点之后,寻找 原本替代结点位置的时候,不能用递归 erase(删除结点操作函数,也就是现在我们正在写的函数)来递归寻找。有人就会想,既然 要删除的值已经在 erase()函数参数位置给出,那么直接调用 erase()函数就行了。
其实不是,比如上述图中的例子,要删除8,假设此时使用 7 来替代的,那么7就在根结点处,8就在原本 7 所在位置处,当递归调用 erase()函数寻找 8 的时候,因为根结点是 7 ,会想右子树当中去寻找,显然右子树当中是没有 8 的,此时就发生了错误。
在上述问题的基础之上,还引发出一个问题,如下图所示:
当我们删除了 3 这个结点之后,又出现之前说到的 一个孩子的情况,所以此时我们还是需要判断一下这种情况。所以此时,我们在给 之前父亲结点指针的初始值不能给 nullptr:
4.还有一种情况,如下图所示,当要删除的结点 是 整颗二叉搜索数的 根结点,且这颗二叉搜索树只有一颗子树的时候,我们上述说的三种情况都不能完成这一操作:
此时,就只能直接把 8 删除,让 10 作为这颗二叉搜索树 新的 根结点。
erase()函数全部代码:
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 // 找到了
{
// 左为空
if (cur->_left == nullptr)
{
if (cur == _root)
{
_root = cur->_right;
}
else
{
if (parent->_right == cur)
{
parent->_right = cur->_right;
}
else
{
parent->_left = cur->_right;
}
}
}// 右为空
else if (cur->_right == nullptr)
{
if (cur == _root)
{
_root = cur->_left;
}
else
{
if (parent->_right == cur)
{
parent->_right = cur->_left;
}
else
{
parent->_left = cur->_left;
}
}
} // 左右都不为空
else
{
// 找替代节点
Node* parent = cur; // 不能给 nullptr
Node* leftMax = cur->_left;
while (leftMax->_right)
{
parent = leftMax;
leftMax = leftMax->_right;
}
swap(cur->_key, leftMax->_key);
if (parent->_left == leftMax)
{
parent->_left = leftMax->_left;
}
else
{
parent->_right = leftMax->_left;
}
cur = leftMax;
}
delete cur;
return true;
}
}
return false;
}
递归
递归实现在要删除结点左右孩子都存在的时候,在事项上要简单一些,在寻找替代结点,和删除结点进行值交换之后,可以直接调用递归(erase())来把key值的结点删除掉(此时key值结点已经被交换到叶子结点处)。
而,在非递归当中不能递归调用erase()函数因为,调用的话,会直接错过要删除结点所在子树;而递归当中就不会,为递归当中传入的指针参数是 以 引用的方式传入的,直接传入的就是上一个结点指向这个结点的别名(也就是现在结点的父亲结点直线该结点的指针别名)。
在非递归当中,把替换结点个要删除的结点进行交换之后,这整棵树就不再是 二叉搜索树了;但是递归当中,替换之后,递归是在左子树(或者是右子树)当中进行寻找,而此时左子树还是二叉搜索树,所以可以进行寻找。
两个孩子都有的代码部分:
Node* leftMax = root->_left;
while (leftMax->_right)
{
leftMax = leftMax->_right;
}
swap(root->_key, leftMax->_key);
return _EraseR(root->_left, key);
完整代码:
bool EraseR(const K& key)
{
_EraseR(_node, key);
}
private:
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;
// 1、左为空
// 2、右为空
// 3、左右都不为空
if (root->_left == nullptr)
{
root = root->_right;
}
else if (root->_right == nullptr)
{
root = root->_left;
}
else
{
Node* leftMax = root->_left;
while (leftMax->_right)
{
leftMax = leftMax->_right;
}
swap(root->_key, leftMax->_key);
return _EraseR(root->_left, key);
}
delete del;
return true;
}
}
还需要注意的点是,在最后递归调用erase()函数时候,虽然 root->left 和 LeftMax 两个指针在传参的时候之后,看似实现效果是一样,但是传参的时候不能用 LeftMax 作为参数:
如下述情况就不行了:
当你要删除 8 这个结点 ,那么 3 就是左子树当中的最大结点,如果传入的是 LeftMax ,下一层递归erase()函数当中的 root 就是 LeftMax,按照我们上述实现的删除逻辑,就乱套了。
删除整个树
删除就要编译整个树,要删除的话,采用后序来遍历是最好的,也就是从叶子结点开始往前面删,保证删除每一个结点,关系不会乱套。
而且,我们在选择传入参数的时候,可以采用结点指针引用的方式,这种方式相当于是二级指针的效果,但是引用用起来要比二级指针要好用很多。
void Destroy(Node*& root)
{
if (root == nullptr)
return;
Destroy(root->_left);
Destroy(root->_right);
delete root;
// 因为root 是引用,这里修改可以直接修改到 树当中的指针指向位置
root = nullptr;
}
Destroy 函数可以直接用于 二叉搜索树的析构函数当中:
~BSTree()
{
Destroy(_node);
}
拷贝构造函数(深拷贝)
因为是另开空间,对于拷贝,还是要进行深拷贝,不然程序就会奔溃。
树的深拷贝的话,我们采用前序遍历的方式来创建新树和遍历老树。在旧树访问的同时,创建新树当中的结点。
BSTree(const BSTree<K>& t)
{
_root = Copy(t._root);
}
private:
Node* Copy(Node* root)
{
// 递归终止条件,也就是构建叶子结点的左右根指针
if (root == nullptr)
return nullptr;
// 前序遍历,先创建根结点,在构建这个根结点左子树和右子树
Node* newNode = new Node(root->_key);
Node* _left = Copy(root->_left);
Node* _right = Copy(root->_right);
// 最后返回根结点指针
return newNode;
}
赋值重载运算符
这里采用简单的现代写法,叫编译器帮我们利用拷贝构造函数来构造出临时对象,然后我们只需要交换根结点指针,把当前空对象不要的指针和指针维护的空间交给编译器管理的临时对象来自动调用析构函数帮我们销毁,而我们就使用临时对象构建的数:
BSTree<K>& operator=(BSTree<K> t)
{
swap(_root, t._root); // 交换
return *this; // 返回当前对象
}
完整代码参考:
#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
{
public:
typedef BSTreeNode<K> Node;
BSTree()
:_node(nullptr)
{
}
BSTree(const BSTree<K>& t)
{
_root = Copy(t._root);
}
BSTree<K>& operator=(BSTree<K> t)
{
swap(_root, t._root); // 交换
return *this; // 返回当前对象
}
~BSTree()
{
Destroy(_node);
}
void inOrder()
{
_inOrder(_node);
}
bool Find(K key)
{
if (_node == nullptr)
{
return false;
}
Node* cur = _node;
if (cur->_key < key)
{
cur = cur->_right;
}
else if (cur->_key > key)
{
cur = cur->_right;
}
else
{
return true;
}
}
bool FindR(K key)
{
return _FindR(_node, key);
}
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 // 找到了
{
// 左为空
if (cur->_left == nullptr)
{
if (cur == _root)
{
_root = cur->_right;
}
else
{
if (parent->_right == cur)
{
parent->_right = cur->_right;
}
else
{
parent->_left = cur->_right;
}
}
}// 右为空
else if (cur->_right == nullptr)
{
if (cur == _root)
{
_root = cur->_left;
}
else
{
if (parent->_right == cur)
{
parent->_right = cur->_left;
}
else
{
parent->_left = cur->_left;
}
}
} // 左右都不为空
else
{
// 找替代节点
Node* parent = cur; // 不能给 nullptr
Node* leftMax = cur->_left;
while (leftMax->_right)
{
parent = leftMax;
leftMax = leftMax->_right;
}
swap(cur->_key, leftMax->_key);
if (parent->_left == leftMax)
{
parent->_left = leftMax->_left;
}
else
{
parent->_right = leftMax->_left;
}
cur = leftMax;
}
delete cur;
return true;
}
}
return false;
}
bool insert(const K& key)
{
// 首先判断 此时是不是一个空树
if (_node == nullptr)
{
_node = new Node(key); // 是就直接 new 一个空间直接给给头结点指针
return true;
}
Node* cur = _node; // 作为循环迭代指针
Node* perant = _node; // 方便查找 cur 的父亲指针
// 循环 找到合适地方插入 key 结点
while (cur)
{
// 判断 要插入结点的值大小
if (cur->_key < key)
{
perant = cur;
cur = cur->_right;
}
else if (cur->_key > key)
{
perant = cur;
cur = cur->_left;
}
else // 此时相等,不能插入
{
return false;
}
}
// 这里做 开空间,插入结点的操作
// 这里采用 再一次判断 要插入结点的值大小 的方式确认插入那一边
if (perant->_key < key)
{
perant->_right = new Node(key);
return true;
}
else
{
perant->_left = new Node(key);
return true;
}
}
bool insertR(K key)
{
return _insertR(_node , key);
}
bool EraseR(const K& key)
{
_EraseR(_node, key);
}
private:
void _inOrder(Node* cur)
{
if (cur == NULL)
{
return;
}
_inOrder(cur->_left);
cout << cur->_key << " ";
_inOrder(cur->_right);
}
Node* Copy(Node* root)
{
// 递归终止条件,也就是构建叶子结点的左右根指针
if (root == nullptr)
return nullptr;
// 前序遍历,先创建根结点,在构建这个根结点左子树和右子树
Node* newNode = new Node(root->_key);
Node* _left = Copy(root->_left);
Node* _right = Copy(root->_right);
// 最后返回根结点指针
return newNode;
}
bool _FindR(Node* node, K key)
{
if (node == nullptr)
return false;
if (node->_key < key)
{
return _FindR(node->_right, key);
}
else if (node->_key < key)
{
return _FindR(node->_left, key);
}
else
{
return true;
}
}
bool _insertR(Node*& node, K key)
{
if (node == nullptr)
{
node = new Node(key);
return true;
}
if (node->_key < key)
{
return _FindR(node->_right, key);
}
else if (node->_key < key)
{
return _FindR(node->_left, key);
}
else
{
return false;
}
}
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;
// 1、左为空
if (root->_left == nullptr)
{
root = root->_right;
}
// 2、右为空
else if (root->_right == nullptr)
{
root = root->_left;
}
// 3、左右都不为空
else
{
Node* leftMax = root->_left;
while (leftMax->_right)
{
leftMax = leftMax->_right;
}
swap(root->_key, leftMax->_key);
return _EraseR(root->_left, key);
}
delete del;
return true;
}
}
void Destroy(Node*& root)
{
if (root == nullptr)
return;
Destroy(root->_left);
Destroy(root->_right);
delete root;
// 因为root 是引用,这里修改可以直接修改到 树当中的指针指向位置
root = nullptr;
}
private:
Node* _node;
};
二叉搜索树的应用
1. K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到
的值。
比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:
以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。
2. KV模型:每一个关键码key,都有与之对应的值Value,即<Key, Value>的键值对。该种方
式在现实生活中非常常见:
比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英
文单词与其对应的中文<word, chinese>就构成一种键值对;
再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出
现次数就是<word, count>就构成一种键值对.
下述就是在上述二叉搜索树实现之后,对这个数据结构的运用:
// 改造二叉搜索树为KV结构
template<class K, class V>
struct BSTNode
{
BSTNode(const K& key = K(), const V& value = V())
: _pLeft(nullptr), _pRight(nullptr), _key(key), _Value(value)
{}
BSTNode<T>* _pLeft;
BSTNode<T>* _pRight;
K _key;
V _value
};
template<class K, class V>
class BSTree
{
typedef BSTNode<K, V> Node;
typedef Node* PNode;
public:
BSTree() : _pRoot(nullptr) {}
PNode Find(const K& key);
bool Insert(const K& key, const V& value)
bool Erase(const K& key)
private:
PNode _pRoot;
};
void TestBSTree3()
{
// 输入单词,查找单词对应的中文翻译
BSTree<string, string> dict;
dict.Insert("string", "字符串");
dict.Insert("tree", "树");
dict.Insert("left", "左边、剩余");
dict.Insert("right", "右边");
dict.Insert("sort", "排序");
// 插入词库中所有单词
string str;
while (cin >> str)
{
BSTreeNode<string, string>* ret = dict.Find(str);
if (ret == nullptr)
{
cout << "单词拼写错误,词库中没有这个单词:" << str << endl;
}
else
{
cout << str << "中文翻译:" << ret->_value << endl;
}
}
}
void TestBSTree4()
{
// 统计水果出现的次数
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
"苹果", "香蕉", "苹果", "香蕉" };
BSTree<string, int> countTree;
for (const auto& str : arr)
{
// 先查找水果在不在搜索树中
// 1、不在,说明水果第一次出现,则插入<水果, 1>
// 2、在,则查找到的节点中水果对应的次数++
//BSTreeNode<string, int>* ret = countTree.Find(str);
auto ret = countTree.Find(str);
if (ret == NULL)
{
countTree.Insert(str, 1);
}
else
{
ret->_value++;
}
}
countTree.InOrder();
}
我们看到,二叉搜索树这个数据模型还是有很多作用的,在将来我们需要使用二叉搜索树模型的时候,不需要自己去手搓一个二叉搜索树,一是自己难写,而是我们写出来了二叉搜索树可能有问题,性能也不好;库当中有现成的,比如 set , map。set 就是 key 模型,map 是 key - value模型。