⭐前言:学习二叉搜索树,是我们学好map和set的前提,因为二叉搜索树是map和set的特性。因此本篇文章意在努力将二叉搜索树相关的内容较完善地写出来!
二叉搜索树的介绍
二叉搜索树(BST, Binary Search Tree)又叫做二叉排序树,它可以是一颗空树,其性质如下:
①若它的左子树不为空,则左子树上所有的节点的值都小于根节点的值
②若它的右子树不为空,则右子树上所有的节点的值都大于根节点的值
③它的左右子树也分是二叉搜索树
④成形的二叉搜索树一般不能用来修改数据。因为一旦修改了,就可能不再是二叉搜索树了。
对二叉搜索树的操作
1. 查找:
①一般查找某个数据,就先根节点的值比较,若是查找的数据val小于根节点,那就去左子树找,val大于根节点的值,那就去右子树找。
②最多查找高度次,走到到空,还没找到,这个值不存在。
2.插入:
①如果是一颗空树,那么直接新增节点,赋值给root指针
②不是空树,则按二叉搜索树的性质插入数据
3.删除:
删除的情况就比较复杂,我们慢慢来看。
首先查找元素是否在二叉搜索树中,如果不存在,则返回, 否则要删除的结点可能分下面四种情况:
①要删除的节点无孩子结点。
操作:这种情况比较简单,删除该节点后,让该节点的父亲节点指向该节点的左孩子节点或右孩子节点。都可以,因为都是空指针。
②要删除的节点只有左孩子节点.
操作:删除该节点后,让该节点的父亲节点指向该节点的左孩子节点
③要删除的节点只有右孩子节点
操作:删除该节点收,让该节点的父亲节点指向该节点的右孩子节点
④要删除的节点有左右孩子。
第一种操作是替换法。
根据二叉搜索树的性质,右子树的值 > 根节点的值 > 左子树的值。因此,替换的意思是,重新选根,那么我们可以从左子树中选最大的那个来当新根,也可以从右子树中选最小的那个来当新根。这里我们使用右子树最小的那个来当新根。
当替换之后,我们就将这个节点删除,删除的时候需要注意,让其父亲节点指向它的右孩子,因为它可能还会带有右孩子。不可能是左孩子,如果是左孩子,那么寻找新根的行为还会往下,会找到最左,即最小的那个节点。
第二种操作是:使用交换法。将右子树最小的值,跟需要删除的节点的值交换。这也在删除之后就不会毁掉二叉搜索树的结构。然后转化到在右子树上删除节点。不过这种方法需要用到递归。
二叉搜索树的实现
这一步主要实现的是上面所提到的相关操作。
①先来创建节点。
template<class K>
struct BSTreeNode
{
BSTreeNode<K>* _left;
BSTreeNode<K>* _right;
K _key;
BSTreeNode(const K& key)
:_key(key)
,_left(nullptr)
,_right(nullptr)
{}
};
然后是框架的准备:
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node;
private:
Node* _root = nullptr;
};
}
②插入操作
非递归版:
先判断是否为空树,如果是,直接插入。如果不是空树,那么需要两个指针,一个是cur指针,用来找插入的位置,一个是parent指针,用来记录父节点,因为后面需要重新链接。
当发现树中已经有了相同的值,那么就直接返回false。
在链接新节点的时候,需要再重新判断一下这个节点的值是否大于父节点的值。
//非递归插入
bool Insert(const K& key)
{
//1.先判断是否为空树,如果是,直接插入
if (_root == nullptr)
{
_root = new Node(key);
return true;
}
//2.不是空树
Node* cur = _root;
Node* parent = cur;//用于记录父节点,删除后需要链接被删除节点的孩子节点
//开始找可以插入数据的位置
while (cur)
{
if (cur->_key < key)//插入的值比较大,则去右子树找
{
parent = cur;
cur = cur->_right;
}
else if (cur->_key > key)//插入的值比较小,则去左子树找
{
parent = cur;
cur = cur->_left;
}
else //如果发现已经有这个值了,那么直接返回false;
{
return false;
}
}
//找到可以插入的位置后
cur = new Node(key);
//链接操作,由于此时不知道cur来到的位置是左孩子还是右孩子,因此还需要再判断一次
if (parent->_key < key)
{
parent->_right = cur;
}
else
{
parent->_left = cur;
}
return true;
}
递归版:
由于递归,需要用到私有成员变量_root,但私有成员变量不能暴露 出来。因此,这里选择私有插入操作的函数,在公有中额外写一个插入的函数用于调用它。
这里的递归,我认为它是一个点睛之笔,那就是Node*& root。对传入的节点的指针进行了引用!
对传入的节点的指针进行引用,意味着此时的root,是上一个root的左孩子或右孩子的别名,当此时的root到达了空指针,进行插入数据的操作后,就意味着是上一个root的左孩子或右孩子进行了插入数据!就不需要记录父节点来链接了,因为在插入数据的时候就已经链接成功了!
public:
bool InsertR(const K& key)
{
return _InsertR(_root, key);
}
private:
//注意这里的引用,这是这里的递归操作的点睛之笔
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;
}
③删除操作
非递归版本
非递归的版本代码量比递归版本的多。
代码的思路是:①先找到要删除的节点。②然后判断这个要被删除的节点是如何的,是只有左子树,还是只有右子树,还是左右孩子都有。另外,叶子节点被包含在了前两个情况里面。③如果是左右孩子都有的节点,那么还需要找以这个被删除的节点为根,找它右子树的最小值,然后让这个值跟被删除的节点的值替换,此时在逻辑上,被替换掉的不仅仅是值,连要删除的节点也被替换掉了,被替换成这个右子树的最小值的节点了。④接着在链接的时候,需要判断被删除的节点是父节点的左孩子还是右孩子。
需要注意的额外情况是:被删除的节点可能是根节点。
//非递归
bool Erase(const K& key)
{
//用父节点来记录被删的节点的父节点
Node* parent = nullptr;
Node* cur = _root;
//先找要删除的节点
while (cur)
{
if (cur->_key < key)
{
parent = cur;
cut = cur->_right;
}
else if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
else //找到了
{
//1.要删除的节点的左子树为空
//2.右子树为空
//3.左右都不为空,替换删除
//1.左子树为空--让父节点链接要删除节点的右子树
if (cur->_left == nullptr)
{
//还要判断一下要删除的节点是否为根节点
if (cur == root)
{
_root = cur->_right;
}
else //删除的节点不是根节点
{
//判断一下要删除的节点是父节点的左孩子还是右孩子
//如果是左孩子,那么就让父节点的左孩子指针链接被删除节点的右子树
if (parent->_left == cur)
{
parent->_left = cur->_right;
}
else//如果是右孩子,那么就让父节点的右孩子指针链接被删除节点的右子树
{
parent->_right = cur->_right;
}
}
delete cur;//最后删除节点
}
else if(cur->_right==nullptr)//右子树为空----让父节点链接被删除节点的左子树
{
if (root == cur)
{
_root = cur->_left;
}
else
{
if (parent->_left == cur)
{
parent->left = cur->_left;
}
else
{
parent->_right = cur->_left;
}
}
delete cur;
}
else//3.左右孩子都不为空---替换法
{
//要找到右子树的最小节点
Node* parent = cur;
Node* minRight = cur->_right;
while (minRight->_left)
{
parent = minRight;
minRight = minRight->_left;
}
//将根节点的值替换掉
cur->_key = minRight->_key;
//然后删除这个最小的节点
//如果它是父节点的左孩子,那就让父节点的左孩子的指针指向它的右孩子
//因为它已经是最左的那个了,没有左孩子了,可能会有右孩子
if (minRight == parent->_left)
{
parent->_left = minRight->_right;
}
else//右子树的最小节点,不一定是最左的节点。
{
parent->_right = minRight->_right;
}
delete minRight;
}
return true;
}
}
return false;
}
递归的版本:
代码的思路是:①需要先判断树是否为空。②找要删除的节点。③找到后,判断这个节点是那种情况,然后进行链接。如果是左右都有孩子的节点,那么就使用交换法,让右子树最小值跟要删除的节点的值交换,此时原本要删除的那个节点,从物理上变成了原本的右子树的最小值的节点。然后通过递归,去右子树找这个节点。最后进行链接节点的右孩子或左孩子,最后删除。
public:
bool EraseR(const K& key)
{
return _EraseR(_root, key);
}
private:
bool _EraseR(Node*& root, const K& key)
{
if (root == nullptr)
{
return false;
}
if (root->_key < key)
{
return _Erase(root->_right, key);
}
else if (root->_key > key)
{
return _Erase(root->_left, key);
}
else
{
Node* del = root;
if (root->_right == nullptr)
{
root = root->_left;
}
else if (root->_left == nullptr)
{
root = root->_right;
}
else//左右孩子都不为空---交换法
{
Node* minRight = root->_right;
while (minRight->_left)
{
minRight->_left;
}
//交换值
swap(root->_key, minRight->_key);
//交换后,此时原本要是删除的key,去到了minRight的节点上
//接下来进行递归,交给右子树去找这个节点
return _EraseR(root->_right, key);
}
delete del;
return true;
}
}
④查找
查找的操作很简单。
非递归版本:
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;
}
递归版本:
public:
bool FindR(const K& key)
{
return _FindR(_root, key);
}
private:
bool _FindR(Node* root, const K& key)
{
if (root == nullptr)
{
return false;
}
if (root->_key < key)
return _FindR(root->_right, key);
if (root->_key > key)
return _FindR(root->_left, key);
else
return true;
}
⑤.中序遍历
中序遍历的顺序是左根右。学过简单数据结构中的二叉树的话,这里就洒洒水啦!
public:
void InOrder()
{
_InOrder(_root);
cout << endl;
}
private:
void _InOrder(Node* root)
{
if (root == nullptr)
return;
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
⑥构造函数
不需要写什么构造函数,只需用初始化列表初始化一下私有成员变量_root即可。
BSTree()
:_root(nullptr)
{}
⑦拷贝构造
写一个Copy函数,返回值是Node*,也就是返回节点的指针。先创建一个新的节点,赋值为拷贝目标的根的值,然后让其左孩子和右孩子递归,链接下一个值。
public:
BSTree(const BSTree<K>& t)
{
_root = Copy(t._root);
}
private:
Node* Copy(Node* root)
{
if (root == nullptr)
return nullptr;
Node* newRoot = new Node(root->_key);
newRoot->_left = Copy(root->_left);
newRoot->_right = Copy(root->_right);
return newRoot;
}
⑧赋值
operator=()函数的话,不要传引用,然后交换一下_root即可。注意这里有些隐藏条件:this指针的_root必须初始化成空指针。
BSTree<K>& operator=(BSTree<K> t)
{
swap(_root, t._root);
return *this;
}
⑨析构函数
public:
~BSTree()
{
Destory(_root);
_root = nullptr;
}
private:
void Destory(Node* root)
{
if (root == nullptr)
return;
Destory(root->_left);
Destory(root->_right);
delete root;
}
二叉搜索树的应用
1.K模型
K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。
比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:
用若干个单词(字典)组成一棵二叉搜索树, 然后在里面查找单词word,找到返回true,找不到返回false;
2.KV模型
KV模型:每一个关键码key,都有与之对应的值Value,即<Key, Value>的键值对。
只需在上面的代码的基础上,加上一个_value即可。
namespace KV
{
template<class K, class V>
struct BSTreeNode
{
BSTreeNode<K, T>* _left;
BSTreeNode<K, T>* _right;
K _key;
V _value;
BSTreeNode(const K& key, const V& value)
:_key(key)
, _value(value)
, _left(nullptr)
, _right(nullptr)
{}
};
template<class K, class V>
class BSTree
{
typedef BSTreeNode<K, V> Node;
public:
bool Insert(const K& key, const V& value)
{
if (_root == nullptr)
{
_root = new Node(key, value);
return true;
}
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
{
return false;
}
}
cur = new Node(key, value);
if (parent->_key < key)
{
parent->_right = cur;
}
else
{
parent->_left = cur;
}
return true;
}
Node* 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 cur;
}
}
return nullptr;
}
void Inorder()
{
_Inorder(_root);
}
void _Inorder(Node* root)
{
if (root == nullptr)
return;
_Inorder(root->_left);
cout << root->_key << ":" << root->_value << endl;
_Inorder(root->_right);
}
private:
Node* _root = nullptr;
};
}
比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文<word, chinese>就构成一种键值对;
KV::BSTree<string, string> dict;
dict.Insert("sort", "排序");
dict.Insert("string", "字符串");
dict.Insert("left", "左边");
dict.Insert("right", "右边");
string str;
while (cin >> str)
{
KV::BSTreeNode<string, string>* ret = dict.Find(str);
if (ret)
{
cout << ret->_value << endl;
}
else
{
cout << "无此单词" << endl;
}
}
再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是<word, count>就构成一种键值对。
// 统计水果出现的次数
string arr[] = { "苹果", "西瓜", "香蕉", "草莓","苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
KV::BSTree<string, int> countTree;
for (auto e : arr)
{
auto* ret = countTree.Find(e);
if (ret == nullptr)
{
countTree.Insert(e, 1);
}
else
{
ret->_value++;
}
}
countTree.Inorder();
二叉搜索树的性能分析
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),树是平衡的,则n个节点的二叉搜索树的高度为,其查找效率为
,近似于折半查找.其平均比较次数为:O(long(N))
最差情况下,二叉搜索树退化为单支树(或者类似单支),深度达到N,其平均比较次数为:O(N)。