【C++】二叉搜索树(二叉查找树、二叉排序树)详解

【C++】二叉搜索树(二叉查找树、二叉排序树)详解

文章目录

一、概念

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

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

从二又搜索树的定义可知,它的前提是二叉树,并且采用了递归的方式进行定义,它的结点间满足一个偏序关系,左子树根结点的值定比父结点小,右子树根结点的值一定比父结点大。

正如它的名字所说,构造这样一棵树的目的是为了提高搜索的速度,如果对二叉搜索树进行中序遍历,我们可以发现,得到的序列是个递增序列。

递归:(1) 左右子树本身是二叉搜索树无论从哪个节点开始,只需考察:当前节点的值是否满足与左子树和右子树的比较关系;然后递归到左右子树去判断它们是否分别满足二叉搜索树的性质。

这种对左右子树重复性质的要求是递归定义的直接体现。(2) 以根节点为核心的分层定义+ 当前树的性质由当前根节点与左右子树的性质共同决定; + 左子树和右子树本身可以被视为规模更小的二叉搜索树,这种嵌套结构直接表明递归定义的存在。

二、定义

//树节点的结构体structBSTreeNode{typedef BSTreeNode<K> Node;//千万别少了这句!!!!!! Node* _left; Node* _right; K _key;BSTreeNode(const K& key)//这里是K是大写,一定要注意,改了好多:_left(nullptr),_right(nullptr),_key(key){}};template<classK>classBSTree{typedef BSTreeNode<K> Node;public://默认构造BSTree()=default;//拷贝构造BSTree(const BSTree<K>& t){ _root =Copy(t._root);}private://下面这部分隐藏,对外只提供InOrder().用户只用关注接口不必关心细节。 Node*Copy(Node* root){if(root ==nullptr){returnnullptr;} Node* newRoot =newNode(root->_key);//通过递归调用Copy函数来复制当前节点的左子节点。将返回的新左子树赋值给newRoot的左子节点 newRoot->_left =Copy(root->_left); newRoot->_right =Copy(root->_right);return newRoot;} Node* _root =nullptr;}

默认构造函数

强制生成默认构造BSTree() = default

在上面完整的代码中,BSTree() 默认构造函数是通过以下代码定义的:

BSTree()=default;

= default 的作用

  • 生成默认构造函数:
    • 当我们写 BSTree() = default; 时,编译器会为类自动生成一个默认的构造函数。
    • 它的行为等价于一个空的用户定义构造函数,但由编译器自动完成。
  • 为什么用 = default
    • 显式告诉编译器生成默认构造函数,表示我们明确需要这个默认的构造行为。
    • 避免用户误以为我们忘记定义构造函数。
    • 保持代码简单,同时遵守编译器生成的高效实现。

为什么需要显式生成默认构造函数?

BSTree 类包含一个私有成员变量 _root

Node* _root =nullptr;
  • 原因 1: 明确默认构造需求
    如果没有声明任何构造函数,编译器会自动生成默认构造函数,且会将 _root 初始化为 nullptr(因为你用了类内初始化 Node* _root = nullptr;)。但如果你显式定义了其他构造函数(比如拷贝构造、赋值重载),编译器将不再自动生成默认构造函数。为避免丢失默认构造函数的功能,就需要显式使用 = default;
  • 原因 2: 保持代码的可读性与语义
    通过显式声明 BSTree() = default;,代码更直观地表明:
    “这个类的默认构造行为是由编译器生成的,我没有进行额外的修改。”

= default 和手动定义的区别

如果不使用 = default 而是手动定义默认构造函数:

BSTree():_root(nullptr){}

行为与 = default 等价,但显式手动定义不如 = default 简洁。

= default 的好处包括:

  1. 让编译器自动生成,减少代码量和维护负担。
  2. 避免意外错误,例如忘记初始化成员变量。

适用场景

使用 = default 常见于以下场景:

  1. 需要默认构造函数,但有其他特殊构造函数:
    如果定义了其他构造函数(如拷贝构造、移动构造等),而仍需要默认构造函数,必须显式声明它。
  2. 需要提高代码语义的清晰度:
    即使编译器会自动生成默认构造函数,显式 = default 更直观。

赋值运算符重载函数

 BSTree<K>&operator=(const BSTree<K>& t){swap(_root, t._root);return*this;}//传统深拷贝写法。也很好//BSTree<K>& operator =(const BSTree<K>& t)//{// //tree1=tree2// if (this != &t)//// {// Destory(_root);//清空tree1树里的节点,树还在,但是空树// _root = Copy(t._root);//把tree2里的节点深拷贝到tree1// }// return *this;//返回tree1;//}

赋值重载swap写法详解

这种写法很高效,节省资源,一定要掌握!!!

我们通过一个详细的例子来说明写法二(swap 方式)中的每一步,以及各个变量(如 thist)的变化。

代码回顾

template<classK>classBSTree{typedef BSTreeNode<K> Node;public: BSTree<K>&operator=(BSTree<K> t){swap(_root, t._root);return*this;}private: Node* _root =nullptr;};

假设情景

假设有两个二叉搜索树对象 tree1tree2,它们的树结构如下:

  • tree1:树的根节点为 _root = 5
  • tree2:树的根节点为 _root = 10

现在我们要将 tree1 赋值为 tree2,即 tree1 = tree2;

初始状态

  • tree1._root:指向 5 节点。
  • tree2._root:指向 10 节点。

接下来我们逐步讲解赋值运算符 tree1 = tree2; 的执行过程。


第一步:函数调用时,创建临时副本t

BSTree<K>&operator=(BSTree<K> t)
  • tree1 = tree2; 发生时,tree2 作为参数传递给 BSTree<K> t,因此会调用 复制构造函数,为 t 创建一个 tree2 的副本。
  • 在此过程中,t._root 指向了 tree2 的根节点(即 10),但 t 是一个独立的对象,内存完全独立于 tree2

变量状态:

  • this:指向 tree1this->_root = 5
  • t._root:指向 tree2 的副本,即根节点为 10

第二步:执行 swap

swap(_root, t._root);

这里使用的是标准库中的 std::swap 函数。该函数的核心是交换两个对象的值,具体步骤如下:

  1. 创建临时变量 temptemp 保存 this->_root(即 tree1_root),此时 temp = 5
  2. t._root 赋给 this->_root:此时 this->_root 指向 t._root 的值(即 10),也就是 tree2 的根节点。
  3. temp 赋给 t._root:最后,t._root 变为 5,即之前 tree1 的根节点。

swap 操作结束后,tree1t 的根节点已经交换。

变量状态:

  • this->_root:现在指向 10,即 tree2 的根节点。
  • t._root:现在指向 5,即 tree1 原来的根节点。

第三步:返回当前对象(链式调用支持)

return*this;
  • operator= 函数返回当前对象,即 tree1,此时 tree1._root = 10
  • t 是传递进来的临时变量,会在函数结束时自动销毁。由于 t._root 现在指向的是 5(即 tree1 原来的根节点),当 t 离开作用域时,其析构函数会被调用,自动释放 5 这个节点及其子树(如果有)。

变量状态:

  • this->_root:保持指向 10,即 tree2 的根节点。
  • t:将在函数结束时销毁,t._root 指向 5 的那部分内存会被释放。

终结果

  • tree1:现在拥有 tree2 的树结构,根节点为 10
  • tree2:原来的结构不变,仍然指向 10,但由于我们是通过副本 t 操作,tree2 本身不会受到影响。
  • 旧的 tree1 的树(即以 5 为根的树):在 t 的生命周期结束时,随着 t 的析构函数被调用,旧的 tree1 树被销毁。

关键点总结:

  1. 临时副本:通过传值的方式,将 tree2 的副本传递给 tt 独立于 tree2
  2. 高效的 swapswap 交换了 tree1t 的根节点指针,而不是深拷贝整个树,效率更高。
  3. 资源管理:由于 t 是传入的临时变量,赋值过程结束后 t 会被自动销毁,释放旧的 tree1 的内存,避免了手动 Destroy 的麻烦。
  4. 链式赋值return *this 支持链式调用,如 tree1 = tree2 = tree3;

这种 swap 的写法,简洁、高效,并且通过临时对象 t 自动管理资源,是现代 C++ 中推荐的写法。

传统深拷贝写法中为什么需要return *this?

为什么返回 *this?即使 tree1 的内存已被释放?

  1. 释放的只是原树的节点,树本身仍然存在

调用 Destory(_root) 时,仅释放了 tree1 原有的节点内存,但 tree1 本身(即当前对象)仍然存在。_root 只是一个指针成员变量,而非整个 tree1 对象的内存。

例如:

  • 如果 tree1 原本有数据,它们会被 Destory 清空,变成一个空树(_root == nullptr)。
  • 然后通过 Copy(t._root),重新构造 tree1 的数据结构,使其变得与 tree2 相同。

最终,tree1 并没有被销毁,而是被重建。


  1. 返回值的意义:支持链式赋值

返回 *this 是赋值运算符的标准设计,用于支持链式赋值。链式赋值允许连续的赋值操作,如:

BSTree<int> a, b, c; a = b = c;
  • 流程
    1. b = c 执行后,返回 b 的引用。
    2. 然后将 b 的引用作为参数传递给 a = b

如果不返回 *this,链式赋值将无法工作。


  1. 为什么不是返回销毁的内容?

Destory(_root) 只是清空了树的原有数据,tree1 的对象本身还存在。返回 *this 是返回当前对象的引用,表示赋值操作完成,当前对象的状态被更新为目标对象的状态。

例如:

BSTree<int> tree1, tree2;// 假设 tree1 有旧的节点,tree2 是目标树 tree1 = tree2;

tree1 = tree2 过程中:

  1. 清空 tree1 的原节点,但 tree1 本身还在。
  2. tree2 的数据复制到 tree1
  3. 返回 tree1 的引用,表示 tree1 现在是 tree2 的深拷贝。

  1. 示意图说明

初始状态

  • tree1:包含若干节点,_root 指向一棵树。
  • tree2:包含若干节点,_root 指向另一棵树。

操作后

  • Destory(_root) 释放 tree1 的所有节点,_root 变成 nullptr
  • Copy(t._root) 创建一棵新的树,将 tree2 的节点深拷贝到 tree1
  • tree1 现在与 tree2 的内容一致。

链式赋值

a = b = c;

等效于:

b = c;// b 变为 c 的拷贝 a = b;// a 变为 b 的拷贝 (实际上 a 也变为 c 的拷贝)

每次赋值后,*this 返回当前对象的引用供下一个操作使用。


析构函数~BSTree()

~BSTree(){Destory(_root);}

中序遍历InOrder()

voidInOrder(){_InOrder(_root); cout << endl;}void_InOrder(Node* root){if(root ==nullptr)return;_InOrder(root->_left); cout << root->_key <<" ";_InOrder(root->_right);}

InOrder()为何这样设计?

InOrder() 函数设计的目的是为了封装实现细节,让用户只需调用接口函数即可完成对二叉树的中序遍历,而不必了解树的内部数据结构。这种设计的优势在于以下几点:

  1. 隐藏内部实现细节

在代码中,InOrder() 调用了一个私有的辅助递归函数 _InOrder(Node* root)

voidInOrder(){_InOrder(_root); cout << endl;}

用户只需要调用 tree.InOrder() 就可以触发中序遍历,输出节点值,而不需要知道:

  • 树的节点结构 (BSTreeNode<K> 的定义)。
  • 遍历的逻辑细节。
  • 遍历需要从根节点 _root 开始。

这种封装遵循了面向对象编程中的封装原则,屏蔽了用户不需要知道的复杂实现。


  1. 简化用户操作

对于用户来说,他们关心的是功能,而非具体实现。用户只需要:

tree.InOrder();

而不需要自己写出复杂的递归逻辑去遍历树,例如:

voidInOrderManual(BSTreeNode<int>* root){if(!root)return;InOrderManual(root->_left); cout << root->_key <<" ";InOrderManual(root->_right);}

封装之后,用户无需处理树的节点指针,也不用理解递归过程,降低了使用门槛。


  1. 降低错误概率

让用户直接访问和操作树的内部结构(如 _root 指针)可能导致以下问题:

  • 不小心破坏了树的完整性(如误修改了 _root 或其他节点的子指针)。
  • 错误遍历逻辑:用户可能误用树的节点指针,造成逻辑混乱。

通过只暴露 InOrder() 方法,用户无法直接接触内部的 _root 或树节点,减少了误操作的风险。


  1. 增强代码的可维护性和扩展性

如果以后需要修改中序遍历的实现(例如加入线程或并行化),我们只需改动私有的 _InOrder() 方法,而无需修改 InOrder() 或影响用户代码。

用户调用 InOrder() 的方式保持不变,代码改动对外透明。这种设计符合模块化设计的思想。

三、查找、插入

1.查找

  • 从根开始比较,查找,比根大则往右边走查找,比根小则往左边走查找。
  • 最多查找高度次,走到到空,还没找到,这个值不存在

另外需要注意的是
①最大元素一定是在树的最右分枝的端结点上。
②最小元素一定是在树的最左分枝的端结点上。

//find函数。 用k,关键字的意思 boolFind(const K& key){ Node* cur = _root;// 从根节点开始查找//_root 是存储在 BSTree 类中的成员变量,它指向树的根节点。通过这个指针,你可以访问整个树。while(cur){if(cur->_key < key){ cur = cur->_right;}elseif(cur->_key > key){ cur = cur->_left;}else{returntrue;}}returnfalse;}

2.插入

  1. 特殊情况处理:
    如果树是空的(即 root == nullptr),那么直接把要插入的元素作为根节点插入即可。
  2. 基本规则:
    ○ 因为是二叉搜索树,所以不能插入重复元素
    ○ 插入的元素一定会成为某个叶子节点(即没有子节点的位置)。
  3. 具体操作思路:
    ○ 从根节点开始定义一个指针变量 cur 来遍历树,初始时 cur = root。
    ○ 如果插入的值比当前节点的值小,就向左子树移动;如果插入的值比当前节点的值大,就向右子树移动。
    ○ 在移动时,还需要定义另一个变量 parent 记录当前节点的“父节点”,也就是 cur 移动前的位置。这样,当找到一个空位时,知道应该插入在 parent 的左子树还是右子树。
  4. 最后一步:
    ○ 当 cur 移动到空位置(即 cur == nullptr)时,根据 parent 的位置决定插入新元素:
    ■ 如果插入的值小于 parent 的值,插入到 parent 的左子树;
    ■ 如果插入的值大于 parent 的值,插入到 parent 的右子树。
    这样就完成了插入操作。
//插入//类的成员函数(如 Insert)中,成员变量(_root)是直接可见的,无需通过参数传递。//这是因为成员函数默认会操作类的当前实例。boolInsert(const K& key){//Node* cur = new Node(key);//这里的 new 后面加什么还是不太清楚。New的用法忘了,及时复习。if(_root ==nullptr){ _root =newNode(key);returntrue;}//关键:保存父节点 Node* parent =nullptr;//Node* cur = root; 错误写法 Node* cur = _root;//不是rootwhile(cur){if(cur->_key < key){ parent = cur; cur = cur->_right;}elseif(cur->_key > key){ parent = cur; cur = cur->_left;}else{returnfalse;//重复了,树里面已经有了key。插入不了。}}//此时 cur为空,为待插入的位置//cur = new Node(key); 这句又忘了//if (parent->_right == nullptr)//{// parent->_left = cur;//}//else//{// parent->_left = cur;//}//上面的写法是错的。//假如插入16,此时 parent为14,cur 为空,应该判断key与parent的大小关系,而不是14左右子树是否为空。//按照错误逻辑,16插入到14的左子树了// 8// 3 10 //1 6 14 //  cur =newNode(key);if(parent->_key < key){ parent->_right = cur;}else{ parent->_left = cur;}returntrue;}

四、删除

首先查找元素是否在二叉搜索树中,如果不存在,则返回, 否则要删除的结点可能分下面四种情
况:
a. 要删除的结点无孩子结点
b. 要删除的结点只有左孩子结点
c. 要删除的结点只有右孩子结点
d. 要删除的结点有左、右孩子结点
看起来有待删除节点有4中情况,实际情况a可以与情况b或者c合并起来,因此真正的删除过程
如下:
情况b:删除该结点且使被删除节点的双亲结点指向被删除节点的左孩子结点–直接删除
情况c:删除该结点且使被删除节点的双亲结点指向被删除结点的右孩子结点–直接删除
情况d:在它的右子树中寻找中序下的第一个结点(关键码最小),用它的值填补到被删除节点
中,再来处理该结点的删除问题–替换法删除

  • a,b,c情况——直接删除

将待删除节点的父节点指向待删除节点的孩子节点

  • d情况(要删除的结点有左、右孩子结点) ——替换法删除

先假删除根节点8.

我们的目标依然是要保证删除结点8后,再次中序遍历它,仍不改变其升序的排列方式。 那么我们只有用7或者10来替换8原来的位置

用7替换待删除节点

用10替换待删除节点

为什么是7或者10来替换8的位置?

显然,7与10是挨着8的,如果用其他元素替换则会打扰其顺序。那7和10怎么在二叉排序树中找到呢?

显然,7在8左子树的“最右边”,10在8右子树的“最左边”。根据二叉排序树的插入方式,比8小的元素一定在左子树,而我们又要找到比8小的最大的数,这样才能保证他们俩在顺序上是挨着的,所以它又会在8的左子树的最右边。同理也可以找到10.
boolErase(const K& key){ Node* parent =nullptr; Node* cur = _root;while(cur){if(cur->_key < key){ parent = cur; cur = cur->_right;}elseif(cur->_key > key){ parent = cur; cur = cur->_left;}//找到了待删除的节点。else{//左子节点为空(或者是叶子节点):if(cur->_left ==nullptr){if(cur == _root){ _root = cur->_right;}else{/* 10 / \ 5 15 / \ 3 7*///假如删除,15是10的右,把15的右(空),给10的右。//由此可知:左子节点为空 这部分代码可以解决叶子节点的情况if(cur == parent->_right){ parent->_right = cur->_right;}else{ parent->_left = cur->_right;}}delete cur;returntrue;}// 右子节点为空:elseif(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;returntrue;}//左右子节点均不为空: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;returntrue;}}}returnfalse;}

下面一张图也可以帮助理解。

五、性能分析

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

对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二

叉搜索树的深度的函数,即结点越深,则比较次数越多。

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

最优情况下,二叉搜索树为完全二叉树(或者接近完全二叉树),其平均比较次数为: l o g 2 N log_2 N log2​N

最差情况下,二叉搜索树退化为单支树(或者类似单支),其平均比较次数为: N 2 \frac{N}{2} 2N​

六、应用——KV模型

  1. K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到
    的值。
    比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:
    以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树
    在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。
  2. KV模型:每一个关键码key,都有与之对应的值Value,即<Key, Value>的键值对。该种方
    式在现实生活中非常常见:
    比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英
    文单词与其对应的中文<word, chinese>就构成一种键值对;
    再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出
    现次数就是<word, count>就构成一种键值对。
  3. 统计次数
//统计词语出现的次数 string strs[]={"苹果","西瓜","苹果","樱桃","苹果","樱桃","苹果","樱桃","苹果"};// 统计水果出现的次 BSTree<string,int> countTree;for(auto str : strs){auto ret = countTree.Find(str);if(ret ==NULL){ countTree.Insert(str,1);}else{ ret->_value++;}} countTree.InOrder();

<K,V>模型代码

namespace key_value {// ======================// 二叉搜索树节点结构体// ======================template<classK,classV>structBSTreeNode{// 为了简化书写,用 Node 表示 BSTreeNode<K, V> 类型本身typedef BSTreeNode<K, V> Node; Node* _left;// 指向左子节点 Node* _right;// 指向右子节点 K _key;// 节点存储的键 V _value;// 节点存储的值// 构造函数,用来初始化节点的 key 和 valueBSTreeNode(const K& key,const V& value):_left(nullptr),_right(nullptr),_key(key),_value(value){}};// =====================// 二叉搜索树类// =====================template<classK,classV>classBSTree{typedef BSTreeNode<K, V> Node;public:// 构造函数BSTree():_root(nullptr){}// 向二叉搜索树中插入一个键值对 (key, value)// 返回值:如果插入成功返回 true,如果插入失败(已存在相同key)返回 falseboolInsert(const K& key,const V& value){// 如果树为空,则直接创建根节点if(_root ==nullptr){ _root =newNode(key, value);returntrue;}// 从根节点开始,寻找正确的插入位置 Node* parent =nullptr; Node* cur = _root;while(cur){// 若当前节点 key 小于要插入的 key,则往右子树查找if(cur->_key < key){ parent = cur; cur = cur->_right;}// 若当前节点 key 大于要插入的 key,则往左子树查找elseif(cur->_key > key){ parent = cur; cur = cur->_left;}else{// 发现已有相同 key,插入失败returnfalse;}}// 创建新节点 cur =newNode(key, value);// 判断要插入到 parent 的左子树还是右子树if(parent->_key < key){ parent->_right = cur;}else{ parent->_left = cur;}returntrue;}// 查找给定 key 对应的节点,返回指向该节点的指针,如果不存在则返回 nullptr Node*Find(const K& key){ Node* cur = _root;while(cur){if(cur->_key < key){ cur = cur->_right;}elseif(cur->_key > key){ cur = cur->_left;}else{// 找到 keyreturn cur;}}// 未找到 keyreturnnullptr;}// 从二叉搜索树中删除 key 对应的节点,成功返回 true,失败返回 falseboolErase(const K& key){ Node* parent =nullptr;// 记录要删除节点的父节点 Node* cur = _root;// 从根节点开始查找// 1. 先找到要删除的节点 cur 及其父节点 parentwhile(cur){if(cur->_key < key){ parent = cur; cur = cur->_right;}elseif(cur->_key > key){ parent = cur; cur = cur->_left;}else{// 找到了要删除的节点// 2. 根据 cur 是否存在子树分情况处理// a) cur 没有左子树if(cur->_left ==nullptr){// 如果 cur 是根节点,则直接将根设置为右子树if(cur == _root){ _root = cur->_right;}else{// 如果 cur 不是根节点,则根据 parent 判断// cur 是 parent 的左子树还是右子树if(cur == parent->_right){ parent->_right = cur->_right;}else{ parent->_left = cur->_right;}}delete cur;returntrue;}// b) cur 没有右子树elseif(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;returntrue;}// c) cur 既有左子树又有右子树else{// 使用替换法:找到 cur 的右子树中的最小值节点替换掉 cur Node* rightMinParent = cur; Node* rightMin = cur->_right;// 在右子树中一直往左走,找到最小值节点while(rightMin->_left){ rightMinParent = rightMin; rightMin = rightMin->_left;}// 用最小值节点替换当前节点 cur->_key = rightMin->_key; cur->_value = rightMin->_value;// 将最小值节点删除if(rightMin == rightMinParent->_left) rightMinParent->_left = rightMin->_right;else rightMinParent->_right = rightMin->_right;delete rightMin;returntrue;}}}// 没有找到要删除的 keyreturnfalse;}// 中序遍历:从小到大打印 keyvoidInOrder(){_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;// 指向二叉搜索树的根节点};}

七、完整代码

template<classK>//树节点的结构体structBSTreeNode{typedef BSTreeNode<K> Node;//千万别少了这句!!!!!! Node* _left; Node* _right; K _key;BSTreeNode(const K& key)//这里是K是大写,一定要注意,改了好多:_left(nullptr),_right(nullptr),_key(key){}};template<classK>//二叉搜索树这个类classBSTree{typedef BSTreeNode<K> Node;public://默认构造BSTree()=default;//拷贝构造BSTree(const BSTree<K>& t){ _root =Copy(t._root);}//赋值重载//完整的函数名每次忘记怎么写//BSTree<K>& :返回值类型是对当前对象的引用,以便支持链式赋值(a = b = c; )。//operator=:定义赋值运算符。//const BSTree<K>& t:参数类型是一个const引用,表示要赋值的对象不应被修改。//使用 swap 交换资源 BSTree<K>&operator=(const BSTree<K>& t){swap(_root, t._root);return*this;}//传统深拷贝写法。也很好(代码详解在语雀)//BSTree<K>& operator =(const BSTree<K>& t)//{// //tree1=tree2// if (this != &t)// {// Destory(_root);//清空tree1树里的节点,树还在,但是空树// _root = Copy(t._root);//把tree2里的节点深拷贝到tree1// }// return *this;//返回tree1;//}//析构~BSTree(){Destory(_root);}//插入//类的成员函数(如 Insert)中,成员变量(_root)是直接可见的,无需通过参数传递。这是因为成员函数默认会操作类的当前实例。boolInsert(const K& key){//Node* cur = new Node(key);//这里的 new 后面加什么还是不太清楚。New的用法忘了if(_root ==nullptr){ _root =newNode(key);returntrue;}//关键:保存父节点 Node* parent =nullptr;//Node* cur = root; Node* cur = _root;//不是rootwhile(cur){if(cur->_key < key){ parent = cur; cur = cur->_right;}elseif(cur->_key > key){ parent = cur; cur = cur->_left;}else{returnfalse;//重复了,树里面已经有了key。插入不了。}}//此时 cur为空,为待插入的位置//cur = new Node(key); 这句又忘了//if (parent->_right == nullptr)//{// parent->_left = cur;//}//else//{// parent->_left = cur;//}//上面的写法是错的。//假如插入16,此时 parent为14,cur 为空,应该判断key与parent的大小关系,而不是14左右子树是否为空。//按照错误逻辑,16插入到14的左子树了// 8// 3 10 //1 6 14 //  cur =newNode(key);if(parent->_key < key){ parent->_right = cur;}else{ parent->_left = cur;}returntrue;}boolErase(const K& key){ Node* parent =nullptr; Node* cur = _root;while(cur){if(cur->_key < key){ parent = cur; cur = cur->_right;}elseif(cur->_key > key){ parent = cur; cur = cur->_left;}//找到了待删除的节点。else{//左子节点为空(或者是叶子节点):if(cur->_left ==nullptr){if(cur == _root){ _root = cur->_right;}else{/* 10 / \ 5 15 / \ 3 7*///假如删除,15是10的右,把15的右(空),给10的右。//由此可知:左子节点为空 这部分代码可以解决叶子节点的情况if(cur == parent->_right){ parent->_right = cur->_right;}else{ parent->_left = cur->_right;}}delete cur;returntrue;}// 右子节点为空:elseif(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;returntrue;}//左右子节点均不为空: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;returntrue;}}}returnfalse;}//find函数。 用k,关键字的意思 boolFind(const K& key){ Node* cur = _root;// 从根节点开始查找// Node* cur = nullptr;//为什么不行?//_root 是存储在 BSTree 类中的成员变量,它指向树的根节点。通过这个指针,你可以访问整个树。while(cur){if(cur->_key < key){ cur = cur->_right;}elseif(cur->_key > key){ cur = cur->_left;}else{returntrue;}}returnfalse;}//前序遍历voidInOrder(){_InOrder(_root); cout << endl;}//------------------------------------------------------------------------------------------------------------------//递归实现//这样的设计通常是为了提供递归操作的接口,允许用户以更简单的方式调用递归函数。以下是这些函数的作用://bool FindR(const K& key)// 递归查找键值是否存在于树中。// 实际上调用 _FindR 函数。boolFindR(cosnt K& key){return_FindR(_root,, key);}// bool InsertR(const K& key)// 递归插入一个键值到树中。// 实际上调用 _InsertR 函数。boolInsertR(const K& key){return_InsertR(_root, key);}// bool EraseR(const K& key)// 递归删除一个键值。// 实际上调用 _EraseR 函数。boolEraseR(const K& key){return_EraseR(const K & key);}private://下面这部分隐藏,对外只提供InOrder().用户只用关注接口不必关心细节。void_InOrder(Node* root){if(root ==nullptr)return;_InOrder(root->_left); cout << root->_key <<" ";_InOrder(root->_right);} Node*Copy(Node* root){if(root ==nullptr){returnnullptr;} Node* newRoot =newNode(root->_key);//通过递归调用Copy函数来复制当前节点的左子节点。将返回的新左子树赋值给newRoot的左子节点 newRoot->_left =Copy(root->_left); newRoot->_right =Copy(root->_right);return newRoot;}//赋值运算重载的第二种写法要用到,析构函数也要用到voidDestory(Node* root){if(root ==nullptr){return;}Destory(root->_left);Destory(root->_right);delete root;}//这些函数的实现通常会更简洁,因为递归结构自然而然地处理了树的遍历:// bool _FindR(Node* root, const K& key)// 如果当前节点为空,则返回 false。// 根据键值与当前节点的比较,决定向左或向右子树递归查找。bool_FindR(Node* root,const K& key){if(root ==nullptr){returnfalse;}if(root->_key < key){return_FindR(root->_right, key);}elseif(root->_key > key){return_FindR(root->_left, key);}else{returntrue;}}// bool _InsertR(Node*& root, const K& key)// 处理插入操作的递归逻辑。// 当找到合适的插入位置(当前节点为空)时,创建新节点。bool_Insert(Node*& root,const k& key){if(root ==nullptr){//root->_key = key; root =newNode(key);returntrue;}if(root ==nullptr){returnfalse;}if(root->_key < key){return_Insert(root->_right, key);}elseif(root->_key > key){return_Insert(root->_left, key);}else{//遇到值相等的, 重复了 无法插入returnfalse;}}// bool _EraseR(Node*& root, const K& key)// 处理删除操作的递归逻辑。// 逻辑与非递归的 _Erase 类似,但通过递归方式实现。bool_EraseR(Node*& root,const K& key){//Node* parent = nullptr;//Node* cur = root;if(root ==nullptr){returnfalse;}if(root->_key < key){return_EraseR(root->_right, key);}elseif(root->_key < key){return_EraseR(root->_left, key);}else{//注意,root是上一节点的左指针(右指针)//保存当前待删除的节点。 Node* del = root;//如果左子树为空if(root->_right ==nullptr){ root = root->_left;}elseif(root->_left ==nullptr){ root = root->_right;}else{//Node* rightMin = root->_right;//while (rightMin->_left)//{// rightMin = rightMin->_left;//} Node* rightMin = root->_right;while(rightMin->_left){ rightMin = rightMin->_left;}swap(root, rightMin);return_EraseR(root->_right, key);}}}private: Node* _root =nullptr;};

八、代码中重难点

为什么有两处template <class K

在上面的代码中,出现两处 template <class K> 是因为它们的作用域和含义不同:

  1. 第一处 template <class K>
template<classK>structBSTreeNode

这一部分定义了一个模板结构体 BSTreeNode,它接受一个模板参数 K,用来指定二叉搜索树节点的关键字类型。模板参数 K 是用来描述节点内部的 _key 数据成员类型。

  1. 第二处 template <class K>
template<classK>classBSTree

这里定义了一个模板类 BSTree,它也接受一个模板参数 K,用来指定树中的节点关键字类型。BSTree 内部的 Node 类型被定义为 BSTreeNode<K>,即二叉树节点的模板参数类型与 BSTree 的模板参数一致。

为什么需要两次 template <class K>

这是因为 模板定义的作用域是独立的,模板参数的声明只能在其所属的模板中生效。也就是说:

  • BSTreeNode<K>KBSTree<K>K 是两个不同的模板参数,尽管它们名字相同,但彼此独立。
  • BSTree 中使用 BSTreeNode<K> 时,需要显式传递模板参数 K,以表明 BSTreeNode 的模板实例化。

如果模板类 BSTree 不单独定义自己的模板参数,而直接使用 BSTreeNode<K> 的模板参数,会导致代码难以扩展和维护。

bool和statue的区别

boolstatus 的区别主要取决于上下文中的定义和使用方式。以下是它们之间的一些典型区别:


1. bool 是一种数据类型

  • 描述: bool 是一种内置的布尔类型,用来表示逻辑上的真假值,取值范围是 truefalse
  • 用途: 用于条件判断、布尔表达式、逻辑运算等。
  • 常见场景:
    • 判断条件是否成立。
    • 返回是否成功的标志,例如函数的执行结果。

示例:

bool isReady =true;// 表示某个条件成立if(isReady){// 做某些事情}

2. status 是一种抽象的状态表示

  • 描述: status 一般用于表示系统、进程、任务、操作等的状态,通常不局限于 truefalse 两种值。
  • 用途: 表示多种状态(例如成功、失败、处理中、错误等),通常会被定义为枚举或特定类型。
  • 常见场景:
    • 描述复杂的操作结果或状态。
    • 提供更多上下文信息。

示例(用枚举表示 status):

enumclassStatus{ SUCCESS, FAILURE, PENDING, ERROR }; Status taskStatus = Status::PENDING;// 当前任务正在处理中if(taskStatus == Status::SUCCESS){// 操作成功}elseif(taskStatus == Status::ERROR){// 操作失败}

3. 典型区别

特性boolstatus
类型内置类型通常是枚举或扩展类型
取值范围仅有 truefalse可以有多个值(如 SUCCESS, FAILURE
信息丰富度表示简单的二元逻辑结果可以描述更复杂的状态
可扩展性不可扩展易于扩展更多状态值

4. 选择依据

  • 简单的真假判断:
    • 如果某个结果仅需表示 “是或否”(例如操作是否成功),选择 bool
  • 描述复杂状态:
    • 如果需要提供更详细的上下文信息,或者状态有多个可能值,选择 status 或类似的机制。

示例:二者结合使用

在实际工程中,可以同时使用 boolstatus,例如在函数返回值中使用 status 表示总体结果,用 bool 表示局部成功或失败的标志:

enumclassStatus{ SUCCESS, PARTIAL_SUCCESS, FAILURE, ERROR };boolisValidInput(const std::string& input){return!input.empty();// 简单的真假判断} Status processTask(const std::string& task){if(!isValidInput(task)){return Status::ERROR;// 返回复杂状态}// 处理任务逻辑...return Status::SUCCESS;}

  1. 📜 [ 声明 ] 由于作者水平有限,本文有错误和不准确之处在所难免,
  2. 本人也很想知道这些错误,恳望读者批评指正!
  3. 我是:勇敢滴勇~感谢大家的支持!

Read more

【C++】平衡树优化实战:如何手搓一棵查找更快的 AVL 树?

【C++】平衡树优化实战:如何手搓一棵查找更快的 AVL 树?

🎬 个人主页:MSTcheng · ZEEKLOG 🌱 代码仓库 :MSTcheng · Gitee 🔥 精选专栏: 《C语言》 《数据结构》 《C++由浅入深》 💬座右铭:路虽远行则将至,事虽难做则必成! 前言:前两篇文章我们已经向大家介绍了map和set这两个容器,他们的底层都是平衡二叉搜索树,而今天我们就来介绍一种平衡二叉搜索树——AVL树。 文章目录 * 一、AVL树的认识 * 1.1AVL树的概念 * 二、AVL树的实现 * 2.1AVL树的基本框架 * 2.2AVL树的插入 * 2.3AVL树的中序遍历 * 2.4AVL树其他功能实现 * 三、总结 一、AVL树的认识 1.1AVL树的概念 AVL树是由G. M. Adelson-Velsky和E. M. Landis两个前苏联的科学家所发明的,它的具体定义如下: * AVL树是最先发明的自平衡⼆叉查找树,AVL是⼀颗空树,

By Ne0inhk
【Linux/C++多进程篇(二) 】万字解析从“传纸条”到“建仓库”:一文读懂linux系统编程之进程间通信 (IPC)

【Linux/C++多进程篇(二) 】万字解析从“传纸条”到“建仓库”:一文读懂linux系统编程之进程间通信 (IPC)

⭐️在这个怀疑的年代,我们依然需要信仰。 个人主页:YYYing. ⭐️Linux/C++进阶系列专栏:【从零开始的linux/c++进阶编程】 系列上期内容:【Linux/C++多进程篇(一) 】C/C++ 程序中神奇的“分身术” 系列下期内容:【Linux/C++多线程篇(一) 】多线程编程入门 目录 前言: 进程间通信(IPC) 一、进程间通信的基础概念 二、内核提供的通信方式 2.1、无名管道  📖 无名管道的API  📖 代码案例 2.2、有名管道  📖 有名管道的API  📖 代码案例 2.3、管道特点 2.4、信号  📖 信号相关概念

By Ne0inhk

震惊!C++中这个逻辑运算符的坑,90%程序员都踩过!

今天我要分享一个C++开发中常见的逻辑陷阱,以及如何优雅地处理多个容器的非空检查。这不仅是一个语法问题,更是一种编程思维的体现。 🎯 问题背景 在实际开发中,我们经常需要检查多个容器是否都为空,或者是否有任意一个非空。比如在图形处理、数值计算等场景,我们需要确保多个数据源都已经准备就绪。 原始需求是这样的:我们有一个车辆轨迹曲线的容器 GwheelCurve,需要检查它是否为空,并将结果取反: bool success =!GwheelCurve.empty(); 但现在需求升级了!我们需要同时检查5个不同的数据容器,只要任意一个非空,就认为检查失败(success = false)。 💡 核心思想 这个问题看似简单,但隐藏着几个关键点: 1. 逻辑运算符的选择:应该用 && 还是用 ||? 2. 取反的时机:是先各自判断再组合,还是先组合再整体取反? 3. 可读性和性能的平衡 让我用几个完整的代码示例来详细说明。 📊 示例1:错误的直观写法 #include<iostream>#include&

By Ne0inhk
Elasticsearch + Kibana 实战指南:从安装部署到 C++ 客户端封装,解锁搜索引擎开发核心技能

Elasticsearch + Kibana 实战指南:从安装部署到 C++ 客户端封装,解锁搜索引擎开发核心技能

文章目录 * 本篇摘要 * 一.ES 介绍及简单使用 * 1·介绍 * 2.安装过程 * 检测是否安转成功 * 对应配置文件修改 * 3.ES核心知识概念 * **1. 索引(Index-->库)** * **2. 文档(Document)** * **3. 字段(Field)** * **4. 类型(Type-->类似表)**(7.x后已废弃) * **5. 映射(Mapping)** * 4.kibana介绍 * **Kibana 是什么?** * **Kibana 和 Elasticsearch 的关系** * 5.安装Kibana过程 * 6.kibana-es使用 * 7.es-client使用及封装使用接口 * es接口 * 1.

By Ne0inhk