作者简介:一名在后端领域学习,并渴望能够学有所成的追梦人。
个人主页:蜗牛牛啊
系列专栏:数据结构、C++
学习格言:博观而约取,厚积而薄发
欢迎进来的小伙伴,如果小伙伴们在学习的过程中,发现有需要纠正的地方,烦请指正,希望能够与诸君一同成长!
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
它的左右子树也分别为二叉搜索树
二叉搜索树还有一个特征:按照中序走的话是一个升序的状态。所以二叉树搜索树可以叫做二叉排序树或二叉查找树。
首先实现一个结点类,结点类当中包含三个成员变量:结点值、左指针、右指针,同时结点类当中要对成员变量进行初始化,需要实现一个构造函数,用于将结点的左右指针置空和初始化指定结点值。
结点类的代码实现:
//二叉树搜索树结点类
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 {
//喜欢在类里进行类型重定义,因为受到类域的限制
typedef BSTreeNode<K> Node;
private:
Node* _root = nullptr; //可以给一个构造函数,也可以直接写一个缺省值
};
//构造函数
BSTree()
:root(nullptr)
{}
我们也可以这样写:
//default:默认情况下不会生成,让其强制生成构造函数
BSTree() = default;//指定强制生成默认构造
通过插入函数在二叉搜索树中插入一个值,如果成功返回true,失败返回false。
当我们想进行插入数据时,要和树的根结点及各个子树的根结点进行比较,如果待插入结点值比当前结点小就插入到该结点的左子树;如果待插入结点值比当前节点值大就插入到该结点的右子树。
默认的搜索二叉树是不允许冗余的,有相同的值会插入失败。
根的值是怎么来的?插入的第一个值就是根。所以如果是同样的值,插入的顺序不同二叉搜索树的形状就不同。
在实现时我们要定义一个parent指针,方便新增节点和父结点链接。同时在链接的时候还要判断一下是和父亲的左边链接还是右边链接。
代码实现:
bool Insert(const K& key)
{
//确定插入的是否是第一个值,如果是第一个值插入的值就是根结点
if (_root == nullptr)
{
_root = new Node(key);//申请一个新节点
return true;
}
//当根不是空时找对应的位置
Node* cur = _root;//从根结点开始向后找
Node* parent = nullptr;//父结点
while (cur)
{
if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
//当相等时返回false,搜索二叉树中不能有相等的
else
{
return false;
}
}
cur = new Node(key);
//将其链接到父结点上
if (parent->_key > key)
{
parent->_left = cur;
}
else if (parent->_key < key)
{
parent->_right = cur;
}
return true;
}
二叉搜索树中序遍历出来的顺序是升序的,我们可以实现一个中序遍历来验证。
void InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
InOrder(root->_left);
cout << root->_key << endl;
InOrder(root->_right);
}
但是我们发现这个函数调用时候不好处理,因为要传根结点,但是并没有根结点,如果参数没有根,又没办法递归。
所以我们可以套上一层函数:调用无参的函数,当我们调用时调用无参的函数就可以实现中序遍历了。
//套用一层函数
void InOrder()
{
_InOrder(_root);
}
//实现中序遍历,中序遍历打印出来是升序的
void _InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
测试一下:
void Test_BSTree()
{
int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
BSTree<int> t1;
for (auto e : a)
{
//插入
t1.Insert(e);
}
//中序遍历
t1.InOrder();
}
int main()
{
Test_BSTree();
return 0;
}
打印结果:
在二叉搜索树中也可以通和根结点和左右子树的根结点比较查找指定节点值,如果找到返回true,没找到返回false。
代码实现:
//查找接口
bool Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_key > key)
{
cur = cur->_left;
}
else if (cur->_key < key)
{
cur = cur->_right;
}
else
{
return true;
}
}
return false;
}
首先查找要删除的元素是否存在,如果不存在,则返回;否则要删除的结点可能分下面三种情况:
a.要删除的结点只有左孩子结点(包含要删除的结点无孩子结点)
b.要删除的结点只有右孩子结点
c.要删除的结点有左、右孩子结点
所以我们针对上面三种情况进行分析:
情况a:要删除的结点只有左孩子结点(包含要删除的结点无孩子结点)
此时删除要删除的结点之后,使被删除节点的父结点指向被删除节点的左孩子结点(直接删除法)
情况b.要删除的结点只有右孩子结点
此时删除要删除的结点之后,使被删除节点的父结点指向被删除节点的右孩子结点(直接删除法)
情况c.要删除的结点有左、右孩子结点
若待删除结点有左、右孩子结点,可以使用替换法进行删除。
可以找到待删除结点左子树中结点值最大的结点,或者是待删除结点右子树中结点值最小的结点来代替待删除结点被删除。代替待删除结点被删除的结点,在左右子树当中至少有一个为空树,那删除该结点之后可以利用上面两种情况来处理。
必须是待删除结点左子树中结点值最大的结点,或者是待删除结点右子树中结点值最小的结点代替待删除结点被删除,只有这样才能保证删除后的二叉树仍保持二叉搜索树的特性。
删除的时候也要用parent记录父结点,保证当被删除结点还有孩子时能够被接管。
代码实现:
bool Erase(const K& key)
{
Node* parent = nullptr;//父结点
Node* cur = _root;
//循环查找
while (cur)
{
if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
//找到该节点,删除该节点
else {
//如果该结点只有左孩子
if (cur->_right == nullptr)
{
//当只有一边时,需要更新_root
if (cur == _root)
{
//左孩子为空,让_root等于右孩子
_root = cur->_left;
}
else
{
//判断是哪边的,让其接管
if (parent->_left == cur)
{
parent->_left = cur->_left;
}
else
{
parent->_right = cur->_left;
}
}
//删除该结点
delete cur;
}
//该节点只有右孩子
else if (cur->_left == nullptr)
{
//当只有右边时,需要更新_root
if (cur == _root)
{
_root = cur->_right;
}
else {
if (parent->_right == cur)
{
parent->_right = cur->_right;
}
else
{
parent->_left = cur->_left;
}
}
//删除结点
delete cur;
}
//该节点有左右孩子
else
{
//找右树的最小结点替代
//这里不能等于空
//Node* pminRight = nullptr;
Node* pminRight = cur;
Node* minRight = cur->_right;
while (minRight->_left)
{
pminRight = minRight;
minRight = minRight->_left;
}
//找到右树的最小结点之后再把key传过去
cur->_key = minRight->_key;
if (pminRight->_left == minRight)
{
pminRight->_left = minRight->_right;
}
else
{
pminRight->_right = minRight->_right;
}
delete minRight;
}
return true;
}
}
return false;
}
同时我们在实现时应该注意当其为如下特殊情况时,更新_root
:
一般在类里面写递归都要套上一层。
要在搜索树里面进行一个插入:比根结点大的值和右子树根结点比较插入;比根结点小的值和左子树根结点比较插入,需要注意插入要和父结点链接起来。我们可以传入父结点,但是这里使用引用是最优的,root
是_root->left
或者_root->right
的别名(上一层的别名),就能够链接上。要注意C++的引用不能改变指向,循环里面不能用引用。
递归实现插入代码:
bool _InSertR(Node*& root, const K& key)
{
if (root == nullptr)
{
root = new Node(key);
return true;
}
if (root->_key > key)
{
_InSertR(root->_left, key);
}
if (root->_key < key)
{
_InSertR(root->_right, key);
}
else
return false;
}
bool InSertR(const K& key)
{
return _InSertR(_root, key);
}
查找,如果在二叉树中返回true,否则返回false。
递归实现查找代码:
//套用一层函数
bool _FindR(Node* root,const K& key)
{
if (root == nullptr)
{
return false;
}
if (root->_key == key)
{
return true;
}
if (root->_key > key)
{
return _FindR(root->_left, key);
}
else
{
return _FindR(root->_right, key);
}
}
bool FindR(const K& key)
{
return _FindR(_root,key);
}
删除的思路和非递归方式一样,当要删除的结点有左右孩子的时候使用替换法,找左子树的最大值或者右子树的最小值(下面的递归实现采用的是找左子树的最大值),但是注意递归这里不能使用root->_key = maxLeft->_key
,如果这样两个值相同了,不能找到要删除的结点。
递归删除函数子函数中必须使用引用接收参数,保证能够链接起来。
root是指针,直接让指针指向其指定结点就可以了,不用找到父节点。要保存一下要删除的结点Node* del = root
,不然改变root指针后没办法删除要删除的结点。
bool _EarseR(Node*& root,const K& key)
{
if (root == nullptr)
{
return false;
}
if (root->_key > key)
{
return _EarseR(root->_left, key);
}
else if (root->_key < key)
{
return _EarseR(root->_right, key);
}
else {
Node* del = root;//保存一下要删除的结点
if (root->_left == nullptr)
//root是指针,直接让指针指向其指定结点,这时就链接成功了
root = root->_right;
else if (root->_right == nullptr)
root = root->_left;
else
{
//去找左树的最大值
Node* maxLeft = root->_left;
while (maxLeft->_right)
{
maxLeft = maxLeft->_right;
}
//找到进行替代,直接交换
swap(root->_key, maxLeft->_key);
//交换值的时候不能使用root->_key = maxLeft->_key,如果这样两个值相同了,不能找到要删除的结点。
return _EarseR(root->_left,key);//转换成在子树去删除
}
delete del;
}
}
bool EarseR(const K& key)
{
return _EarseR(_root, key);
}
return _EraseR(root->_left, key);
这里不能使用maxLeft
,因为要使用引用,maxLeft
只是一个局部变量,会出问题的,引用在递归里面又变成别名。
当我们没有实现拷贝构造时候,使用的都是默认拷贝构造函数,属于浅拷贝。
当我们在没有实现析构函数时使用以下代码时并不会出问题:
void Test_BSTree() {
int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
BSTree<int> t1;
for (auto e : a)
{
t1.Insert(e);
}
t1.InOrder();
cout << endl;
BSTree<int> t2(t1);
t2.InOrder();
}
监视窗口如下:
但是当我们实现析构函数之后就会报错:
所以拷贝构造我们要写成深拷贝(推荐使用递归去实现):
主要思想就是先去创建结点,在返回的时候才开始将各个节点链接起来。
从根结点开始,不能使用插入函数,因为插入顺序不一样,形状不一样:
BSTree(const BSTree<K>& t)
{
_root = Copy(t._root);
}
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;
}
利用拷贝构造然后将其根结点交换即可。
BSTree& operator=(const BSTree<K> t)
{
swap(_root, t._root);
return *this;//支持连续赋值
}
二叉树搜索树的析构函数我们采用一个后序的递归删除完成:
//析构函数
~BSTree()
{
Destroy(_root);
_root = nullptr;
}
void Destroy(Node* root)
{
if (root == nullptr)
{
return;
}
Destroy(root->_left);
Destroy(root->_right);
delete root;
}
也可以在Destroy那里使用引用传参,从而可以不在析构函数那里写_root = nullptr;
,直接在Destroy函数实现,因为root
就是_root
的别名。
~BSTree()
{
Destroy(_root);
}
void Destroy(Node*& root)
{
if (root == nullptr)
{
return;
}
Destroy(root->_left);
Destroy(root->_right);
delete root;
root = nullptr;
}
K模型
K模型,即只有key作为关键码,结构中只需存储key即可,关键码即为需要搜索到的值。
我们在本篇中讲到的构建、查找、插入就属于K模型。
KV模型
KV模型,对于每一个关键码key,都有与之对应的值value,即
的键值对。
通过一个值查找另一个值:如中英文互译字典、电话号码查询快递信息等。
我们可以通过改一下本篇中的二叉树搜索树来认识一下key-value模型,之前的二叉树搜索树是key模型。
将代码修改,主要修改模板参数,增加一个参数:
namespace kv {
template<class K,class V>
struct BSTreeNode {
BSTreeNode<K,V>* _left;
BSTreeNode<K,V>* _right;
K _key;
V _value;
//构造函数
BSTreeNode(const K& key,const V& value)
:_left(nullptr)
, _right(nullptr)
, _key(key)
,_value(value)
{}
};
template<class K, class V>
class BSTree {
//喜欢在类里进行类型重定义,因为受到类域的限制
typedef BSTreeNode<K,V> Node;
public:
//插入成功返回true,插入失败返回false
bool Insert(const K& key,const V& value)
{
if (_root == nullptr)
{
_root = new Node(key,value);
return true;
}
Node* cur = _root;
Node* parent = nullptr;
while (cur)
{
if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
else
{
return false;
}
}
cur = new Node(key,value);
if (parent->_key > key)
{
parent->_left = cur;
}
else {
parent->_right = cur;
}
return true;
}
bool Erase(const K& key)
{
Node* parent = nullptr;//父结点
Node* cur = _root;
//循环查找
while (cur)
{
if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
//找到该节点,删除该节点
else {
//如果该结点只有左孩子
if (cur->_right == nullptr)
{
//当只有一边时,需要更新_root
if (cur == _root)
{
//左孩子为空,让_root等于右孩子
_root = cur->_left;
}
else
{
//判断是哪边的,让其接管
if (parent->_left == cur)
{
parent->_left = cur->_left;
}
else
{
parent->_right = cur->_left;
}
}
//删除该结点
delete cur;
}
//该节点只有右孩子
else if (cur->_left == nullptr)
{
//当只有右边时,需要更新_root
if (cur == _root)
{
_root = cur->_right;
}
else {
if (parent->_right == cur)
{
parent->_right = cur->_right;
}
else
{
parent->_left = cur->_left;
}
}
//删除结点
delete cur;
}
//该节点有左右孩子
else
{
//找右树的最小结点替代
//这里不能等于空
//Node* pminRight = nullptr;
Node* pminRight = cur;
Node* minRight = cur->_right;
while (minRight->_left)
{
pminRight = minRight;
minRight = minRight->_left;
}
//找到右树的最小结点之后再把key传过去
cur->_key = minRight->_key;
if (pminRight->_left == minRight)
{
pminRight->_left = minRight->_right;
}
else
{
pminRight->_right = minRight->_right;
}
delete minRight;
}
return true;
}
}
return false;
}
Node* Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_key > key)
{
cur = cur->_left;
}
else if (cur->_key < key)
{
cur = cur->_right;
}
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;
};
}
实现之后我们通过实现一个单词翻译来测试一下:
//测试
void Test_BSTree()
{
kv::BSTree<string, string> dict;
dict.Insert("sort", "排序");
dict.Insert("left", "左边");
dict.Insert("right", "右边");
dict.Insert("string", "字符串");
dict.Insert("insert", "插入");
dict.Insert("erase", "删除");
string str;
while (cin >> str)
{
//kv::BSTreeNode* ret = dict.Find(str);
auto ret = dict.Find(str);
if (ret)
{
cout << ":" << ret->_value << endl;
}
else
{
cout << "无此单词" << endl;
}
}
}
int main()
{
Test_BSTree();
return 0;
}
测试结果:
这种程序怎么结束呢?while (cin >> str)
按ctrl+c
是发送终止信号,也可以使用ctrl+z+换行
来结束。
我们还可以使用修改后的代码用来测试统计水果出现的次数:
void Test_BSTree()
{
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
"苹果", "香蕉", "苹果", "香蕉" };
kv::BSTree<string, int> countTree;
for (auto str : arr)
{
//kv::BSTreeNode* ret = countTree.Find(str);
auto ret = countTree.Find(str);
if (ret == nullptr)
{
countTree.Insert(str, 1);
}
else
{
ret->_value++;
}
}
countTree.InOrder();
}
int main()
{
Test_BSTree();
return 0;
}
测试结果(这里的顺序是按照string数组中出现的先后顺序来排序的):
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:
对于有N个结点的二叉搜索树,最优的情况下,二叉搜索树为完全二叉树,其平均比较次数为:logN;最差的情况下,二叉搜索树退化为单支树,其平均比较次数为:N/2。
而时间复杂度描述的是最坏情况下算法的效率,因此普通二叉搜索树各个操作的时间复杂度都是O(N)。