樊梓慕:个人主页

 个人专栏:《C语言》《数据结构》《蓝桥杯试题》《LeetCode刷题笔记》《实训项目》《C++》《Linux》《算法》

每一个不曾起舞的日子,都是对生命的辜负

前言

本篇文章博主会对二叉搜索树的一些特性进行讲解,并且进行模拟实现。

欢迎大家收藏以便未来做题时可以快速找到思路,巧妙的方法可以事半功倍。

=========================================================================

GITEE相关代码:樊飞 (fanfei_c) - Gitee.com

=========================================================================

1.概念

二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:

若它的左子树不为空,则左子树上所有节点的值都小于根节点的值若它的右子树不为空,则右子树上所有节点的值都大于根节点的值它的左右子树也分别为二叉搜索树

2.模拟实现

2.1节点类

一般像这种链式结构,我们都要实现一个节点类出来用来构建联系。

结点类当中包含三个成员变量:结点值、左指针、右指针。

template

struct BSTreeNode

{

BSTreeNode* _left;

BSTreeNode* _right;

K _key;

BSTreeNode(const K& key = 0)

:_left(nullptr)

, _right(nullptr)

, _key(key)

{}

};

2.2构造函数

因为这里的构造不需要特定的要求,只需要构造一棵空树,即给root赋值为nullptr即可。

//构造函数

BSTree()

:_root(nullptr)

{}

或者你可以让系统自己生成一个默认的构造。

BSTree() = default;

2.3拷贝构造函数

注意这里完成的是深拷贝,不能是值拷贝,防止二次析构发生。

//拷贝构造函数

BSTree(const BSTree& 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;

}

2.4赋值运算符重载

在之前数据结构的模拟实现中,一般对于赋值运算符这块我们已经学习了现代写法,直接swap即可。

//赋值运算符重载函数

BSTree& operator=(BSTree t)

{

swap(_root, t._root);

return *this;

}

原理:=的右值由于参数传递的不是引用,所以会调用自身的拷贝构造形成一个临时对象,交换临时对象与左值根节点后,此时左值根节点已经是之前的右值根节点了,然后返回左值根节点完成赋值,结束后右值根节点会被析构(即之前的左值)。

当然还有以下这种传统写法。

//传统写法

const BSTree& operator=(const BSTree& t)

{

if (this != &t) //防止自己给自己赋值

{

_Destory(_root); //先将当前的二叉搜索树中的结点释放

_root = _Copy(t._root); //拷贝t对象的二叉搜索树

}

return *this; //支持连续赋值

}

2.5析构函数

二叉树的析构一定采用『 后序』的方式。

//析构函数

~BSTree()

{

Destroy(_root);

}

void Destroy(Node* root)

{

if (root == nullptr)

return;

Destroy(root->_left);

Destroy(root->_right);

delete root;

}

2.6查找函数

根据二叉搜索树的特性(左子树都小于根,右子树都大于根),我们在二叉搜索树当中查找指定值的结点的方式如下:

若树为空树,则查找失败,返回nullptr。若key值小于当前结点的值,则应该在该结点的左子树当中进行查找。若key值大于当前结点的值,则应该在该结点的右子树当中进行查找。若key值等于当前结点的值,则查找成功,返回对应结点的地址。

非递归方式: 

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;

}

 递归方式:

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);

}

else if (root->_key > key)

{

return _FindR(root->_left, key);

}

else

{

return true;

}

}

思考:为什么要设计成子函数这种形式呢?

因为当外部调用FindR时,我们没法直接传递根节点『 根节点是private域』,所以我们需要通过FindR获取到*this从而获取到_root,然后再调用_FindR,_FindR设计的参数为root和key就可以实现逻辑了,包括后面的递归方式都是这个思路。

2.7插入函数

插入的具体过程如下:

树为空,则直接新增节点,赋值给root指针;树不空,按二叉搜索树性质查找插入位置,插入新节点。

非递归方式: 

使用非递归方式实现二叉搜索树的插入函数时,找到插入位置后我们需要new新节点,然后将该节点与对应的父节点进行连接,所以我们需要定义一个parent指针,该指针用于标记待插入结点的父结点。

注意:连接parent和cur时,需要判断应该将cur连接到parent的左边还是右边。

//插入函数

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 if (cur->_key > key)

{

parent = cur;

cur = cur->_left;

}

else

{

return false;

}

}

cur = new Node(key);

if (parent->_key < key) //key值大于当前parent结点的值

{

parent->_right = cur; //将结点连接到parent的右边

}

else //key值小于当前parent结点的值

{

parent->_left = cur; //将结点连接到parent的左边

}

return true;

}

递归方式:

同样的,对于递归方式来讲,我们如何将新节点与父节点进行连接呢?

这里只需要给参数上加一个『 引用』即可。

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;

}

}

2.8删除函数

二叉搜索树的删除函数情况比较复杂,场景比较多。

若是在二叉树当中没有找到待删除结点,则直接返回false表示删除失败即可,但若是找到了待删除结点,此时就有以下三种情况:

待删除结点的左子树为空(待删除结点的左右子树均为空包含在内)。待删除结点的右子树为空。待删除结点的左右子树均不为空。

分情况进行讨论:

(1)待删除结点的左子树为空(待删除结点的左右子树均为空包含在内)。

若待删除结点的左子树为空,那么找到待删除结点后,只需先让其父结点指向该结点的右孩子结点,然后再将该结点释放。

(2)待删除结点的右子树为空。

若待删除结点的右子树为空,那么找到待删除结点后,只需先让其父结点指向该结点的左孩子结点,然后再将该结点释放。

(3)待删除结点的左右子树均不为空。

比如以下场景,要删除节点『 7』。

利用替换法:

step1:

将待删除结点与待删除结点左子树当中值最大的结点“替换”(这里的替换指将该最大节点赋值给待删除节点)或者将待删除结点与待删除结点右子树当中值最小的结点“替换”(这里的替换指将该最小节点赋值给待删除节点)『 以此为例』

step2:

然后将该替换后的节点删除,该替换后的节点必然左右子树当中至少有一个为空树,因此删除该结点的方法与前面说到的情况一和情况二的方法相同。

注意:只能是待删除结点左子树当中值最大的结点,或是待删除结点右子树当中值最小的结点代替待删除结点被删除,因为只有这样才能使得进行删除操作后的二叉树仍保持二叉搜索树的特性。

非递归方式:

使用minParent标记待删除结点右子树当中值最小结点的父结点。使用minRight标记待删除结点右子树当中值最小的结点。

左子树的最大一定是左子树中最右面的节点;

右子树的最小一定是右子树中最左面的节点。 

当找到待删除结点右子树当中值最小的结点时,先将待删除结点的值改为minRight的值,之后直接判断此时minRight是minParent的左孩子还是右孩子,然后对应让minParent的左指针或是右指针转而指向minRight的右孩子(注意:minRight的左孩子为空),最后将minRight结点进行释放即可。

bool Erase(const K& key)

{

Node* parent = nullptr; //记录待删除结点的父结点

Node* cur = _root; //记录待删除结点

while (cur)

{

if (key < cur->_key) //key值小于当前结点的值

{

parent = cur;

cur = cur->_left;

}

else if (key > cur->_key) //key值大于当前结点的值

{

parent = cur;

cur = cur->_right;

}

else //找到了待删除结点

{

if (cur->_left == nullptr) //待删除结点的左子树为空

{

if (cur == _root) //待删除结点是根结点,此时parent为nullptr

{

_root = cur->_right; //二叉搜索树的根结点改为根结点的右孩子即可

}

else //待删除结点不是根结点,此时parent不为nullptr

{

if (cur == parent->_left) //待删除结点是其父结点的左孩子

{

parent->_left = cur->_right; //父结点的左指针指向待删除结点的右子树即可

}

else //待删除结点是其父结点的右孩子

{

parent->_right = cur->_right; //父结点的右指针指向待删除结点的右子树即可

}

}

delete cur; //释放待删除结点

return true; //删除成功,返回true

}

else if (cur->_right == nullptr) //待删除结点的右子树为空

{

if (cur == _root) //待删除结点是根结点,此时parent为nullptr

{

_root = cur->_left; //二叉搜索树的根结点改为根结点的左孩子即可

}

else //待删除结点不是根结点,此时parent不为nullptr

{

if (cur == parent->_left) //待删除结点是其父结点的左孩子

{

parent->_left = cur->_left; //父结点的左指针指向待删除结点的左子树即可

}

else //待删除结点是其父结点的右孩子

{

parent->_right = cur->_left; //父结点的右指针指向待删除结点的左子树即可

}

}

delete cur; //释放待删除结点

return true; //删除成功,返回true

}

else //待删除结点的左右子树均不为空

{

//替换法删除

Node* minParent = cur; //标记待删除结点右子树当中值最小结点的父结点

Node* minRight = cur->_right; //标记待删除结点右子树当中值最小的结点

//寻找待删除结点右子树当中值最小的结点

while (minRight->_left)

{

//右子树中最小一定在最左面

minParent = minRight;

minRight = minRight->_left;

}

cur->_key = minRight->_key; //将待删除结点的值改为minRight的值

//注意一个隐含条件:此时minRight的_left为空

if (minRight == minParent->_left) //minRight是其父结点的左孩子

{

minParent->_left = minRight->_right; //父结点的左指针指向minRight的右子树即可

}

else //minRight是其父结点的右孩子

{

minParent->_right = minRight->_right; //父结点的右指针指向minRight的右子树即可

}

delete minRight; //释放minRight

return true; //删除成功,返回true

}

}

}

return false; //没有找到待删除结点,删除失败,返回false

}

 递归方式:

在找到了待删除节点后的思路与操作一样,未找到之前改换为递归方式即可。 

bool _EraseR(Node*& root, const K& key)

{

if (root == nullptr)

return false;

if (key < root->_key) //key值小于根结点的值

return _EraseR(root->_left, key); //待删除结点在根的左子树当中

else if (key > root->_key) //key值大于根结点的值

return _EraseR(root->_right, key); //待删除结点在根的右子树当中

else //找到了待删除结点

{

if (root->_left == nullptr) //待删除结点的左子树为空

{

Node* del = root; //保存根结点

root = root->_right; //根的右子树作为二叉树新的根结点

delete del; //释放根结点

}

else if (root->_right == nullptr) //待删除结点的右子树为空

{

Node* del = root; //保存根结点

root = root->_left; //根的左子树作为二叉树新的根结点

delete del; //释放根结点

}

else //待删除结点的左右子树均不为空

{

Node* minParent = root; //标记根结点右子树当中值最小结点的父结点

Node* minRight = root->_right; //标记根结点右子树当中值最小的结点

//寻找根结点右子树当中值最小的结点

while (minRight->_left)

{

//右子树中最小一定在最左面

minParent = minRight;

minRight = minRight->_left;

}

root->_key = minRight->_key; //将根结点的值改为minRight的值

//注意一个隐含条件:此时minRight的_left为空

if (minRight == minParent->_left) //minRight是其父结点的左孩子

{

minParent->_left = minRight->_right; //父结点的左指针指向minRight的右子树即可

}

else //minRight是其父结点的右孩子

{

minParent->_right = minRight->_right; //父结点的右指针指向minRight的右子树即可

}

delete minRight; //释放minRight

}

return true; //删除成功,返回true

}

}

bool EraseR(const K& key)

{

return _EraseR(_root, key); //删除_root当中值为key的结点

}

3.二叉搜索树的应用

3.1『 K模型』

K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。

比如:

给一个单词word,判断该单词是否拼写正确,具体方式如下:

以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。

3.2『 KV模型』

每一个关键码key,都有与之对应的值Value,即的键值对。该种方式在现实生活中非常常见:

比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文就构成一种键值对;再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是就构成一种键值对。

namespace key_value

{

template

struct BSTreeNode

{

typedef BSTreeNode Node;

Node* _left;

Node* _right;

K _key;

V _value;

BSTreeNode(const K& key, const V& value)

:_left(nullptr)

,_right(nullptr)

,_key(key)

,_value(value)

{}

};

template

class BSTree

{

typedef BSTreeNode 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;

}

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 (cur == parent->_right)

{

parent->_right = cur->_right;

}

else

{

parent->_left = cur->_right;

}

}

delete cur;

return true;

}

else if (cur->_right == nullptr)

{

if (cur == _root)

{

_root = cur->_left;

}

else

{

if (cur == parent->_right)

{

parent->_right = cur->_left;

}

else

{

parent->_left = cur->_left;

}

}

delete cur;

return true;

}

else

{

// 替换法

Node* rightMinParent = cur;

Node* rightMin = cur->_right;

while (rightMin->_left)

{

rightMinParent = rightMin;

rightMin = rightMin->_left;

}

cur->_key = rightMin->_key;

if (rightMin == rightMinParent->_left)

rightMinParent->_left = rightMin->_right;

else

rightMinParent->_right = rightMin->_right;

delete rightMin;

return true;

}

}

}

return false;

}

void InOrder()

{

_InOrder(_root);

cout << endl;

}

private:

void _InOrder(Node* root)

{

if (root == nullptr)

return;

_InOrder(root->_left);

cout << root->_key << " ";

_InOrder(root->_right);

}

private:

Node* _root = nullptr;

};

}

4.二叉树的性能分析

『 查找效率』代表了二叉搜索树中各个操作的性能,因为插入和删除操作都必须先查找。

对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:

最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为:log(N)最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为:N/2

时间复杂度描述的是最坏情况下算法的效率,因此普通二叉搜索树各个操作的时间复杂度都是O(N)。

如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插入关键码,二叉搜索树的性能都能达到最优?(即log(N))

我们后面会学习AVL树与红黑树,他们对二叉搜索树进行了一定的优化,使得二叉搜索树的性能都能达到最优。

=========================================================================

如果你对该系列文章有兴趣的话,欢迎持续关注博主动态,博主会持续输出优质内容

博主很需要大家的支持,你的支持是我创作的不竭动力

~ 点赞收藏+关注 ~

=========================================================================

文章链接

评论可见,请评论后查看内容,谢谢!!!
 您阅读本篇文章共花了: