二叉树进阶 - 二叉搜索树
目录
二叉搜索树概念
1.二叉搜索树介绍
2.二叉搜索树的中序遍历特征
二叉搜索树模拟实现
二叉搜索树结点定义 - BSTreeNode
1.模板参数说明
2.结点结构定义
二叉搜索树查找Find - 循环版本
1.查找思路
2.查找过程
3.代码实现
二叉搜索树查找Find - 递归版本
1.递归查找思路
2.类成员函数递归实现的设计考虑
3.代码实现
二叉搜索树插入Insert - 循环版本
1.思路分析
2.代码
3.测试
3.1.中序遍历的两种写法
3.2.测试 Insert 代码
4.搜索二叉树插入顺序引发的性能隐患剖析
二叉树搜索树查找Insert — 递归版本
1.二叉搜索树递归插入的链接方法
编辑
1.1.方法 1:根结点指针使用传引用传参(推荐)
1.2.方法:传递父结点参数(不建议)
1.3.方法3
2.代码
(1)代码实现
(2)递归调用函数栈帧的过程
二叉搜索树删除Erase — 循环版本
1.二叉搜索树删除操作的情况分析
2.代码
2.1.删除过程
2.2.二叉搜索树删除操作错误分析与解决方案
2.2.1 情况 2(删除有 2 个孩子的结点)错误写法分析
2.2.2 情况 1(删除叶子结点 或 只有一个孩子的结点)错误写法分析
2.2.3 删除搜索二叉树根结点的两种情况对比
2.3.代码实现
二叉搜索树删除Erase — 递归版本
1.思路分析
2代码实现
2.1.方法 1 (不建议) - 使用手动托孤法删除保姆结点,代码复杂
2.2.方法2(建议) - 递归复用_Erase情况 1 的托孤逻辑删除保姆结点 ,简化代码结构。
2.3.方法 1 和方法 2 在删除保姆结点的区别
构造函数
1.构造函数规则总结
2.代码实现
(1)写法 1:显式编写默认构造函数
(2)写法 2:使用default关键字强制生成默认构造函数
拷贝构造函数(深拷贝 - 递归版本)
1.不能通过Insert函数进行拷贝的原因
2.代码实现
赋值重载(非递归版本 - 交换资源)
析构函数(递归版本 - 后序遍历删除)
1.方法1:子函数使用传值传参Node* root
2.方法2:子函数使用传引用传参Node*& root
二叉搜索树性能分析
1.性能与查找操作的关系
2.树结构对性能的影响
二叉搜索树应用 - 搜索场景
K 模型 - 在不在问题
1. 应用场景概述
2. 具体应用场景及原理
3. K 模型定义
KV 模型 - 通过一个值查询另一个值的问题
1. 应用场景概述
2. 具体应用场景及原理
3. KV 模型定义
二叉搜索树模拟实现 - K模型
1.在 C++ 中,递归传引用传参具有两种情况
2. 二叉搜索树模拟实现 - K 模型
二叉搜索树模拟实现 - KV模型
1.模型特性差异说明
2.KV 模型二叉搜索树代码实现
注意事项:普通二叉树若仅用于存储数据,其结构优势难以充分体现,相比顺序表或链表,在数据存储方面缺乏明显优势。为更高效地存储和管理数据,二叉搜索树应运而生。
二叉搜索树概念
1.二叉搜索树介绍
二叉搜索树(Binary Search Tree,BST),也称二叉排序树或二叉查找树。它要么是一棵空树,要么具备以下特性:
- 左子树性质:若左子树非空,那么左子树上所有结点的值都小于根结点的值。
- 右子树性质:若右子树非空,那么右子树上所有结点的值都大于根结点的值。
- 子树性质:其左右子树也分别为二叉搜索树,即每个子二叉搜索树都满足左子树结点值小于根结点值,右子树结点值大于根结点值的条件。
例如:
- 在包含结点值为 18、10、20、7、5、22 的二叉树中,结点 5 不满足左子树所有结点值小于根结点值(这里根结点为 10 )的条件,所以该树不是二叉搜索树(图中对应标记为叉号 )。
- 在包含结点值为 30、15、41、33、50 的二叉树中,结点 33 满足二叉搜索树的性质,该树是二叉搜索树(图中对应标记为对号 )。
- 在包含结点值为 6、3、9、1、4、7、10、2、5、8 的二叉树中,结点 5 满足二叉搜索树的性质,该树是二叉搜索树(图中对应标记为对号 )。
2.二叉搜索树的中序遍历特征
用中序遍历搜索二叉树的特征:对二叉搜索树进行中序遍历,得到的结果是一个升序序列。这是因为中序遍历的顺序是左子树 - 根结点 - 右子树,结合二叉搜索树左子树结点值小于根结点值、右子树结点值大于根结点值的性质,遍历结果自然呈现升序。基于此特性,二叉搜索树也可被称为排序二叉树或二叉排序树。
例如:对于结点值为 8、3、10、1、6、4、7、14、13 的二叉搜索树,其中序遍历结果为 1、3、4、6、7、8、10、13、14 。
二叉搜索树模拟实现
注意事项:
- 二叉搜索树与普通数据结构的区别 - 概述:二叉搜索树与常见数据结构在功能和操作特性上存在差异。像
string
、vector
、list
这类普通数据结构主要用于单纯存储数据,并且支持在任意位置进行插入和删除操作。而stack
(栈)、queue
(队列)、priority_queue
(堆 / 优先级队列)等数据结构,底层通过封装容器(如vector
、list
、deque
)来实现,除了存储数据外,还带有特定的操作功能,例如栈的后进先出、队列的先进先出等。二叉搜索树则是基于特定的排序规则来组织数据,它不仅用于存储,更方便进行查找等操作。 - 二叉搜索树的命名规范 - 类名简写建议:二叉搜索树英文为
Binary Search Tree
,在定义类模板时,建议使用简写名称BSTree
。这样可以避免类名过长,方便后续使用。而Search Binary Tree
简写为SBTree
,由于该简写在某些语境下可能存在歧义或不适当的联想,不推荐使用其作为类名。
二叉搜索树结点定义 - BSTreeNode
1.模板参数说明
在定义二叉搜索树结点时,使用模板参数 K
。这是因为二叉搜索树插入的是关键值 key
,且插入后需满足二叉搜索树的特性,即左子树所有结点值小于根结点值,右子树所有结点值大于根结点值。所以用 K
来表示存储数据的类型,更强调其作为关键值的特性,而非随意使用 T
。
2.结点结构定义
template<class K>
struct BSTreeNode//二叉搜索树结点定义
{BSTreeNode<K>* _left;//_left指针指向左子树BSTreeNode<K>* _right;//_right指针指向右子树K _key;//结点存储的关键值数据//构造函数BSTreeNode(const K& key = K())//缺省值为K(),通过调用默认构造函数初始化key:_left(nullptr),_right(nullptr),_key(key){}
};
构造函数解释:构造函数采用带默认参数的形式,默认参数 key = K()
,这里 K()
会调用 K
类型的默认构造函数来创建一个匿名对象,并将其用于初始化成员变量 _key
。同时,将 _left
和 _right
指针初始化为 nullptr
,表示新创建的结点初始时没有左右子树。
二叉搜索树查找Find - 循环版本
1.查找思路
在二叉搜索树中查找特定值 key 时,通过将 key 与根结点值进行比较来确定查找方向。若 key 大于根结点值,则在右子树中继续查找;若 key 小于根结点值,则在左子树中继续查找。
2.查找过程
- 从根结点开始进行比较和查找操作。若待查找的值大于根结点值,就向根结点的右子树方向移动继续查找;若小于根结点值,则向根结点的左子树方向移动继续查找。
- 由于二叉搜索树的高度决定了查找路径的最大长度,最多查找次数为树的高度。当查找路径走到空结点时,说明要查找的值在该二叉搜索树中不存在。
3.代码实现
二叉搜索树的查找
- a、从根开始比较,查找,比根大则往右边走查找,比根小则往左边走查找。
- b、最多查找高度次,走到到空,还没找到,这个值不存在。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://查找某个值keybool Find(const K& key){Node* cur = _root;while (cur){if (cur->_key < key){//当前根结点比查找值key小,则在右子树继续查找。cur = cur->_right;}else if (cur->_key > key){//当前根结点比查找值key大,则在左子树继续查找。cur = cur->_left;}else{//当前根结点与查找值key相等,则找到了,则返回true说明查找成功。return true;}}//当cur遍历到空树(cur == nullptr)时,则说明在搜索二叉树中没有找到该key值。return false;}private:Node* _root = nullptr;
};
二叉搜索树查找Find - 递归版本
1.递归查找思路
在搜索二叉树中查找值 key
时,通过递归方式进行。若 key
大于根结点值,将问题转换为在右子树中继续查找;若 key
小于根结点值,将问题转换为在左子树中继续查找。具体步骤如下:
- 当遇到空树(即当前根结点为
nullptr
)时,说明要查找的值不存在,直接结束查找并返回false
。 - 若当前根结点的值等于要查找的
key
,则直接返回true
,表示成功找到。 - 若
key
大于当前根结点值,递归进入右子树继续查找。 - 若
key
小于当前根结点值,递归进入左子树继续查找。
总结来说,先判断当前根结点是否为要查找的值 key
,若不是,则根据 key
与根结点值的大小关系,决定在左子树或右子树中继续查找。若一直查找至空树都未找到,则说明值不存在。
注意事项:
- 在二叉搜索树中,查找操作是基于树的特定结构和规则进行的。当从根结点开始查找一个值时,会根据要查找的值与当前结点值的大小关系来决定向左子树还是右子树继续查找。
- 如果在查找过程中遇到了空树(当前根结点为 nullptr),这意味着已经遍历到了树的尽头,没有更多的结点可以检查了。因为二叉搜索树的性质决定了,如果要查找的值存在于树中,那么按照比较大小的规则进行遍历,最终一定会找到该值或者到达一个空结点。如果到达了空结点还没有找到目标值,就说明这个值不在树中,所以可以直接结束查找并返回 false。
2.类成员函数递归实现的设计考虑
当实现搜索二叉树的类成员函数采用递归方式时,通常会设计一个辅助的子函数。在搜索二叉树类中,根结点指针 Node* _root
一般作为私有成员变量。为了既实现递归操作又不破坏类的封装性,会有以下设计:
- 定义一个公有的类成员函数(主函数),该函数作为对外接口。在其函数体内部,利用类的私有成员变量根结点指针
_root
来调用私有的子函数。 - 定义一个私有的子函数,其参数列表包含根结点指针参数(即接受
Node* _root
作为参数)。这样在递归过程中,可以依据根结点及其子树进行相关操作。通过这种方式,在类的外部调用公有的类成员函数时,无需传入根结点指针(因为公有的类成员函数内部可访问并传递类的私有根结点指针给子函数),既实现了递归功能,又保证了类私有成员的安全性和封装性。
3.代码实现
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public:bool FindR(const K& key) {return _FindR(_root, key);}protected://注意事项:普通二叉树利用前序遍历进行查找某值是先查找根,根不是再去左子树中找,//左子树中没有再去右子树中找。即普通二叉树递归查找都是左右子树都去查找的。//搜索二叉树递归查找思路:先查找根,若根不是,key比根大则去右子树中找 或 key比根小则去左子树中找。//但是搜索二叉树的特性导致每次查找都只能在根和左右子树中的一个去查找,无法做到在同一棵树中//左子树没有找到就去右子树找,即每次查找一棵树只能选左右子树中的一棵去查找。//查找 - 递归版本bool _FindR(Node* root, const K& key) {//走到空,就表示没有找到if (root == nullptr)return false;//若根结点是,就查找成功if (root->_key == key)return true;//若根不是://key比根结点大,就去右子树找,而不是去左子树找。if (root->_key < key)return _FindR(root->_right, key);//key比根结点小,就去左子树找,而不是去右子树找。elsereturn _FindR(root->_left, key);}private:Node* _root = nullptr;
};
二叉搜索树插入Insert - 循环版本
1.思路分析
(1)案例分析:插入 12、13
①注意事项
- 二叉搜索树的特性(左子树结点值小于根结点值,右子树结点值大于根结点值)使其查找操作较为方便。
- 二叉搜索树插入操作存在成功和失败两种情况。该树不允许数据冗余,即存储的数据不能重复。当插入的数据
key
与树中已存储的数据相等时,插入操作失败。
②插入 12 的过程:二叉搜索树的插入操作包含查找和插入两个步骤(且存在插入是否成功的问题)。以插入数据key = 12
为例,用当前指针cur
遍历二叉搜索树(cur
每次遍历的都是当前子树的根结点,初始cur = _root
)。若key = 12
大于cur
遍历到的当前根结点值_key
,则cur
往右子树移动,即cur = cur->right
;若key = 12
小于cur
遍历到的当前根结点值_key
,则cur
往左子树移动,即cur = cur->left
。直至走到空树位置,此时可插入数据 12;若未走到空树,且key = 12
等于cur
遍历到的当前根结点值_key
,则插入失败。
(2)插入思路
①注意事项
- 插入数据
key
时,若在二叉搜索树中查找到符合二叉搜索树特性的插入位置,通过创建新结点并将其值设为key
来完成插入操作。 - 二叉搜索树存储数据的结点按需申请和释放。插入新数据时创建新结点,删除数据时释放对应结点。插入操作并非覆盖原结点存储的值,而是在符合二叉搜索树特性的前提下,通过创建新结点并赋值来完成。
②插入过程
- 情况 1:若二叉搜索树一开始为空树,直接新增(创建)结点,并将其赋值给_
root根结点
指针。 - 情况 2:若二叉搜索树一开始不为空树,按照二叉搜索树的性质查找插入位置,然后插入新结点。
2.代码
(1)注意事项
- 在二叉搜索树插入数据时,根据二叉搜索树特性找到的插入位置通常为空树位置,即原叶子结点的左右孩子位置。
- 二叉搜索树存储数据不能冗余,即不能有重复数据。
(2)解决链接两种写法:
- 写法 1(建议):定义父结点指针记录最终插入位置的父结点,以此辅助链接操作来实现插入数据。
- 写法 2(不建议):若插入数据比当前根结点大,先判断当前二叉搜索树的右子树是否为空树。若为空,创建新结点并直接链接到当前根结点以完成插入操作;若不为空,才在右子树中继续查找插入位置。同理,若插入数据比当前根结点小,先判断左子树是否为空树。若为空,创建新结点并直接链接到当前根结点以完成插入操作;若不为空,才在左子树中继续查找插入位置。
(3)代码实现
注意事项:线性表(如顺序表、链表等序列式容器)插入数据可在任意位置进行,而二叉搜索树插入数据并非任意位置,而是在特定的符合其特性的位置插入。
//搜索二叉树插入
//注意:由于搜索二叉树不允许数据冗余(即存储数据不能重复),因此Insert函数需要返回一个布尔值来表示插入是否成功。
//插入的数据key将作为搜索二叉树中某个子树的根结点的值。//定义二叉搜索树类
template<class K>
class BSTree
{//为BSTreeNode<K>定义别名Node,方便后续使用typedef BSTreeNode<K> Node;
public://插入函数,尝试将key插入到搜索二叉树中bool Insert(const K& key){//插入情况分析//情况1:插入的数据是搜索二叉树的第一个结点。//若搜索二叉树初始为空(即根结点为空),则插入的数据将成为搜索二叉树的根结点。if (_root == nullptr){//若搜索二叉树为空,创建一个新结点作为根结点,并将key赋值给它。_root = new Node(key);//插入成功,返回truereturn true;}//情况2:插入的数据不是搜索二叉树的第一个结点。//父结点指针,用于记录插入位置的父结点Node* parent = nullptr;//当前结点指针,用于遍历搜索二叉树Node* cur = _root;//过程1:查找插入位置//搜索二叉树的特性是:左子树的所有结点值小于根结点值,右子树的所有结点值大于根结点值。//查找插入位置的本质是找到一个空的子树位置,即搜索二叉树叶子结点的下一个位置。while (cur){//根据搜索二叉树的特性,通过比较key和当前结点的值来决定遍历方向。if (cur->_key < key){//记录当前结点为父结点,方便后续插入新结点时使用parent = cur;//由于key大于当前结点的值,所以向右子树遍历cur = cur->_right;}else if (cur->_key > key){//记录当前结点为父结点,方便后续插入新结点时使用parent = cur;//由于key小于当前结点的值,所以向左子树遍历cur = cur->_left;}else{//若插入的数据key与当前结点的值相等,说明数据已存在,插入失败。return false;}}//过程2:找到插入位置(即空树位置),插入数据//创建一个新结点,将key赋值给它。cur = new Node(key);//根据搜索二叉树的特性,判断新结点应该作为父结点的左子结点还是右子结点。if (parent->_key < key){//若父结点的值小于key,则将新结点作为父结点的右子结点。parent->_right = cur;}else{//若父结点的值大于key,则将新结点作为父结点的左子结点。parent->_left = cur;}//插入成功,返回truereturn true;}private://搜索二叉树的根结点指针,初始化为空Node* _root = nullptr;
};
3.测试
3.1.中序遍历的两种写法
注意:由于二叉搜索树类模板的成员变量根结点指针Node* _root
是私有的,在类外部无法直接将其作为参数传递给中序遍历函数InOrder
来对二叉搜索树进行中序遍历。为此,有两种解决方式。
(1)解决方式 1(建议):在BSTree
类外部,中序遍历函数InOrder
无法访问私有成员变量根结点指针Node* _root
。可通过嵌套一层InOrder
函数来解决。
结论:在二叉搜索树中,对于递归操作,建议嵌套一层子函数。这样在类外部可通过无参调用递归函数,避免因无法传递私有成员变量二叉搜索树根结点Node* _root
而导致无法正常调用递归函数。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://中序遍历void InOrder(){_InOrder(_root);//嵌套一层中序遍历,就封装子函数cout << endl;}//中序遍历的子函数void _InOrder(Node* root){//遇到空树就结束遍历if (root == nullptr)return;//先遍历左子树_InOrder(root->_left);//再访问当前根结点,并打印根结点的值。cout << root->_key << " ";//最后遍历右子树_InOrder(root->_right);}
private:Node* _root = nullptr;//搜索二叉树根结点指针_root
};void TestBSTree()
{int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };BSTree<int> t1;//通过不断插入来创建搜索二叉树for (auto e : a){t1.Insert(e);}//中序遍历t1.InOrder();
}
(2)解决方式 2(不建议,因为写法怪异):在BSTree
类模板内定义公有成员函数GetRoot()
来获取私有成员变量根结点指针Node* _root
,并将其作为参数传递给中序遍历函数InOrder
,即对象名.InOrder(对象名.GetRoot())
。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://获取搜索二叉树私有成员变量根结点指针_rootNode* GetRoot(){return _root;}void InOrder(Node* root){//遇到空树就结束遍历if (root == nullptr)return;//先遍历左子树_InOrder(root->_left);//再访问当前根结点,并打印根结点的值。cout << root->_key << " ";//最后遍历右子树_InOrder(root->_right);}
private:Node* _root = nullptr;//搜索二叉树根结点指针_root
};void TestBSTree()
{int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };BSTree<int> t1;//通过不断插入来创建搜索二叉树for (auto e : a){t1.Insert(e);}//中序遍历t1.InOrder(t1.GetRoot());
}
(3)中序遍历的错误写法
错误写法:中序遍历InOrder
的形参Node* root = _root
不建议使用缺省值来解决上述问题。
①问题 1:缺省值必须是全局变量、常量或(全局 / 局部)静态变量。
②问题 2:void InOrder(Node* root = _root)
等价于void InOrder(BSTree* this,Node* root = _root)
。最大问题在于,void InOrder(Node* root = _root)
中要访问成员变量_root
需使用this
指针,但形参this
指针必须在接收实参后才能在函数体内部使用,而在中序遍历InOrder
函数的参数列表BSTree* this,Node* root = _root
中无法直接使用this
调用类成员变量_root
,因为this
指针只有接收实参后才指向搜索二叉树对象,此时才能在函数体内部使用this
访问类成员变量。
总的来说,参数列表BSTree* this,Node* root = _root
可理解为BSTree* this,Node* root = this->_root
,但此时参数列表中this->_root
的this
值是随机值,只有this
接收实参值后才指向搜索二叉树对象,才能在函数体内部使用this
访问类成员变量。即this
只能在成员函数的函数体内部起作用,在参数列表中不起作用。
若私有成员变量Node* _root
是静态变量,则void InOrder(Node* root = _root)
的缺省值是正确的,因为静态变量属于整个类域,无需使用this
指针访问;
若私有成员变量Node* _root
是局部普通变量,则void InOrder(Node* root = _root)
的缺省值是错误的,因为在类内部或类成员函数内部,该成员变量Node* _root
需通过this
指针进行访问。
注意:在类内部,除静态成员变量和成员函数外,成员函数和成员变量均通过this
指针进行访问。
3.2.测试 Insert 代码
解析:可通过插入一系列数据,然后进行中序遍历,检查插入结果是否符合二叉搜索树的特性。如上述TestBSTree
函数,通过插入数组a
中的数据并进行中序遍历,验证插入操作的正确性。
4.搜索二叉树插入顺序引发的性能隐患剖析
(1)最坏情况:树退化为链表
问题描述:当插入的数据是有序的(升序或降序)时,二叉搜索树会退化为链表。
示例说明:假设按升序插入数据1, 2, 3, 4, 5
到一棵初始为空的二叉搜索树中。
- 插入
1
时,1
成为根节点。 - 插入
2
时,由于2 > 1
,2
成为1
的右子节点。 - 插入
3
时,因为3 > 2
,3
成为2
的右子节点。 - 以此类推,最终树的形状会变成一个链表,每个节点只有右子节点。
性能影响:
- 查找操作:在正常的平衡搜索二叉树中,查找操作的平均时间复杂度为 O(logn),其中 n 是树中节点的数量。但当树退化为链表时,查找操作需要遍历链表中的每个节点,时间复杂度变为 O(n)。例如,要查找节点
5
,需要依次访问1
、2
、3
、4
才能找到5
。 - 插入操作:插入操作同样会受到影响。在平衡的搜索二叉树中,插入操作的平均时间复杂度为 O(logn)。但在退化为链表的情况下,每次插入都需要遍历到链表的末尾,时间复杂度变为 O(n)。例如,插入一个新节点
6
,需要从根节点1
开始,依次访问2
、3
、4
、5
才能将6
插入到链表的末尾。 - 删除操作:删除操作的时间复杂度也会从 O(logn) 变为 O(n)。因为在删除节点时,需要先找到该节点,而查找的时间复杂度已经变为 O(n)。
(2)导致树的高度不平衡
问题描述:即使插入的数据不是完全有序的,插入顺序也可能导致树的高度不平衡。
示例说明:假设插入的数据顺序为1, 3, 2
。
- 插入
1
时,1
成为根节点。 - 插入
3
时,由于3 > 1
,3
成为1
的右子节点。 - 插入
2
时,因为2 > 1
且2 < 3
,2
成为3
的左子节点。此时树的左子树高度为0
,右子树高度为2
,树的高度不平衡。
性能影响:树的高度不平衡会导致搜索、插入和删除操作的效率下降。因为这些操作的时间复杂度与树的高度密切相关,树的高度越高,操作所需的时间就越长。在不平衡的树中,平均情况下的时间复杂度会接近 O(n),而不是理想的 O(logn)。
(3)解决方法:为了解决搜索二叉树因插入顺序导致的性能问题,可以使用平衡搜索二叉树(如 AVL 树、红黑树等)来解决。这些平衡搜索二叉树会在插入或删除节点时自动调整树的结构,保证树的高度始终保持在 O(logn) 的范围内,从而使搜索、插入和删除操作的时间复杂度稳定在 O(logn)。
二叉树搜索树查找Insert — 递归版本
1.二叉搜索树递归插入的链接方法
1.1.方法 1:根结点指针使用传引用传参(推荐)
(1)原理阐述:在 C++ 中,传引用传参常用于输出型参数场景。输出型参数意味着在函数执行过程中,对形参的修改能够反馈到调用函数时传入的实参上,即形参的改变会影响实参。在二叉搜索树的递归插入操作中,将子函数_InsertR (Node* root, const K& key) 改为_InsertR (Node*& root, const K& key),利用传引用传参的特性来简化插入位置的链接操作。
(2)代码解析
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;public:bool InsertR(const K& key){return _InsertR(_root, key);}protected: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;}}private://搜索二叉树根结点指针_rootNode* _root = nullptr;
};
- InsertR 函数作为对外的接口,负责调用内部的递归子函数_InsertR,并将根结点_root 作为初始参数传入。
- 在_InsertR 函数中,首先判断 root 是否为空。
若为空,这意味着已经抵达了符合二叉搜索树特性的插入位置。此时,直接创建一个新结点,该结点的值为要插入的 key。由于 root 是以引用 Node*& 的形式传递的参数,它实际上是上一层调用中对应指针(可以是根结点指针_root,也可以是某个结点的左子树指针_left 或右子树指针_right)的别名。所以,对当前栈帧中 root 的赋值操作,即让 root 指向新创建的结点,会同步修改上一层调用中对应的指针。如此一来,新创建的结点就被正确地链接到了树的相应位置,完成了结点插入操作,随后返回 true,用以表明插入成功 。 - 若 root 不为空,则比较 root->_key 与 key 的大小。
如果 root->_key < key,则继续在 root 的右子树中递归调用_InsertR 进行插入操作;
如果 root->_key > key,则在 root 的左子树中递归调用_InsertR。
如果 root->_key 等于 key,表示树中已存在该值,插入失败,返回 false。
(3)优势:这种方法简洁高效,通过引用传递,避免了额外处理新结点与父结点链接关系的复杂逻辑。因为当找到插入位置(root 为空)时,直接对 root 赋值新结点,就自动完成了与父结点的链接,无需区分是左链接还是右链接。
1.2.方法:传递父结点参数(不建议)
(1)思路描述:该方法通过在子函数参数列表中增加父结点指针 parent,将子函数_InsertR (Node* root, const K& key) 改为_InsertR (Node* root, Node* parent, const K& key),以便在找到插入位置时,利用 parent 来确定新结点与父结点的链接关系。
(2)代码解析
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;public://主函数bool InsertR(const K& key){return _InsertR(_root, nullptr, key);}protected://子函数bool _InsertR(Node* root, Node* parent, const K& key){if (root == nullptr){Node* newNode = new Node(key);if (parent == nullptr){_root = newNode;}else if (parent->_key < key){//父亲比插入值key小,则右链接parent->_right = newNode;}else{//父亲比插入值key大,则左链接parent->_left = newNode;}return true;}if (root->_key < key){return _InsertR(root->_right, root, key);}else if (root->_key > key){return _InsertR(root->_left, root, key);}else{return false;}}private://搜索二叉树根结点指针_rootNode* _root = nullptr;
};
- 在 InsertR 主函数中,初始调用_InsertR 时,将 parent 设为 nullptr,表示当前从根结点开始查找插入位置,且初始时没有父结点(针对空树插入情况)。
- 在_InsertR 子函数中,当 root 为空时,创建新结点 newNode。此时判断 parent,
如果 parent 为 nullptr,说明是在空树中插入结点,直接将_root 指向新结点 newNode。
如果 parent 不为 nullptr,则根据 parent->_key 与 key 的大小关系确定链接方向:若 parent->_key <key,将新结点链接到 parent 的右子结点;
如果 parent->_key > key,将新结点链接到 parent 的左子结点。完成链接后返回 true 表示插入成功。 - 若 root 不为空,同样比较 root->_key 与 key 的大小。
如果 root->_key < key,递归调用_InsertR 处理 root 的右子树,并将 root 作为新的 parent 传递下去;
如果 root->_key > key,递归调用_InsertR 处理 root 的左子树,并将 root 作为新的 parent 传递下去。若 root->_key 等于 key,返回 false 表示插入失败。
(3)缺陷:此方法虽然能实现插入功能,但代码复杂度增加。需要额外处理空树插入时 parent 为 nullptr 的特殊情况,并且在递归调用时,每次都要传递 parent 参数,使得代码结构不够简洁,可读性和维护性相对较差,因此不建议使用。
1.3.方法3
(1)思路解析:该方法通过直接判断当前结点的子树是否为空来确定插入位置。若 key 比根结点 root 大且右子树为空树(root->_right == nullptr),则将新结点直接插入到右子树位置;若 key 比根结点 root 小且左子树为空树(root->_left == nullptr),则将新结点直接插入到左子树位置。
(2)代码解析
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;public://主函数bool Insert(const K& key){if (_root == nullptr){_root = new Node(key);return true;}return _InsertR(_root, key);}protected://子函数bool _InsertR(Node* root, const K& key){if (root->_key < key){if (root->_right == nullptr){root->_right = new Node(key);return true;}return _InsertR(root->_right, key);}else if (root->_key > key){if (root->_left == nullptr){root->_left = new Node(key);return true;}return _InsertR(root->_left, key);}else{return false;}}private://搜索二叉树根结点指针_rootNode* _root = nullptr;
};
- 在_InsertR 函数中,首先比较 root->_key 与 key 的大小。若 root->_key < key,接着判断 root 的右子树是否为空。若为空,创建新结点并将其链接到 root 的右子树位置,然后返回 true 表示插入成功;若右子树不为空,则递归调用_InsertR 继续在右子树中查找插入位置。
- 若 root->_key > key,判断 root 的左子树是否为空。若为空,创建新结点并将其链接到 root 的左子树位置,返回 true;若左子树不为空,则递归调用_InsertR 在左子树中查找插入位置。
- 若 root->_key 等于 key,返回 false 表示插入失败。在 Insert 函数中,先处理空树插入的情况,若_root 为空,创建新结点并赋值给_root,返回 true;否则调用_InsertR 进行递归插入。
(3)缺陷:这种方法虽然实现了插入功能,但逻辑不够通用和灵活。它紧密依赖于当前结点的子树状态,对于更复杂的场景(如后续需要对插入操作进行扩展,增加额外的逻辑判断或处理),代码的可维护性和扩展性较差。相比之下,方法 1 通过传引用传参的方式更加简洁且具有更好的通用性,因此此方法不建议使用。
2.代码
(1)代码实现
- 引用变量特性回顾:在 C++ 中,引用变量在初始化后不能改变其指向。在二叉搜索树递归插入代码中,使用 Node*& root 作为参数,每次递归调用时,虽然实参不同(从_root 开始,逐渐变为子树的左右指针),但这并不违反引用变量的特性。因为每次递归调用创建新的栈帧,每个栈帧中的形参 root 是对应实参的别名,它们之间的绑定关系在各自栈帧创建时确定,且在栈帧生命周期内不变。
- 对插入操作的影响:以方法 1 的_InsertR 函数为例,由于 root 是引用类型,当在某个栈帧中 root 为空并创建新结点赋值给 root 时,这个操作会直接影响到上一层栈帧中对应的实参指针。例如,假设在某层栈帧中 root 是 parent 结点的左子结点指针,当 root 为空并被赋值新结点后,parent 的左子结点就正确地链接到了新结点,从而实现了新结点与父结点的链接,完成了插入操作。这种机制确保了递归插入操作能够正确地构建二叉搜索树结构。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://主函数bool InsertR(const K& key){return _InsertR(_root, key);}protected://参数说明:由于参数Node*& root使用的是引用,当层栈帧的形参root是上一层栈帧实参的别名。
// 上一层函数栈帧传入的实参,可能是左子树根结点的地址(若上一层递归处理的是左子树),
// 也可能是右子树根结点的地址(若上一层递归处理的是右子树)。
// 由于采用传引用传参,本层函数栈帧的形参root是上一层实参(即左子树或右子树根结点地址)的别名。
// 并且该实参(左/右子树根结点地址)正指向本层函数栈帧所处理子树的根结点 。
// 当子树根结点为空时,该位置就是插入位置,此时直接修改该根结点指针Node*& root的值为新创建结点的值,
// 就能将新创建的结点链接到上一层栈帧中合适的父结点位置,因为形参root作为上一层实参的别名,对形参的修改会直接反映到上一层的实参上。//注意:形参root只能使用引用传参Node*& root,而不能使用传值传参。
//若是使用传值传参Node* root,则下一层函数栈帧形参的改变无法影响到上一层函数栈帧的实参,因为传值传参时,
//下一层函数栈帧的形参是上一层实参的拷贝,修改拷贝不会影响原实参;//若是使用传引用传参Node*& root,则下一层函数栈帧形参的改变就会影响到上一层函数栈帧的实参,因为引用本质上是实参的别名,
//对形参(别名)的修改等同于对实参的修改。//子函数bool _InsertR(Node*& root, const K& key){//若根结点为空,则找到插入位置,创建新结点直接插入。if (root == nullptr){//注:使用传引用传参就不用额外判断插入结点是父结点的左孩子还是右孩子,//而是直接通过修改引用指针将新结点链接到合适的父结点位置。root = new Node(key);return true;}//key比根结点要大,则转换到右子树中插入if (root->_key < key){return _InsertR(root->_right, key);}//key比根结点小,则转换到左子树中插入else if (root->_key > key){return _InsertR(root->_left, key);}else//若key与根结点相等,则插入失败,返回false{return false;}}private:Node* _root = nullptr;
};
(2)递归调用函数栈帧的过程
二叉搜索树删除Erase — 循环版本
1.二叉搜索树删除操作的情况分析
(1)情况 1:删除叶子结点(没有孩子) - 删除方法:直接删除。
删除思路:定位到叶子结点后,直接释放该结点内存,然后将其父结点指向该叶子结点的指针置空(若父结点存在)。例如在树中删除叶子结点 4,找到该结点后直接释放,同时让其父结点的对应指针指向nullptr
。
(2)情况 2:删除只有一个孩子的结点 - 删除方法:托孤。
注意:二叉树中父结点最多管理两个孩子。
删除思路:当要删除的结点只有一个孩子时,将这个孩子 “托付” 给该结点的父结点管理,然后删除该结点。形象地说,就像自己忙不过来,把孩子交给父亲照顾。比如删除结点 6 或 14(只有一个孩子的情况),在执行托孤操作时,需先判断要删除结点是其父结点的左孩子还是右孩子:
- 当待删除结点是其父结点的左孩子时,无论该待删除结点自身的孩子是左孩子还是右孩子(若存在) ,都将其孩子连接到父结点的左指针,由父结点的左指针进行管理;
- 当待删除结点是其父结点的右孩子时,无论该待删除结点自身的孩子是左孩子还是右孩子(若存在),都将其孩子连接到父结点的右指针,由父结点的右指针进行管理。
(3)情况 3:删除有两个孩子的结点 - 删除方法:请保姆(替换法删除)。
替换法原理:当要删除的结点(目标结点)有两个孩子时,不直接删除找到的目标结点,而是寻找其目标结点的左子树中最大结点(最右结点)或者右子树中最小结点(最左结点)并用该结点的值替换目标结点的值,然后再删除这个被选中的结点。这就好比找一个合适的 “保姆” 来替代自己(删除结点)管理左右孩子(左右子树),且这个 “保姆” 要满足一定条件,即比左子树所有结点大且比右子树所有结点小,以维持搜索二叉树的特性。
注意事项:
- 不能用托孤法处理有两个孩子的结点,因为父结点最多管理两个孩子,若将两个孩子都托付给父结点,会超出其管理能力。
- 可选择左子树最大结点或右子树最小结点作为 “保姆结点”,这两种结点要么是叶子结点,要么只有一个孩子 。例如删除结点 3 和 8 时,可以从左子树最大结点或右子树最小结点中选一个作为 “保姆”,用其值替换要删除结点的值,再删除该 “保姆结点”。
(4)情况归纳
情况 1(删除叶子结点)和情况 2(删除只有一个孩子的结点)在代码实现时,删除方式可统一采用托孤法(直接删除思路类似托孤,将子树连接关系处理好后释放结点)。这两类结点的共性在于:当左孩子为空时,可将右孩子托付给父结点;当右孩子为空时,可将左孩子托付给父结点。将它们归为一类,有助于简化代码编写。
情况 1(删叶子结点)与情况 2(删单孩结点)在代码实现时删除方式统一采用托孤法。这两类结点的共性在于:判断删除结点是其父结点的左/右孩子,是其父的左孩子就将自身孩子(若有)托给父结点左指针管,是其父的右孩子则托给父结点右指针管,归为一类利于简化代码。
2.代码
2.1.删除过程
要删除元素key
,需先在二叉搜索树中查找key
。若key
不存在于树中,函数直接返回false
;若key
存在,根据要删除结点的子结点情况,可分为以下 3 种情形来处理 :
- 情况 1:删除叶子结点(没有孩子) - 直接删除
- 情况 2:删除只有一个孩子的结点(左孩子为空 或 右孩子为空) - 托孤(直接删除)
- 情况 3:删除有 2 个孩子的结点(左、右孩子都不为空) - 请保姆(替换法删除 / 间接删除)
看起来有待删除结点有 3 种情况,实际情况 1 可以与情况 2 合并起来,因此真正的删除过程如下:
①情况 1:删除叶子结点(没有孩子)或删除只有一个孩子的结点(左孩子为空 或 右孩子为空) - 删除方法:托孤(直接删除) 。
- 类型 1:删除结点的左孩子为空(只有右孩子),若删除结点是父结点的左孩子,则父结点的左指针指向删除结点的右孩子,然后再直接释放要删除的结点(直接删除)。
- 类型 2:删除结点的右孩子为空(只有左孩子),若删除结点是父结点的右孩子,则父结点的右指针指向删除结点的左孩子,然后再直接释放要删除的结点(直接删除)。
②情况 2:删除有 2 个孩子的结点(左右孩子都不为空) - 删除方法:请保姆(替换法删除 / 间接删除)
请保姆(替换法删除 / 间接删除)思路:
- 选定保姆结点:寻找删除结点右子树的最小结点(最左结点) 或 左子树的最大结点(最右结点)作为保姆结点。
- 替换值:将删除结点的值替换为保姆结点的值。
- 删除保姆结点:由于搜索二叉树不允许数据冗余,替换后需删除保姆结点。保姆结点只能是叶子结点或仅有一个孩子的结点(左孩子为空或右孩子为空 ) ,删除时参考情况 1 的托孤方法(直接删除),且在删除前要处理好保姆结点的托孤问题,即妥善调整其与父结点的指针指向关系 。 这样,通过用保姆结点值替换删除结点值,间接实现了对删除结点的删除操作,而无需直接释放删除结点。
注意:下面我只介绍使用删除结点右子树的最小结点来作为保姆结点。
2.2.二叉搜索树删除操作错误分析与解决方案
2.2.1 情况 2(删除有 2 个孩子的结点)错误写法分析
当删除有两个孩子的结点时,常见错误围绕保姆结点的选择与处理。
- 问题 1:保姆结点的子结点处理:保姆结点只能是叶子结点(没有孩子)或者只有一个孩子的结点。当保姆结点有孩子时,删除结点值被替换成保姆结点值后,准备删除保姆结点之前要考虑保姆结点自己孩子托孤给其父亲管的问题。具体来说,当保姆结点是删除结点右子树最小结点(最左结点),则保姆结点最多只有右孩子,没有左孩子;当保姆结点是删除结点左子树最大结点(最右结点),则保姆结点最多只有左孩子,没有右孩子。
- 问题 2:保姆结点与父结点的链接关系:保姆结点是自己父亲的左或右孩子是不确定的,需要单独判断。例如,当保姆结点是左子树最大结点(最右结点)时,不能认为此时保姆结点一定是自己父亲的右孩子;当保姆结点是右子树最小结点(最左结点)时,不能认为此时保姆结点一定是自己父亲的左孩子。
案例分析:
- 案例 1:当前删除结点的右子树最小结点(最左结点)不是右子树根结点,此时保姆结点一定是保姆父亲的左孩子。例如删除结点 8 时,其右子树最小结点值是 9 且不是右子树的根结点,保姆结点是父亲左孩子,且保姆结点没有孩子,保姆父亲左指针指向
nullptr
。代码实现如下:
- 案例 2:当前删除结点的右子树最小结点(最左结点)是右子树根结点,此时保姆结点一定是保姆父亲的右孩子。错误代码问题在于保姆父亲结点指针初始化为空指针
nullptr
,导致未进入while(minRight->_left)
循环,进而使pminRight
一直是空指针,在执行pminRight->_left = minRight->_right;
时发生对空指针解引用错误。解决思路是不将保姆父亲结点指针pminRight
初始化为nullptr
,而是初始化为cur
。正确代码逻辑如下:
2.2.2 情况 1(删除叶子结点 或 只有一个孩子的结点)错误写法分析
(1)错误情况描述:在删除叶子结点或只有一个孩子的结点时,若当前删除结点是根结点,使用托孤法会存在问题。因为在满足删除结点只有一个孩子或没有孩子的前提下,若当前删除结点是根结点,在使用托孤法直接删除时,会对空指针的父结点指针parent
进行解引用风险。例如错误代码:
解决方案:
- 思路 1(建议):当要删除只有一个孩子的搜索二叉树根结点时,不建议使用托孤法,而是直接更新搜索二叉树根结点指针
_root
的值,然后释放当前要删除的结点cur
。因为使用托孤法可能会导致对空指针的父结点指针parent
进行解引用的风险,而直接更新_root
指针,可避免该问题,更安全、直接地完成根结点的删除操作。代码如下:
- 思路 2(不建议):采用替换法进行删除。具体是查找当前根结点的左子树最大结点或右子树最小结点,将其值替换根结点的值,以此实现删除操作。该过程不仅要执行查找替代结点的操作,还需细致处理后续的指针调整,包括替代结点自身及其与父结点等相关指针的变动,使得代码实现较为繁琐,故不推荐使用。
2.2.3 删除搜索二叉树根结点的两种情况对比
(1)删除有 2 个孩子的根结点:采用请保姆(替换删除法) 。需注意保姆结点指针初始值不能是nullptr
,否则会出现空指针解引用风险。因为保姆结点指针初始值是nullptr
且被正确初始化在cur
之后,删除任意位置带有 2 个孩子的结点,对不会对父结点指针产生影响。
- 保姆结点一定存在父结点,原因如下:
(2)删除只有 1 个孩子或没有孩子的根结点:不建议使用托孤删除方式,因为可能存在对空指针的父结点指针parent
进行解引用风险。推荐直接更新搜索二叉树根结点指针_root
的值完成删除 ;不建议采用替换法删除(代码复杂)。
2.3.代码实现
在二叉搜索树的删除操作中,根据待删除结点(存储目标key
的结点)的子结点数量,可将删除逻辑分为两类:
- 情况 1:托孤法删除:适用于待删除结点为叶子结点(无孩子)或仅有一个孩子的结点。此方法通过将子结点(若存在)直接连接到待删除结点的父结点,实现结点删除,可形象称为 “托孤”。若待删除结点为根结点,则直接更新根指针指向其子结点。
- 情况 2:替换删除法(请保姆):针对有两个孩子的待删除结点 。该方法通过选取左子树最大结点或右子树最小结点作为 “保姆结点”,将其值替换待删除结点的值,随后删除该保姆结点。由于保姆结点至多只有一个孩子,删除保姆结点时可复用情况 1 的托孤逻辑,完成指针调整。
bool Erase(const K& key)
{Node* parent = nullptr;//parent的作用是记录删除结点的父亲Node* cur = _root;//cur的作用是查找删除结点//删除搜索二叉树key值的两个过程:查找要删除的结点 + 执行删除操作(分两种情况进行删除)while (cur){//往深度走//1.查找//当前遍历到的根结点值_key小于要删除数据key,则往右子树走,继续在右子树中查找要删除的结点if (cur->_key < key){parent = cur;cur = cur->_right;}//当前遍历到的根结点值_key大于要删除数据key,则往左子树走,继续在左子树中查找要删除的结点else if (cur->_key > key){parent = cur;cur = cur->_left;}//当前遍历到的根结点值_key等于要删除数据key,此时查找到要删除结点cur,//但是此时不知道该结点cur是父亲parent的左孩子还是右孩子。else{//2.执行删除结点的两种情况//2.1.情况1:删除叶子结点(没有孩子) 或者 删除只有一个孩子的结点 —— 删除方法“托孤//判断要托孤的是左孩子还是右孩子//(1)左为空。(删除结点)自己要托孤的是右孩子。if (cur->_left == nullptr){//若当前删除结点是根结点if (cur == _root)//或者写成判断父亲为空:if (parent == nullptr){//若当前删除结点是根结点,则直接更新根结点指针_root的值来完成删除根结点的操作,而不是使用托孤法来删除根结点_root = cur->_right;}else//若当前删除结点不是根结点{//判断当前删除结点cur是父亲结点parent的左孩子还是右孩子,//这影响删除结点cur自己(右)孩子是与父亲左链接还是右链接。if (parent->_left == cur)//当前删除结点是父亲的左孩子,左链接{//若父亲parent左孩子left等于当前删除结点cur,//则是左链接,自己(右)孩子作为父亲左子树的根结点。parent->_left = cur->_right;}else//当前删除结点是父亲的右孩子,右链接{//若父亲parent右孩子right等于当前删除结点cur,//则是右链接,自己(右)孩子作为父亲右子树的根结点。parent->_right = cur->_right;}}delete cur;}//(2)右为空。(删除结点)自己要托孤的是左孩子。else if (cur->_right == nullptr){//若当前删除结点是根结点if (cur == _root){//若当前删除结点是根结点,则直接更新根结点指针_root的值来完成删除根结点的操作,而不是使用托孤法来删除根结点_root = cur->_left;}else{//判断当前删除结点cur是父亲结点parent的左孩子还是右孩子,//这影响删除结点cur自己(左)孩子是与父亲左链接还是右链接。if (parent->_left == cur)//当前删除结点是父亲的左孩子,左链接{//若父亲parent左孩子left等于当前删除结点cur,//则是左链接,自己(左)孩子作为父亲左子树的根结点。parent->_left = cur->_left;}else//当前删除结点是父亲的右孩子,右链接{//若父亲parent右孩子right等于当前删除结点cur,//则是右链接,自己(左)孩子作为父亲右子树的根结点。parent->_right = cur->_left;}}delete cur;}//注:情况2是左右都不为空,即删除结点有2个孩子,则自己不敢托孤给父亲管,只能请保姆来管自己2个孩子。else//2.2.情况2:删除有2个孩子的结点 —— 删除方法:请保姆{//找右树最小结点 或者 左树最大结点 来替代自己(删除结点)来管理2个孩子//注意:保姆结点父亲pminRight初始化时一定不能初始化为空指针,否则在某些场景下会对保姆父亲pminRight = nullptr空指针进行解引用造成程序崩溃问题。Node* pminRight = cur;//pminRight作用是记录右子树最小结点(保姆结点)的父亲Node* minRight = cur->_right;//minRight作用是找当前删除结点(根结点)cur的右子树最小结点(保姆结点 )。//注意:此时minRight已经是删除结点的右孩子while (minRight->_left)//由于右子树最小结点是子树的最左边的结点,则一直往左走就可以找到最左结点(最小结点){pminRight = minRight;minRight = minRight->_left;//一直往左走}//把右子树中最小结点(最左结点)的值填补(替换)到被删除结点中,这样右子树最小结点值将作为保姆(搜索二叉树根结点)来管自己(删除结点)的左右孩子(即左右子树)。cur->_key = minRight->_key;//注意:该保姆结点可能是叶子结点(没有孩子) 或者 是只有一个孩子的结点。当保姆结点是个只有一个孩子的结点时,完成删除结点的值替换成保姆结点的值后,//准备删除保姆结点之前要考虑保姆结点自己孩子要托孤给保姆结点的父亲管。//当我们是找右子树最小结点作为保姆结点,则不管保姆结点是叶子结点 或者 是只有一个(右)孩子的结点,保姆结点minRight都把自己右孩子托孤给自己父亲pminRight进行托管。//注意:右子树最小结点作为保姆结点,则保姆结点最多只有右孩子,没有左孩子。//删除保姆结点之前,要考虑保姆结点托孤问题。//判断保姆结点是自己父亲的左孩子还是右孩子。if (pminRight->_left == minRight)//左链接{//保姆父亲的左指针指向保姆结点的右孩子pminRight->_left = minRight->_right;}else//右链接{//保姆父亲的右指针指向保姆结点的右孩子pminRight->_right = minRight->_right;}//替换删除法,删除保姆结点(右子树最小结点)。delete minRight;}return true;}}//当cur走到空,则说明搜索二叉树没有查找到要删除的值key。return false;
}
二叉搜索树删除Erase — 递归版本
1.思路分析
要删除元素key
,需先在二叉搜索树中查找key
。若key
不存在于树中,函数直接返回false
;若key
存在,根据要删除结点的子结点情况,可分为以下 3 种情形来处理 :
- 情况 1:删除叶子结点(没有孩子) - 直接删除
- 情况 2:删除只有一个孩子的结点(左孩子为空 或 右孩子为空) - 托孤(直接删除)
- 情况 3:删除有 2 个孩子的结点(左、右孩子都不为空) - 请保姆(替换法删除 / 间接删除)
看起来有待删除结点有 3 种情况,实际情况 1 可以与情况 2 合并起来,因此真正的删除过程如下:
①情况 1:删除叶子结点(没有孩子)或删除只有一个孩子的结点(左孩子为空 或 右孩子为空) - 删除方法:托孤(直接删除) 。
- 类型 1:删除结点的左孩子为空(只有右孩子),若删除结点是父结点的左孩子,则父结点的左指针指向删除结点的右孩子,然后再直接释放要删除的结点(直接删除)。
- 类型 2:删除结点的右孩子为空(只有左孩子),若删除结点是父结点的右孩子,则父结点的右指针指向删除结点的左孩子,然后再直接释放要删除的结点(直接删除)。
②情况 2:删除有 2 个孩子的结点(左右孩子都不为空) - 删除方法:请保姆(替换法删除 / 间接删除)
请保姆(替换法删除 / 间接删除)思路:
- 选定保姆结点:寻找删除结点右子树的最小结点(最左结点) 或 左子树的最大结点(最右结点)作为保姆结点。
- 替换值:将删除结点的值替换为保姆结点的值。
- 删除保姆结点:由于搜索二叉树不允许数据冗余,替换后需删除保姆结点。保姆结点只能是叶子结点或仅有一个孩子的结点(左孩子为空或右孩子为空 ) ,删除时参考情况 1 的托孤方法(直接删除),且在删除前要处理好保姆结点的托孤问题,即妥善调整其与父结点的指针指向关系 。 这样,通过用保姆结点值替换删除结点值,间接实现了对删除结点的删除操作,而无需直接释放删除结点。
实现Erase递归版本时,对于情况2,删除保姆结点有2种方式
- 方法 1(不推荐):直接赋值与手动删除法 - 采用将保姆结点值直接赋值给待删除结点的方式,随后手动处理保姆结点的删除。具体而言,当待删除结点存在两个子结点时,找到左子树最大结点(或右子树最小结点)作为保姆结点,将其值赋予待删除结点。由于此操作会导致树中出现重复数据,需手动处理保姆结点的删除。删除时,需仔细判断保姆结点与父结点的指针关系,完成托孤操作(调整父结点指针指向保姆结点的子结点) 后再释放内存,代码实现较为繁琐,容易出错。
-
方法 2(推荐):值交换与递归删除法 - 通过交换保姆结点和待删除结点的值,避免数据冗余。当待删除结点有两个子结点时,同样找到左子树最大结点作为保姆结点,将二者的值互换。此时,待删除的目标值转移到了保姆结点,而保姆结点最多只有一个子结点(或无子结点)。利用递归调用删除函数
_EraseR
,将问题转化为删除只有一个子结点或叶子结点的情况(即情况 1),复用已有的托孤逻辑自动完成保姆结点的删除,简化了代码逻辑,降低了出错概率,使代码更简洁、高效。
注意:下面只介绍使用删除结点左子树的最大结点来作为保姆结点。
2代码实现
注:子函数_Erase的参数root使用传引用传参Node*& root,则root既是当层函数栈帧处理当前树的根结点,又是上一层函数栈帧根结点的左指针 或 右指针 的别名。需要注意的是上一层函数栈帧根结点是下一层函数栈帧根结点的父结点。
2.1.方法 1 (不建议) - 使用手动托孤法删除保姆结点,代码复杂
思路:若删除结点符合情况 1,则直接使用托孤法直接删除结点;若删除结点符合情况 2,在找到保姆结点后,把保姆结点值赋值给删除结点值,此时二叉树会出现数据冗余,然后手动直接删除保姆结点这个冗余数据。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://主函数bool EraseR(const K& key){return _EraseR(_root, key);}
protected://子函数 - 删除递归版本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:删除叶子结点(没有孩子) 或 删除只有一个孩子的结点 - 删除方法://托孤法(托孤后,直接删除key值所在结点)if (root->_left == nullptr) {root = root->_right;}else if (root->_right == nullptr) {root = root->_left;}else {//情况2:删除有2个孩子的结点 - 删除方法:请保姆(替换法删除,把key所在删除结点//值替换成保姆结点值,然后通过删除保姆结点来间接删除key)Node* pmaxLeft = root;//pmaxLeft作用是查找左子树最大结点(保姆结点)的父亲Node* maxLeft = root->_left;//maxLeft作用是查找左子树最大结点(保姆结点)//找保姆结点 - 左子树最大结点while (maxLeft->_right)//一直往右走,才可以在左子树中找到最大结点{pmaxLeft = maxLeft;maxLeft = maxLeft->_right;//一直往右走}//替换删除法:把删除结点值替换成保姆结点值,然后直接删除保姆结点即可完成//删除key值的操作。root->_key = maxLeft->_key;//使用赋值替换方式建议删除key值//删除保姆结点maxLeft之前,要把保姆结点maxLeft自己孩子托孤给自己//父亲pmaxLeft进行托管。//注:用左子树最大结点作为保姆结点,则该保姆结点只有左孩子,而没有右孩子if (pmaxLeft->_left == maxLeft)//若保姆结点父亲左指针等于保姆结点,//则保姆结点就是自己父亲的左孩子{//保姆结点父亲左指针指向保姆结点的左孩子pmaxLeft->_left = maxLeft->_left;//左链接}else //若保姆结点父亲右指针等于保姆结点,则保姆结点就是自己父亲的右孩子{//保姆结点父亲右指针指向保姆结点的左孩子pmaxLeft->_right = maxLeft->_left;//右链接}//删除保姆结点del = maxLeft;}//直接删除key所在的删除结点delete del;return true;}}
};
2.2.方法2(建议) - 递归复用_Erase情况 1 的托孤逻辑删除保姆结点 ,简化代码结构。
思路:若删除结点符合情况 1,则直接使用托孤法直接删除结点;若删除结点符合情况 2,在找到保姆结点后,将保姆结点值和删除结点值进行交换,此时二叉树的数据不会冗余,但是key
被交换到保姆结点所在位置,而保姆结点原来的值会交换到删除结点位置。此时只需递归调用_EraseR
,转换到情况 1 使用托孤法来删除key
所在的保姆结点(保姆结点最多只有一个孩子)。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public:bool EraseR(const K& key){return _EraseR(_root, key);}
protected://_Erase函数的参数Node*& root采用引用传递,它既是当前函数栈帧处理的子树根结点,//也是上一层函数栈帧中根结点(即当前根结点的父结点)的左指针或右指针的别名//(对应root->left或root->right)。递归删除时,对root的修改会直接更新父结点指针,//从而实现子树的正确链接与结点删除,确保二叉搜索树结构的完整性与操作的正确性。bool _EraseR(Node*& root, const K& key){//root为空,说明在树中未找到要删除的结点,删除操作失败,返回false。if (root == nullptr)return false;//若key大于当前根结点的值,则在右子树中递归查找并删除key所在的结点。if (root->_key < key){return _EraseR(root->_right, key);}//若key小于当前根结点的值,则在左子树中递归查找并删除key所在的结点。else if (root->_key > key){return _EraseR(root->_left, key);}//key与当前根结点的值相等,执行删除结点的操作。else{//保存当前要删除的结点del,防止后续修改root指针后无法正确释放该结点的内存。Node* del = root;//开始准备删除操作,分情况处理//情况1:删除叶子结点(没有孩子)或只有一个孩子的结点,采用托孤法删除(将保姆结点的子结点托付给其父结点)//若当前删除结点的左孩子为空,将父结点的指针(由root引用表示)指向其右孩子。if (root->_left == nullptr){root = root->_right;}//若当前删除结点的右孩子为空,将父结点的指针(由root引用表示)指向其左孩子。else if (root->_right == nullptr){root = root->_left;}//情况2:删除有两个孩子的结点,采用交换法删除//先找左子树中的最大结点(最右结点)作为保姆结点,用来替换要删除的结点。else{//注意:maxleft不能使用引用Node*&,因为Node*& maxleft = root->_left会使maxleft//成为根结点左指针的别名,对maxleft赋值会破坏原搜索二叉树的结构,改变原本的链接方向。Node* maxleft = root->_left;//在左子树中不断向右查找,找到左子树最大结点作为保姆结点。while (maxleft->_right){maxleft = maxleft->_right;}//采用交换法,交换保姆结点和删除结点的值,然后删除保姆结点。//必须使用交换操作,不能使用赋值操作。因为交换后保姆结点的值就是要删除的key值,这样在下次递归//调用_EraseR(root->_left, key)时,能通过key找到保姆结点,并利用情况1的托孤法完成保姆结点的//子结点托孤和保姆结点的删除操作。//若使用赋值,会导致树中出现两个 key 值的冗余数据。此时递归调用_EraseR (root->_left, key),//因原删除结点与保姆结点值相同,无法通过 key 唯一确定需删除的保姆结点,进而无法利用引用变量 Node*& root //完成托孤和删除操作。swap(root->_key, maxleft->_key);//删除保姆结点,采用托孤法。若使用左子树最大结点作为保姆结点,该保姆结点最多只有左孩子。//在删除保姆结点前,需要将保姆结点的子结点托付给其父结点(通过递归调用_EraseR实现)。//由于保姆结点在左子树中,所以递归调用_EraseR(root->_left, key),来完成保姆结点的托孤操作和删除操作。//必须传root->_left,不能传保姆结点地址maxleft。//若传maxleft,形参Node*& root只是保姆结点地址的别名,不是保姆结点父结点左/右指针的别名,//无法找到保姆结点的父结点进行托孤操作。//形参Node*& root作为保姆结点地址的别名与作为保姆结点父结点左/右指针的别名有本质区别,//若为前者,修改root值无意义;若为后者,修改root值会改变上一层栈帧中保姆结点父结点的指针指向。return _EraseR(root->_left, key);}//释放要删除的结点内存,对于情况1,直接删除该结点。delete del;return true;}}private:Node* _root = nullptr;
};
- 不能使用
return _EraseR(maxleft, key);
而要使用return _EraseR(root->_left, key)
的原因:
在递归调用_EraseR
删除保姆结点(key
所在结点)时,实参必须传root->_left
,而不能传保姆结点地址maxleft
。因为函数bool _EraseR(Node*& root, const K& key)
中的形参Node*& root
需要是保姆结点父亲左 / 右指针的别名,这样才能在删除保姆结点时,通过修改root
来实现对保姆结点孩子的托孤操作(即调整保姆结点父亲的指针指向) 。若传maxleft
,Node*& root
就只是保姆结点地址的别名,修改root
无法影响到保姆结点父亲的指针,也就无法完成托孤操作。
2.3.方法 1 和方法 2 在删除保姆结点的区别
- 方法 1:采用赋值替换的方式,将保姆结点的值赋给删除结点,然后手动处理保姆结点的托孤和删除。这种方式会使二叉树在赋值后存在数据冗余(因为原删除结点值和保姆结点值同时存在了),并且手动处理托孤的代码较为繁琐,需要明确判断保姆结点与父结点的指针关系来进行调整。
- 方法 2:采用交换值的方式,将保姆结点和删除结点的值进行交换,避免了数据冗余问题。然后通过递归调用
_EraseR(root->_left, key)
,利用函数本身在情况 1 中处理托孤的逻辑来自动完成保姆结点的托孤和删除操作,代码更为简洁和优雅,也减少了出错的可能性。
构造函数
1.构造函数规则总结
- 编译器生成规则:在 C++ 中,如果没有显式编写任何构造函数(包括默认构造函数、拷贝构造函数等),编译器会自动生成一个默认构造函数。但只要显式定义了任意一个构造函数,编译器就不会再自动生成默认构造函数。若此时代码中需要默认构造函数的功能,就必须显式编写,可以是无参构造函数,或者参数都有缺省值的构造函数 。
- 成员变量缺省值与初始化列表:在 C++11 及后续版本中,允许在成员变量声明时提供缺省值。当构造对象时,如果在构造函数的初始化列表中没有对某个成员变量进行显式初始化,编译器会自动使用该成员变量声明时的缺省值来初始化它。需要注意的是,静态成员变量不能在声明位置给缺省值初始化,因为静态成员变量不属于某个对象,不经过构造函数初始化列表 。
- 拷贝构造函数与默认构造函数关系:如果为类(如搜索二叉树类
BSTree
)显式编写了拷贝构造函数,编译器不会生成默认构造函数。若后续代码需要使用默认构造函数(比如在某些场景下需要创建空的搜索二叉树对象 ),就需要显式编写默认构造函数。编写默认构造函数时,可以利用缺省值来初始化树的相关成员(比如根节点等 )。
2.代码实现
(1)写法 1:显式编写默认构造函数
在这个示例中,通过显式编写默认构造函数,将根节点_root
初始化为nullptr
。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://显示写默认构造函数BSTree():_root(nullptr){}private:Node* _root;
};
(2)写法 2:使用default
关键字强制生成默认构造函数
此示例中,使用default
关键字让编译器生成默认构造函数,并且在成员变量声明时给_root
提供了缺省值nullptr
。当使用此默认构造函数创建对象时,如果没有在初始化列表中对_root
进行其他初始化,_root
将被初始化为nullptr
。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://不显示写默认构造函数的实现体,使用关键字default指定强制生成默认构造BSTree() = default; private://只有提供构造函数(编译器提供默认构造函数 或 显示写默认/带参构造函数),//缺省值才能在构造函数的初始化列表中起作用的。Node* _root = nullptr;
};
拷贝构造函数(深拷贝 - 递归版本)
1.不能通过Insert
函数进行拷贝的原因
原因:由于搜索二叉树的特性,插入顺序不同会导致搜索二叉树的形状不同。因此,不能通过Insert
函数边遍历边插入来完成搜索二叉树的拷贝,否则拷贝后的树结构可能与原树不同。
2.代码实现
在拷贝构造函数中,通过调用Copy
函数,采用前序遍历的方式递归地拷贝每一个节点。先创建根节点,然后递归地创建左子树和右子树,最后返回完整的拷贝树。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://拷贝构造函数BSTree(const BSTree<K>& t){_root = Copy(t._root);}
protected://深拷贝 - 递归版本//拷贝思路:一个结点一个结点的拷贝Node* Copy(Node* root){//若是空树,则没有必要拷贝,并返回空指针nullptr。if (root == nullptr)return nullptr;//若不是空树,才进行拷贝//注意:拷贝搜索二叉树不用考虑搜索二叉树特性,而是直接使用前序遍历整个搜索二叉树,//边遍历结点边拷贝。搜索二叉树的拷贝和普通二叉树的拷贝思路是一样的。//拷贝思路:前序遍历拷贝。- 前序创建,后序链接。解析:通过前序创建好子树,//通过后序把根结点与创建好的左右子树进行链接,最终创建出整棵搜索二叉树。//拷贝根结点:创建根结点Node* newRoot = new Node(root->_key);//左链接,递归拷贝创建左子树newRoot->_left = Copy(root->_left);//右链接,递归拷贝创建右子树newRoot->_right = Copy(root->_right);//返回创建好的搜索二叉树return newRoot;}private:Node * _root = nullptr;
};
赋值重载(非递归版本 - 交换资源)
在赋值重载函数中,采用传值传参,形参t
是实参的拷贝,是一个局部变量。通过调用std::swap
函数交换当前对象的_root
和形参t
的_root
,实现资源交换,完成赋值操作。最后返回*this
,以便支持链式赋值。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://现代写法//赋值重载(深拷贝) - 思路:交换资源//使用传值传参:形参BSTree<K> t是实参的拷贝,则形参BSTree<K> t是个局部变量BSTree<K>& operator=(BSTree<K> t){//由于形参t是个局部变量,则直接调用 std::swap交换自己和局部变量的资源,进而完成赋值操作swap(_root, t._root);return *this;//传引用返回,返回赋值完后的自己}
private:Node* _root = nullptr;
};
析构函数(递归版本 - 后序遍历删除)
1.方法1:子函数使用传值传参Node* root
在这种实现中,析构函数调用Destroy
函数,采用后序遍历的方式递归删除树的所有节点。先递归删除左子树,再递归删除右子树,最后删除根节点。由于子函数Destroy
使用传值传参,在删除根节点后,需要在析构函数中手动将_root
置为nullptr
。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://析构函数~BSTree(){Destroy(_root);_root = nullptr;}protected://递归版本 - 后序递归删除//析构函数思路:后序遍历释放搜索二叉树所有结点void Destroy(Node* root)//传值传参{//遇到空树,则空树没有必要删除,则直接返回即可。if (root == nullptr)return;//后序递归删除//先递归删除左子树Destroy(root->_left);//再递归函数右子树Destroy(root->_right);//最后删除当前搜索二叉树的根结点delete root;}private:Node* _root = nullptr;
};
2.方法2:子函数使用传引用传参Node*& root
在此实现中,Destroy
函数使用传引用传参。在后序遍历删除节点后,当删除根节点时,通过将引用形参root
置为nullptr
,可以直接将原根节点指针_root
置为nullptr
,无需在析构函数中额外处理。
template<class K>
class BSTree
{typedef BSTreeNode<K> Node;
public://析构函数~BSTree(){Destroy(_root);}protected://递归版本 - 后序递归删除//析构函数思路:后序遍历释放搜索二叉树所有结点void Destroy(Node*& root){//遇到空树,则空树没有必要删除,则直接返回即可。if (root == nullptr)return;//后序递归删除//先递归删除左子树Destroy(root->_left);//再递归函数右子树Destroy(root->_right);//最后删除当前搜索二叉树的根结点delete root;//Destroy(Node*& root)使用传引用传参的目的是当删除搜索二叉树根结点时//可以把根结点指针_root设置为空指针nullptr,因为y引用形参Node*& root//此时是根结点指针_root的别名,则root = nullptr对引用形参root设置为空//指针nullptr就是对根结点指针_root设置为空指针nullptr。root = nullptr;}private:Node* _root = nullptr;
};
二叉搜索树性能分析
1.性能与查找操作的关系
插入和删除操作都必须先进行查找,所以查找效率代表了二叉搜索树中各个操作的性能。对于有 n
个结点的二叉搜索树,若每个元素查找的概率相等,那么二叉搜索树平均查找长度是结点在二叉搜索树深度的函数,即结点越深,比较次数越多。
2.树结构对性能的影响
- 最好情况:当二叉搜索树为完全二叉树(或者接近完全二叉树)时,其平均比较次数为
logN
,时间复杂度为O(logN)
。 - 最坏情况:当二叉搜索树退化为单支树(或者类似单支)时,其平均比较次数为
N
,时间复杂度为O(N)
。由于退化后的二叉搜索树性能大幅下降,为了防止这种最坏情况发生,即控制左右子树均衡(使其近似为完全二叉树),提出了平衡搜索二叉树,如 AVL 树、红黑树等。AVL 树和红黑树都是搜索二叉树,并且都有控制左右子树均衡的操作,而普通搜索二叉树没有此类操作。
二叉搜索树应用 - 搜索场景
K 模型 - 在不在问题
1. 应用场景概述
K 模型主要用于解决在搜索二叉树中查找 key
值是否存在的问题,也就是 “在不在” 的问题。这类问题在现实生活中有广泛的应用,以下是一些常见的例子。
2. 具体应用场景及原理
(1)校园门禁系统
- 系统构成:每个学生的学生卡芯片存储着诸如学号、姓名等学生信息。门禁系统主要由读卡设备和后台处理系统组成,后台处理系统中假设将该校所有学生信息存储在内存中的搜索二叉树里。
- 查找过程:当学生刷卡时,门禁系统的读卡设备读取卡中芯片的学生信息,提取出学号作为
key
。然后,从搜索二叉树的根节点开始进行查找。若key
比当前根结点的值大,就往右子树中继续查找;若key
比当前根结点的值小,就往左子树中继续查找;若key
与当前根结点的值相等,则说明该学生是该校学生,门禁系统放行;若在查找过程中遇到空树都未找到该key
,则说明该学生不是该校学生,门禁系统阻止进入。 - 优势:使用搜索二叉树进行查找,在平均情况下可以达到 O(logn) 的时间复杂度,能够快速判断学生是否为该校学生,提高门禁系统的响应速度。
(2)小区车库系统
- 系统构成:小区车库系统包括入口的车牌识别设备和后台的车辆信息数据库,该数据库采用搜索二叉树存储小区所有业主车的信息,如车牌号、车主姓名等。
- 查找过程:当车辆进入小区车库时,车牌识别设备识别出车牌号作为
key
,然后在搜索二叉树中进行查找。同样按照搜索二叉树的查找规则,若找到对应的key
,则说明该车是小区业主的车,车库系统抬杠放行;若未找到,则说明不是小区的车,车库系统不抬杠。 - 优势:利用搜索二叉树的查找特性,能够快速判断车辆是否为小区业主的车,提高车库管理的效率。
(3)停车场停车收费系统
- 系统构成:停车场停车收费系统由入口的车辆信息采集设备、出口的车辆信息识别设备和后台的数据库组成,数据库采用搜索二叉树存储车辆的信息,包括车牌号、入库时间等。
- 操作过程:当车辆入库时,车辆信息采集设备记录车辆的所有信息,如车牌号、入库时间(精确到年月日时分秒),并将这些信息作为一个节点插入到搜索二叉树中,同时直接抬杠放行。当某车出库时,出口的车辆信息识别设备识别出车牌号作为
key
,在搜索二叉树中查找该车的信息。若找到对应的key
,则获取该车的入库时间,通过当前时间与入库时间相减计算出停车时长,根据停车时长计算停车费用,车主支付停车费后,车库系统抬杠放行。 - 优势:使用搜索二叉树可以快速查找车辆的入库信息,准确计算停车费用,提高停车场的管理效率和收费准确性。
(4)检查英文文章拼写错误
- 系统构成:首先需要构建一个包含所有正确单词的词库,将词库中的所有单词作为
key
插入到搜索二叉树中。 - 检查过程:对于一篇英文文章,逐词提取单词作为
key
,在搜索二叉树中进行查找。若文章中的单词在搜索二叉树中未找到,则说明该单词拼写错误;若找到,则说明该单词拼写正确。 - 优势:利用搜索二叉树的查找功能,可以快速检查英文文章中的拼写错误,提高文章的检查效率。
3. K 模型定义
K 模型即只有 key
作为关键码,结构中只需存储 Key
即可,关键码即为需要搜索到的值。例如,判断一个单词 word
是否拼写正确,可将词库中所有单词作为 key
构建一棵二叉搜索树,然后在树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。
KV 模型 - 通过一个值查询另一个值的问题
1. 应用场景概述
KV 模型主要用于解决通过一个值查询另一个值的问题,即通过一个东西 key
查找另外一个东西 value
。这种模型在现实生活中有很多实际应用。
2. 具体应用场景及原理
(1)中英文互译字典
- 系统构成:构建一个中英文互译的搜索树,每个节点包含一个英文单词作为
key
和对应的中文释义作为value
。 - 查询过程:当用户输入一个英文单词作为
key
时,在搜索树中进行查找。按照搜索二叉树的查找规则,若找到对应的key
,则可以获取该英文单词对应的中文释义value
;若用户输入中文,同样可以构建以中文为key
,英文为value
的搜索树进行查询。 - 优势:使用搜索二叉树可以快速实现中英文的互译查询,提高查询效率。
(2)快递信息查询
- 系统构成:快递公司的数据库采用搜索二叉树存储快递信息,以电话号码作为
key
,快递的详细信息(如快递单号、收件地址、收件人姓名等)作为value
。 - 查询过程:当用户输入电话号码作为
key
时,在搜索二叉树中进行查找。若找到对应的key
,则可以获取该电话号码对应的快递详细信息value
。 - 优势:利用搜索二叉树的查找特性,能够快速准确地查询到快递信息,提高快递服务的效率和用户体验。
(3)考试成绩查询
- 系统构成:学校的考试成绩管理系统采用搜索二叉树存储学生的考试成绩信息,以电话号码和验证码组合作为
key
,学生的考试成绩作为value
。 - 查询过程:当学生输入电话号码和验证码作为
key
时,在搜索二叉树中进行查找。若找到对应的key
,则可以获取该学生的考试成绩value
。 - 优势:使用搜索二叉树可以快速查询学生的考试成绩,同时通过电话号码和验证码的组合提高查询的安全性。
(4)统计单词出现的次数
- 系统构成:构建一个搜索二叉树,以单词作为
key
,单词出现的次数作为value
。 - 统计过程:对于一篇文章,逐词提取单词作为
key
,在搜索二叉树中进行查找。若找到对应的key
,则将该单词对应的value
(出现次数)加 1;若未找到,则将该单词作为新的key
插入到搜索二叉树中,并将其value
初始化为 1。 - 优势:利用搜索二叉树可以高效地统计单词出现的次数,便于对文章进行分析和处理。
3. KV 模型定义
每一个关键码 key 都有与之对应的 Value,即 <Key, Value> 的键值对。这种方式在现实生活中非常常见,例如英汉词典中,英文单词与其对应的中文 <word, chinese> 构成一种键值对;统计单词次数时,单词与其出现次数 <word, count> 构成一种键值对。在 KV 模型的搜索二叉树中,结点除了存放 key,还存放其对应值 val,插入 / 删除规则按照 key 进行,找到 key 就可以找到对应的 val。
二叉搜索树模拟实现 - K模型
1.在 C++ 中,递归传引用传参具有两种情况
- 每次传参变量名不同:当下一层函数栈帧接收传引用的参数时,其形参只是上一层函数栈帧中对应实参的别名。这意味着对下一层函数栈帧中的形参进行修改,会直接影响到上一层函数栈帧中的实参,因为它们本质上指向同一块内存空间。例如在二叉搜索树的递归插入函数
_InsertR(Node*& root, const K& key)
中,每一层递归调用时root
虽然在不同栈帧中,但它们都关联到上一层调用传递进来的对应指针变量,所以对root
的修改(如root = new Node(key)
)会影响到上一层调用中相应指针的值。 - 每次传参都是同名变量:在整个递归过程中,全局的函数栈帧使用的都是同一个变量。下一层函数栈帧对该变量进行修改后,会影响全局所有函数栈帧中同名变量的值。这是因为它们引用的是同一个对象,在内存中只有一份存储。总的来说,就是全局都是同一个变量。
2. 二叉搜索树模拟实现 - K 模型
#include<iostream>
using namespace std;//代码定义key命名空间,在该命名空间内实现了二叉搜索树的 K 模型。
namespace key
{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;public://BSTree()// :_root(nullptr)//{}BSTree() = default; // 制定强制生成默认构造BSTree(const BSTree<K>& t){_root = Copy(t._root);}BSTree<K>& operator=(BSTree<K> t){swap(_root, t._root);return *this;}~BSTree(){Destroy(_root);}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){parent->_right = cur;}else{parent->_left = cur;}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;}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{//删除//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;}//2、右为空else if (cur->_right == nullptr){if (cur == _root){_root = cur->_left;}else{if (parent->_left == cur){parent->_left = cur->_left;}else{parent->_right = cur->_left;}}delete cur;}else{//找右树最小节点替代,也可以是左树最大节点替代Node* pminRight = cur;Node* minRight = cur->_right;while (minRight->_left){pminRight = minRight;minRight = minRight->_left;}cur->_key = minRight->_key;if (pminRight->_left == minRight){pminRight->_left = minRight->_right;}else{pminRight->_right = minRight->_right;}delete minRight;}return true;}}return false;}bool FindR(const K& key){return _FindR(_root, key);}bool InsertR(const K& key){return _InsertR(_root, key);}bool EraseR(const K& key){return _EraseR(_root, key);}void InOrder(){_InOrder(_root);cout << endl;}protected: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;}void Destroy(Node*& root){if (root == nullptr)return;Destroy(root->_left);Destroy(root->_right);delete root;root = nullptr;}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->_right, key);elsereturn _FindR(root->_left, key);}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 _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;// 开始准备删除if (root->_right == nullptr){root = root->_left;}else if (root->_left == nullptr){root = root->_right;}else{Node* maxleft = root->_left;while (maxleft->_right){maxleft = maxleft->_right;}swap(root->_key, maxleft->_key);return _EraseR(root->_left, key);}delete del;return true;}}void _InOrder(Node* root){if (root == nullptr)return;_InOrder(root->_left);cout << root->_key << " ";_InOrder(root->_right);}private:Node* _root = nullptr;};
}
测试:
void TestBSTree1()
{int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };key::BSTree<int> t1;for (auto e : a){t1.Insert(e);}//t1.InOrder(t1.GetRoot());t1.InOrder();/*t1.Erase(4);t1.InOrder();t1.Erase(14);t1.InOrder();t1.Erase(3);t1.InOrder();*/t1.Erase(8);t1.InOrder();for (auto e : a){t1.Erase(e);t1.InOrder();}t1.InOrder();
}void TestBSTree2()
{int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };key::BSTree<int> t1;for (auto e : a){t1.InsertR(e);}t1.InOrder();t1.EraseR(10);t1.EraseR(14);t1.EraseR(13);t1.InOrder();for (auto e : a){t1.EraseR(e);t1.InOrder();}t1.InOrder();
}void TestBSTree3()
{int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };key::BSTree<int> t1;for (auto e : a){t1.InsertR(e);}t1.InOrder();key::BSTree<int> t2(t1);t2.InOrder();
}int main()
{TestBSTree1();return 0;
}
二叉搜索树模拟实现 - KV模型
1.模型特性差异说明
(1)数据存储结构差异:在 K 模型中,二叉搜索树的结点仅存储一个关键码K
,主要用于解决 “在不在” 的问题,即判断某个key
是否存在于树中。而 KV 模型的二叉搜索树结点除了存储关键码K
,还存储与之对应的值V
,用于通过一个key
查询另一个value
的场景。
(2)函数接口差异
- 插入函数:KV 模型的
Insert
函数需要在 K 模型Insert
函数的基础上,增加一个参数const V& value
,用于存储与key
对应的value
值。这是因为在 KV 模型中,每个key
都有其特定的关联值,插入操作需要同时保存这两者。 - 查找函数:K 模型的
Find
函数返回值为bool
类型,仅用于判断key
是否存在。而 KV 模型的Find
函数返回值修改为Node*
类型,因为在 KV 模型中,我们不仅要知道key
是否存在,更重要的是在找到key
后,能够通过返回的结点获取与之对应的value
值。 - 删除函数:在 KV 模型中,
Erase
函数与 K 模型保持一致,无需进行改动。因为删除操作主要是基于key
进行的,在删除结点时,其对应的value
会随着结点的删除而自然移除,且比较规则始终基于const K& key
形参,不涉及对value
的特殊处理。
2.KV 模型二叉搜索树代码实现
结点结构体
-
template<class K, class V> struct BSTreeNode {BSTreeNode<K, V>* _left;BSTreeNode<K, V>* _right;K _key;V _value;BSTreeNode(const K& key = K(), const V& value = V()):_left(nullptr), _right(nullptr), _key(key), _value(value){} };
- 该结构体定义了 KV 模型二叉搜索树的结点。除了与 K 模型类似的左右子结点指针
_left
、_right
和关键码_key
外,还增加了一个成员变量_value
,用于存储与key
对应的值。 - 构造函数使用默认参数,当创建结点时,如果没有显式传入
key
和value
,则会调用K
和V
类型的默认构造函数来初始化_key
和_value
。否则,使用传入的参数进行初始化。
#include<string>
#include<iostream>
using namespace std;//命名空间:代码定义key_value命名空间,用于封装 KV 模型二叉搜索树的相关实现,避免命名冲突。
namespace key_value
{//KV模型 - 搜索二叉树结点类模板定义template<class K, class V>struct BSTreeNode{BSTreeNode<K, V>* _left;BSTreeNode<K, V>* _right;K _key;V _value;//默认构造函数BSTreeNode(const K& key = K(), const V& value = V()):_left(nullptr), _right(nullptr), _key(key), _value(value){}};template<class K, class V>class BSTree{typedef BSTreeNode<K, V> Node;public://注意:插入/删除左右规则都是跟着key走即比较都是用key去比较,不用管value,//value只是key附带的而已,不用管。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;//若找到了,则返回查找值key所在结点}}return nullptr;//若没有找到,则返回空指针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{// 删除// 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;} // 2、右为空else if (cur->_right == nullptr){if (cur == _root){_root = cur->_left;}else{if (parent->_left == cur){parent->_left = cur->_left;}else{parent->_right = cur->_left;}}delete cur;}else{// 找右树最小节点替代,也可以是左树最大节点替代Node* pminRight = cur;Node* minRight = cur->_right;while (minRight->_left){pminRight = minRight;minRight = minRight->_left;}cur->_key = minRight->_key;if (pminRight->_left == minRight){pminRight->_left = minRight->_right;}else{pminRight->_right = minRight->_right;}delete minRight;}return true;}}return false;}void InOrder(){_InOrder(_root);cout << endl;}protected:void _InOrder(Node* root){if (root == nullptr)return;_InOrder(root->_left);cout << root->_key << ":" << root->_value << endl;_InOrder(root->_right);}private:Node* _root = nullptr;};
}//场景1:中英文互译字典 - 输入英文找中文
void TestBSTree1()
{//字典:dictionarykey_value::BSTree<string, string> dict;//若key存放英文,value存放中文,则就是实现输入英文找中文//若key存放中文,value存放英文,则就是实现输入中文找英文dict.Insert("sort", "排序");dict.Insert("left", "左边");dict.Insert("right", "右边");dict.Insert("string", "字符串");dict.Insert("insert", "插入");dict.Insert("erase", "删除");//在 Windows 系统下,在控制台输入Ctrl + Z然后按下回车键可以模拟文件结束符(EOF);//在 Unix/Linux 和 macOS 系统下,输入Ctrl + D可以模拟 EOF。当std::cin读取到 EOF 或者//发生其他错误(如输入格式错误)时,std::cin的状态会被设置为无效。在布尔上下文中//(如while循环的条件判断),std::cin会被转换为布尔值,无效状态下会转换为false,从而导致循环终止。//终止程序的方式:Ctrl + Z + 换行string str;while (cin >> str)//在 Windows 系统的控制台中输入Ctrl + Z 模拟文件结束符 EOF 就可以介绍while循环。 {auto ret = dict.Find(str);if (ret){cout << ":" << ret->_value << endl;}else{cout << "无此单词" << endl;}}
}//场景2:统计一堆水果中每种水果出现次数
//或者 统计英文文章中每个单词出现次数,从而找出高频单词。
void TestBSTree2()
{//自动调用string默认构造函数初始化静态数组中每个string类型的元素string arr[] = { "西瓜", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉", "梨" };//统计树countTreekey_value::BSTree<string, int> countTree;//遍历一遍所有水果,然后在搜索二叉树中查找该水果是否存在。若不存在,则把该水果插入搜索二叉树中;若存在,则统计该水果出现次数。for (auto str : arr)//范围for可以遍历静态数组{//auto会自动推导类型auto ret = countTree.Find(str);//或者写成 key_value::BSTreeNode<string, int>* ret = countTree.Find(str);if (ret == nullptr){//在搜索二叉树中没有查找到该水果名,就把该水果名存储(插入)到搜索二叉树中countTree.Insert(str, 1);}else//若在搜索二叉树中找到该水果名,就对该水果名所在结点的成员_value执行++操作就可以实现通过一堆水果中每种水果出现次数。{ret->_value++;}}//中序遍历countTree.InOrder();
}int main()
{TestBSTree1();return 0;
}