目录
一. 概念
二叉搜索树又称二叉排序树,它是一棵空树,或者是具有以下性质的二叉树:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树
如下图,就是一颗二叉搜索树:
二. 二叉搜索树的操作
1. 二叉搜索树的查找
查找的步骤如下:
若根节点不为空:
如果根节点key==查找的key 返回true;
如果根节点key > 查找key,在其左子树查找;
如果根节点key < 查找key,在其右子树查找;
否则,返回false
如上图和步骤:
当我们要查找1时:
先遍历根节点,发现根节点大于1,我们要去左子树寻找,走到3,大于1,继续去左子树寻找,走到1,等于要查找的值,返回true
当我们要查找8时:
先遍历根节点,发现根节点小于8,我们要去右子树寻找,走到7,小于8,继续去右子树寻找,走到8,等于要查找的值,返回true
当我们要查找10时:
先遍历根节点,发现根节点小于10,我们要去右子树寻找,走到7,小于10,继续去右子树寻找,走到8,小于10,继续去右子树寻找,走到9,小于10,继续去右子树寻找,走到nullptr,没有找到,返回false
代码实现如下:
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;
}
2. 二叉搜索树的插入
a. 树为空,则直接插入
如果是空树,直接插入,然后返回true
b. 树不空,按二叉搜索树性质查找插入位置,插入新节点
当我们要插入10时,按照二叉搜索树的性质,查找到插入结点的位置:
路径如下图:
需要注意的是由于我们需要将新结点链接到树上,所以需要维护一个parent的指针去记录cur上一个位置,方便我们将新节点插入到树上
代码如下:
//插入,如果插入成功返回true否则false
bool insert(const K& key)
{
//空树new一个值
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
//cur遍历,parent存储上一个结点的位置
Node* cur = _root;
Node* parent = cur;
//遍历树,找到key值的位置
while (cur)
{
//更新parent的位置
parent = cur;
//key值更大往右走
if (cur->_Key < key)
cur = cur->_right;
//key值更小往左走
else if (cur->_Key > key)
cur = cur->_left;
//如果遇到一样的则不插入,返回false
else
return false;//如果是一样的则不需要插入
}
//跳出循环说明找到了,new一个结点给cur,同时链接到树上
cur = new Node(key);
//key值更大插入到右边
if (parent->_Key < key)
parent->_right = cur;
//否则左边
else
parent->_left = cur;
//走到这说明插入成功了,返回true
return true;
}
3. 二叉搜索树的删除(难点)
首先查找元素是否在二叉搜索树中,如果不存在,则返回, 否则要删除的结点可能分下面四种情况:
- 要删除的结点无孩子结点
- 要删除的结点只有左孩子结点
- 要删除的结点只有右孩子结点
- 要删除的结点有左、右孩子结点
看起来有待删除节点有4中情况,实际情况a可以与情况b或者c合并起来,因此真正的删除过程如下:
- 删除该结点且使被删除节点的双亲结点指向被删除节点的左孩子结点
- 删除该结点且使被删除节点的双亲结点指向被删除结点的右孩子结点
- 在它的右子树中寻找中序下的第一个结点(key最小),用它的值填补到被删除节点中, 再来处理该结点的删除问题
按照上的情况写出下面的代码:
bool erase(const K& key)
{
if (_root == nullptr)
return false;
Node* cur = _root;//使用cur遍历二叉树
Node* parent = nullptr;
//遍历二叉树
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
{
//当cur是parent的左子节点时
if (cur == parent->_left)
{
parent->_left = cur->_right;
}
//当cur是parent的右子节点时
else
{
parent->_right = cur->_right;
}
}
//删除值
delete cur;
}
//当被删除结点的右子树为空
else if (cur->_right == nullptr)
{
//当要删除的结点是根节点时
if (cur == _root)
{
_root = cur->_left;
}
else
{
//当cur是parent的左子节点时
if (cur == parent->_left)
{
parent->_left = cur->_left;
}
//当cur是parent的右子节点时
else
{
parent->_right = cur->_left;
}
}
//删除值
delete cur;
}
//当被删除结点的左子树和右子树都不为空时,替换法
else
{
//找cur右子树中最左值(即最小值)
Node* minParent = cur;//不能给nullptr
Node* minRight = cur->_right;
while (minRight->_left)
{
minParent = minRight;
minRight = minRight->_left;
}
//将cur和minRight的值交换一下
swap(minRight->_Key, cur->_Key);
//将minRight下面结点链接到minParent上,即把nullptr链接上
//如果minRight是minParent的左子树,则赋值给左子树
if (minRight = minParent->_left)
{
minParent->_left = minRight->_right;
}
//否则赋值给右子树
else
{
minParent->_right = minRight->_right;
}
//最后将minRight销毁
delete minRight;
}
//删除后返回true
return true;
}
}
//走出循环说明没有该值,无需删除
return false;
}
解释:进来先判断树是否为空,为空直接返回false,然后就是查找这颗树,看是否是有符合key值的结点,这里和查找类似,需要注意的是维护parent来标记cur的父节点,删除后是需要将后续结点给链接回树上,如果找不到就返回false否则进入else中,这个时候删除的情况就比较复杂了。
当被删除结点的左子树为空时,如下图我们想要删除10,那么我们就需要让cur的右子树链接到8
需要注意的是,当cur是根节点的情况:
这种情况需要特别处理,否则链接时会访问nullptr,因为此时的parent是nullptr,如果不特殊处理,在链接时,让parent的left(right)去链接右子树时会访问parent,而由于这种情况parent是nullptr,所以需要特判
当右子树为空时同理,也需要特判删除的为根节点时。
当被删除的结点左右子节点都不为空时,我们需要使用替代法,上面介绍的是一种替代的方式,事实上替代方法有两种,一种是找左子树的最大值结点,将其的值和被删除结点的值交换,另一种是找右子树的最小值点,将其的值和被删除结点的值交换,这里采用的是找右子树的最小值节点进行交换。
正常情况如下图:
我们要删除3,那么我们就去寻找以3为根节点右子树中的最小值节点,即4,我们将其和3交换并删除minRigth所在节点,需要注意的是需要维护一个minParent为了记录minRight的父节点方便将后续节点链接上,事实上这里要链接上的也就是nullptr,所以链接时,取minRight的左节点还是右节点都是可以的。
特殊情况:
需要将minParent被赋值cur,即被删除的节点,因为,如下情况会访问nullptr:
当删除的节点为根节点时,如果minParent被初始化为nullptr,由于minRight被赋值为cur的right,而minRight的left是nullptr,就不会进入循环,那么此时minParent就会为nullptr,而下面为了链接后续的minRight后续的节点即nullptr会访问minParent的left或者right而导致访问了nullptr
4. 二叉树的析构函数和构造函数
析构函数如下:
//析构函数,通过调用销毁节点函数递归实现
~BinarySearchTree()
{
if (_root)
Destroy(_root);
_root = nullptr;
}
//销毁节点函数,后续遍历销毁节点
void Destroy(Node* root)
{
if (root == nullptr)
return;
Destroy(root->_left);
Destroy(root->_right);
delete root;
}
当想要销毁一颗二叉搜索树,是从节点一个个删除的,但是析构函数是没有参数的,也就意味着如果想要销毁一颗二叉搜索树,我们需要使用额外的函数,即销毁节点的函数,销毁节点在二叉树已讲,这里使用后序遍历销毁节点和二叉树后序遍历销毁同理,注:这里的销毁节点函数被设为私有更合理,毕竟我们只希望这个类内使用
拷贝构造函数如下:
//拷贝构造函数,只能用传统写法,通过调用创建节点函数递归实现
BinarySearchTree(const BinarySearchTree<K>& tree)
{
_root = CopyBinarySearchTreeNode(tree._root);
}
//拷贝节点函数,前序遍历创建节点
Node* CopyBinarySearchTreeNode(Node* root)
{
if (root == nullptr)
return nullptr;
Node* copyNode = new Node(root->_Key);
copyNode->_left = CopyBinarySearchTreeNode(root->_left);
copyNode->_right = CopyBinarySearchTreeNode(root->_right);
return copyNode;
}
这里和析构函数类似,我们拷贝的时候是一个个节点拷贝的,这里需要额外函数是因为传过来的肯定是一个棵树而不是树的结点,所以需要我们自己去写一个额外的结点拷贝函数,因为是从无到有创建一颗树,需要把结点链接起来,所以这里需要采用前序遍历的方式进行创建结点并链接
赋值操作符的重载:
//赋值重载,利用现代写法,只需将根节点交换即可
BinarySearchTree<K>& operator=(const BinarySearchTree<K> tree)
{
swap(_root, tree->_root);
return *this;
}
这里采用的是现代写法,拷贝构造是形势所迫,只能使用传统写法,无法使用现代写法,但是赋值可以(因为拷贝构造已经实现)经过传参后tree已经是被拷贝为和赋值操作符右操作数一样的树,只需要将tree的根节点和_root点进行交换即可,最后返回被赋值的对象(*this)即可
构造函数:
//c++11的语法,强制生成默认的构造函数
BinarySearchTree() = default;
我们可以使用c++11中的default,强制生成默认的构造函数,因为默认生成的拷贝构造即可处理_root
三. 二叉搜索树完整代码
#pragma once
#include <iostream>
using namespace std;
/*
Key值二叉搜索树只实现增删查
不实现改,因为改会破坏二叉搜索树的结构
*/
//创建节点类
template<class K>
struct BinarySearchTreeNode
{
K _Key;
BinarySearchTreeNode<K>* _left;
BinarySearchTreeNode<K>* _right;
//需要自己实现构造函数构造节点
BinarySearchTreeNode(const K& key)
:_Key(key)
,_left(nullptr)
,_right(nullptr)
{}
};
template<class K>
class BinarySearchTree
{
private:
//将节点类重命名,方便使用
typedef BinarySearchTreeNode<K> Node;
public:
//c++11的语法,强制生成默认的构造函数
BinarySearchTree() = default;
//拷贝构造函数,只能用传统写法,通过调用创建节点函数递归实现
BinarySearchTree(const BinarySearchTree<K>& tree)
{
_root = CopyBinarySearchTreeNode(tree._root);
}
//析构函数,通过调用销毁节点函数递归实现
~BinarySearchTree()
{
if (_root)
Destroy(_root);
_root = nullptr;
}
//赋值重载,利用现代写法,只需将根节点交换即可
BinarySearchTree<K>& operator=(const BinarySearchTree<K> tree)
{
swap(_root, tree->_root);
return *this;
}
//插入,如果插入成功返回true否则false
bool insert(const K& key)
{
//空树new一个值
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
//cur遍历,parent存储上一个结点的位置
Node* cur = _root;
Node* parent = cur;
//遍历树,找到key值的位置
while (cur)
{
//更新parent的位置
parent = cur;
//key值更大往右走
if (cur->_Key < key)
cur = cur->_right;
//key值更小往左走
else if (cur->_Key > key)
cur = cur->_left;
//如果遇到一样的则不插入,返回false
else
return false;//如果是一样的则不需要插入
}
//跳出循环说明找到了,new一个结点给cur,同时链接到树上
cur = new Node(key);
//key值更大插入到右边
if (parent->_Key < key)
parent->_right = cur;
//否则左边
else
parent->_left = cur;
//走到这说明插入成功了,返回true
return true;
}
//const Node* finf(const K& key)//如果想要返回结点需要加const因为结点不能被改变,否则结构会发生改变
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 erase(const K& key)
{
if (_root == nullptr)
return false;
Node* cur = _root;//使用cur遍历二叉树
Node* parent = nullptr;
//遍历二叉树
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
{
//当cur是parent的左子节点时
if (cur == parent->_left)
{
parent->_left = cur->_right;
}
//当cur是parent的右子节点时
else
{
parent->_right = cur->_right;
}
}
//删除值
delete cur;
}
//当被删除结点的右子树为空
else if (cur->_right == nullptr)
{
//当要删除的结点是根节点时
if (cur == _root)
{
_root = cur->_left;
}
else
{
//当cur是parent的左子节点时
if (cur == parent->_left)
{
parent->_left = cur->_left;
}
//当cur是parent的右子节点时
else
{
parent->_right = cur->_left;
}
}
//删除值
delete cur;
}
//当被删除结点的左子树和右子树都不为空时,替换法
else
{
//找cur右子树中最左值(即最小值)
Node* minParent = cur;//不能给nullptr,否则会可能会访问到nullptr
Node* minRight = cur->_right;
while (minRight->_left)
{
minParent = minRight;
minRight = minRight->_left;
}
//将cur和minRight的值交换一下
swap(minRight->_Key, cur->_Key);
//将minRight下面结点链接到minParent上,即把nullptr链接上
//如果minRight是minParent的左子树,则赋值给左子树
if (minRight = minParent->_left)
{
minParent->_left = minRight->_right;
}
//否则赋值给右子树
else
{
minParent->_right = minRight->_right;
}
//最后将minRight销毁
delete minRight;
}
//删除后返回true
return true;
}
}
//走出循环说明没有该值,无需删除
return false;
}
//由于需要用到成员变量_root,所以选择再包装一层,让类外调用这个
void inoder()
{
_inoder(_root);
}
private:
//销毁节点函数,后续遍历销毁节点
void Destroy(Node* root)
{
if (root == nullptr)
return;
Destroy(root->_left);
Destroy(root->_right);
delete root;
}
//拷贝节点函数,前序遍历创建节点
Node* CopyBinarySearchTreeNode(Node* root)
{
if (root == nullptr)
return nullptr;
Node* copyNode = new Node(root->_Key);
copyNode->_left = CopyBinarySearchTreeNode(root->_left);
copyNode->_right = CopyBinarySearchTreeNode(root->_right);
return copyNode;
}
//中序遍历函数,搜索二叉树的中序遍历是有序的
void _inoder(Node* root)
{
if (root == nullptr)
return;
_inoder(root->_left);
cout << root->_Key << " ";
_inoder(root->_right);
}
private:
Node* _root = nullptr;
};
为了方便遍历还实现了中序遍历,这里的模板参数选择K是为了对应Key值
四. 二叉搜索树的性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二 叉搜索树的深度的函数,即结点越深,则比较次数越多。 但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
从上图我们会发现:
最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为:logN
最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为:N/2
所以,普通的二叉搜索树的时间复杂度应该是O(N)
如果退化成单支树,二叉搜索树的性能就失去了。那么就需要进行改进,不论按照什么次序插 入关键码,二叉搜索树的性能都能达到最优,那么就需要使用到AVL树和红黑树了。
五. 二叉搜索树的应用
1. K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。
比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:
- 以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树
- 在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。
2. KV模型:每一个关键码key,都有与之对应的值Value,即<Key, Value>的键值对。该种方式在现实生活中非常常见:
- 比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文就构成一种键值对;
- 再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是就构成一种键值对。
由于这里主要优先理解K模型,这里只给出KV模型的例子,不过多讨论,以下是KV模型的一个例子:
// 改造二叉搜索树为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();
}