数据结构复习:二叉搜索树

一、概念:二叉搜索树到底是什么

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

  1. 若它的左子树不为空 ,则左子树上所有结点的值都小于等于根结点的值;
  2. 若它的右子树不为空 ,则右子树上所有结点的值都大于等于根结点的值;
  3. 它的左右子树也分别为二叉搜索树。

1.1 第 3 条才是灵魂

前两条只是「一层」的约束,第 3 条把它变成了递归约束------整棵树的每一棵子树都必须是 BST。

这两者差别巨大。下面这棵树违反第 1 条:

复制代码
                8
              /   \
             3     10
            / \      \
           1   6      14
              / \    /
             4   7  13
                   /
                  1     ← 1 出现在 8 的【右子树】里,但 1 < 8,违反性质!

注意这个 1 的位置很阴险 :顺着它往上看,它到根的路径是 1 → 13 → 14 → 10 → 8。

  • 1 是 13 的左孩子 → 1 < 13 ✓ 局部看没问题;
  • 1 在 14 的左子树里 → 1 < 14 ✓ 也没问题;
  • 1 在 10 的右子树里 → 1 > 10?✗ 这里就已经违规了!

为什么说它「阴险」 :如果你只检查「父结点与子结点」这一层关系(1 < 13),一切正常;但 BST 要求的是每一条祖先链推出的区间约束同时成立。

用「区间收紧」的方法一眼看穿 :从根往下走,每个结点都会把合法取值区间切一刀:

走到结点 已知约束(区间)
8 整棵树无额外约束,区间为 (-∞, +∞)
往 右 到 10 必须 > 8 → 区间收窄为 (8, +∞)
往 右 到 14 必须 > 10 → 区间收窄为 (10, +∞)
往 左 到 13 必须 < 14 → 区间收窄为 (10, 14)
往 左 到 1 必须 < 13 → 区间收窄为 (10, 13)

而 1 不在 (10, 13) 里 → 违规。这就是判定的标准方法:沿路径把区间越收越窄,最后看这个值落不落在里面。

✅ 正确理解方式 :BST 的性质要求的是「整棵左子树的所有值 ≤ 根值 ≤ 整棵右子树的所有值 」,而不是「左孩子 ≤ 根 ≤ 右孩子」。这两句话在一层 上等价,在多层上完全不同------后者允许上面那个 1 蒙混过关。

由这条性质直接可以推出一个极其有用的结论:BST 的中序遍历是升序的 。这也是为什么它叫「排序树」------它把排序结果编码进了结构里。(你可以试着对上面那棵违规的树跑中序:1 3 4 6 7 8 10 1 13 14,1 出现了两次且顺序乱掉------中序不再有序,正是「坏了」的信号。)
🔧 一个实用推论:上面这套「区间收紧」的视角,正是 BST 删除时「替换法」能成立的原因(见 5.5)------ 找替身时,我们选的正是「落在被删结点那个值域空档里的结点」。

1.2 关于「相等的值」

  • BST 可以支持 插入相等的值,也可以不支持,具体看使用场景定义。
  • 后续学习的 map / set / multimap / multiset 系列容器底层就是二叉搜索树,其中:
    • map / set 不支持插入相等值;
    • multimap / multiset 支持插入相等值。

⚠️ 如果选择支持相等的值,一定要保持逻辑一致性------插入相等的值不要一会儿往右走、一会儿往左走。否则「查找中序的第一个 x」这类约定就没法成立了。


二、性能分析:O(logN) 还是 O(N)?

2.1 两种极端情况

情况 树的形态 高度 查找复杂度
最优 完全二叉树(或接近完全二叉树) log₂N O(log₂N)
最差 退化为单支树(或类似单支) N O(N)

退化长什么样? 如果按照 1, 3, 4, 6, 7, 8, 10, 13, 14 这个已经有序的序列依次插入,BST 会变成:

复制代码
1
 \
  3
   \
    4
     \
      6
       \
        7
         \
          8
           \
            10
             \
              13
               \
                14

这已经不是树了,是一条链表。 此时「查找 14」要走 9 步,和线性查找一样慢。板书画的正是这幅退化图。

2.2 为什么课件说综合复杂度是 O(N)

因为增删查改的时间复杂度取决于树高 h:

  • 查找:O(h)
  • 插入:O(h)(先查找插入位置)
  • 删除:O(h)

而 h 在 log₂N(最优)到 N(最差)之间浮动。取最坏情况,就是 O(N)。

那么这样的效率显然是无法满足需求 的。所以后续课程要继续讲二叉搜索树的变形:平衡二叉搜索树 AVL 树和红黑树,才能适用于在内存中存储和搜索数据。

2.3 为什么不用「有序数组 + 二分查找」

二分查找也能实现 O(log₂N) 级别的查找效率,但它有两大缺陷:

  1. 需要存储在支持下标随机访问的结构中,并且有序;
  2. 插入和删除数据效率很低 ------因为存储在下标随机访问的结构中(数组),插入和删除一般需要挪动数据,代价是 O(N)。

板书的对比图把这条链路画得很清楚:

复制代码
数组 ──────────────→ 数组 + 排序 + 二分查找          查找 O(logN)
                        ↑
                     数组不方便插入删除

二叉搜索树 ────────→ 查找最坏 O(N)   ← 退化问题

                    平衡搜索树                        查找 O(logN)
                    AVL Tree / 红黑树 / B树系列

                    哈希表                            查找 O(1)

这里也就体现出了平衡二叉搜索树的价值:它同时拿到了「查找快」和「插入删除快」。而理解它的前提,就是先把普通 BST 的三种操作吃透------这正是本文后面五节要做的事。


三、插入:一条路径走到空位,然后挂上去

3.1 插入的具体过程

  1. 树为空 ,则直接新增结点,赋值给 root 指针;
  2. 树不空 ,按二叉搜索树性质:插入值比当前结点大往右走 ,比当前结点小往左走,找到空位置,插入新结点;
  3. 如果支持插入相等的值 ,插入值跟当前结点相等的值可以往右走,也可以往左走,找到空位置插入新结点。(要注意的是要保持逻辑一致性,插入相等的值不要一会儿往右走,一会儿往左走。)

3.2 逐步演练:用 {8, 3, 1, 10, 6, 4, 7, 14, 13} 建树

步骤 待插入值 比较路径 动作 插入后的树
1 8 树为空 直接 _root = new Node(8) 8
2 3 8 > 3 → 往左 8 左为空,挂上 8(左:3)
3 1 8 > 1 → 左;3 > 1 → 左 3 左为空,挂上 8(左:3(左:1))
4 10 8 < 10 → 往右 8 右为空,挂上 8(左:3(左:1), 右:10)
5 6 8 > 6 → 左;3 < 6 → 右 3 右为空,挂上 8(左:3(左:1, 右:6), 右:10)
6 4 8>6→左;3<6→右;6>4→左 6 左为空,挂上 ...6(左:4)
7 7 8>7→左;3<7→右;6<7→右 6 右为空,挂上 ...6(左:4, 右:7)
8 14 8<14→右;10<14→右 10 右为空,挂上 ...10(右:14)
9 13 8<13→右;10<13→右;14>13→左 14 左为空,挂上 ...14(左:13) ✅

最终得到本文开头那棵树。

3.3 每一步是为了什么

代码/动作 目的
if (_root == nullptr) { _root = new Node(key); return true; } 空树特判 。这里必须单独处理,因为下面用 parent->_left/right 挂结点时,parent 不能为空。
Node* parent = nullptr; Node* cur = _root; 双指针同步下降 :cur 负责探路找空位,parent 负责记住「上一个结点」,因为 cur 走到 nullptr 之后我们就找不到父结点了。
if (cur->_key < key) { parent = cur; cur = cur->_right; } 必须先动 parent 再动 cur 。如果反过来写成 cur = cur->_right; parent = cur;,parent 就会跟 cur 一起滑下去,父结点信息丢失。这是插入里最容易写错的一行。
else if (cur->_key > key) { parent = cur; cur = cur->_left; } 走左子树。注意条件是 > 而不是 >=------把「相等」单独交给 else 处理。
else { return false; } 不支持重复值的版本:遇到相等的 key 直接返回失败。如果场景要求支持重复值,这里改成往右走(并保证全局一致)。
cur = new Node(key); 循环结束时 cur == nullptr,这就是找到的空位,创建新结点。
if (parent->_key < key) parent->_right = cur; else parent->_left = cur; 挂接 。这里用 parent->_key 和 key 比较来决定挂左还是挂右,和上面的下降逻辑是同一个判断,所以不会挂错。

3.4 为什么插入一定挂在「叶子位置」

因为循环的条件是 while (cur),只有 cur 为空才退出。也就是说新结点永远成为某个已有结点的孩子,且自己是叶子。这保证了插入不会破坏已有结点之间的相对关系------BST 性质只需要在新位置这一条边上成立即可。

📌 和上章的堆排序对照 :堆的插入是「放到数组尾部再向上调整」,BST 的插入是「沿路径走到空位直接挂上、不需要任何调整」。这是 BST 比堆更灵活的地方------但代价是它可能长歪。


四、查找:每一步都在砍掉一半的可能

4.1 查找的过程

  1. 从根开始比较:查找 x,x 比根的值大则往右边走查找,x 比根值小则往左边走查找;
  2. 最多查找高度次,走到空还没找到,这个值不存在;
  3. 如果不支持插入相等的值,找到 x 即可返回;
  4. 如果支持插入相等的值(意味着有多个 x 存在),一般要求查找中序的第一个 x。

4.2 逐步演练

查 13(板书顶部红笔标注的查找路径):

步 当前结点 比较 走向
1 8 13 > 8 → 右
2 10 13 > 10 → 右
3 14 13 < 14 → 左
4 13 13 == 13 ✅ 找到,共 4 次比较(恰好等于树高)

查 5 (板书同一位置标注的另一个查找值,不存在):

步 当前结点 比较 走向
1 8 5 < 8 → 左
2 3 5 > 3 → 右
3 6 5 < 6 → 左
4 4 5 > 4 → 右
5 nullptr --- ❌ 走到空,5 不存在,共 5 次比较

为什么走到空就能断定不存在? 因为 BST 的性质保证了:如果你要找的值比当前结点大,那么只可能在右子树里;所有可能的位置都走过了还是空,那就真的没有。

4.3 为什么说「最多查找高度次」

每一轮比较都会把候选范围限定到一个子树 里:要么丢掉整个左子树,要么丢掉整个右子树。所以查找路径就是树里从上到下的一条唯一路径,长度不超过树高。

  • 树平衡时高度 ≈ log₂N → 查找只需约 log₂N 次比较;
  • 树退化成链时高度 = N → 查找退化为 O(N)。

这就是「为什么树高决定一切」的直观解释。

4.4 支持重复值时:为什么要求「中序的第一个 x」

假设允许重复,并且插入相等值时往右走 ,那么相等的值会沿右侧排成一串。此时中序遍历会先输出最左边(也就是最早插入、层次最浅)的那个 x。

教材举的例子是:查找 3,要找到 1 的右孩子的那个 3 返回 。因为若重复的 3 分布成 1 → 3 → 3,中序的第一个 3 就是紧跟在 1 后面的那个。

复制代码
   1
    \
     3     ← 中序第一个 3,查找应返回它
      \
       3

这个约定很重要:multiset 里 equal_range / lower_bound 这类接口要求有稳定的语义,如果重复值的排布规则不统一,这些接口就无法实现。

4.5 查找代码与插入的关系

复制代码
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;                                      // 走到空
}

对比一下插入代码 :插入 = 查找 + 记住最后的父结点 + 挂接。所以插入的下降循环和查找完全一样 ,只是多了 parent 这个记录。


五、删除:BST 最难的一步

5.1 总流程

首先查找元素是否在二叉搜索树中,如果不存在,则返回 false。 如果查找元素存在,则分以下四种情况分别处理(假设要删除的结点为 N):

  1. 要删除结点 N 左右孩子均为空;
  2. 要删除的结点 N 左孩子为空,右孩子结点不为空;
  3. 要删除的结点 N 右孩子为空,左孩子结点不为空;
  4. 要删除的结点 N 左右孩子结点均不为空。

5.2 四种情况的解决方案

情况 孩子状况 解决方案
1 0 个孩子(叶子) 把 N 结点的父亲对应孩子指针指向空,直接删除 N 结点
2 1 个孩子(左空右不空) 把 N 结点的父亲对应孩子指针指向 N 的右孩子,直接删除 N 结点
3 1 个孩子(右空左不空) 把 N 结点的父亲对应孩子指针指向 N 的左孩子,直接删除 N 结点
4 2 个孩子 不能直接删,必须用替换法(见 5.5)

💡 情况 1 可以当成情况 2 或者 3 处理,效果是一样的 。因为「左右都空」时,「指向 N 的右孩子(也就是空)」和「指向 N 的左孩子(也是空)」结果都是把父亲的指针置空。这就是为什么代码里只需要写两个分支 (cur->_left == nullptr 和 cur->_right == nullptr),而不是三个。

5.3 情况 1 样例:删除 1(叶子)

复制代码
删除前:           删除后:
      8                  8
     / \                / \
    3   10             3   10
   / \    \           / \    \
  1   6    14         ×  6    14
     / \   /            / \   /
    4   7 13           4   7 13

步骤:

  1. 从根 8 开始查找 1:8 > 1 → 左到 3;3 > 1 → 左到 1,找到;
  2. 此时 parent = 3,cur = 1;
  3. 判断 cur->_left == nullptr 成立 → 走「左孩子为空」分支;
  4. 判断 parent->_left == cur(3 的左孩子是 1)成立 → parent->_left = cur->_right (即置为 nullptr);
  5. delete cur; 释放 1。

关键点 :先摘链 ,再 delete。顺序反了就是野指针。

5.4 情况 2 与情况 3 样例:删除 10 与删除 14

删除 10(情况 2:左空右不空):

复制代码
删除前:         删除后:
    8                8
   / \              / \
  3   10           3   14
     / \              / \
    6  14     →      6  13
   / \  /           / \
  4  7 13          4   7
  • 查找路径:8 < 10 → 右到 10,找到。parent = 8,cur = 10。
  • cur->_left == nullptr 成立(10 没有左孩子)→ 走情况 2 分支。
  • parent->_right = cur->_right(让 8 的右孩子直接指向 14)→ 10 被摘掉,14 顶上来。
  • 为什么合法? 因为 10 的左子树为空,10 的整棵右子树的值都 ≥ 10,因此它们也都 > 8,作为 8 的右子树完全合法。

删除 14(情况 3:右空左不空):

复制代码
删除前:         删除后:
    8                8
   / \              / \
  3   10           3   10
     / \              /
    6  14     →     6
   / \  /           / \
  4  7 13          4   7
  • 8 < 14 → 右到 10;10 < 14 → 右到 14,找到。parent = 10,cur = 14。
  • cur->_left != nullptr 且 cur->_right == nullptr → 走情况 3 分支。
  • parent->_right = cur->_left(让 10 的右孩子指向 13)。
  • 为什么合法? 14 的整棵左子树的值都 ≤ 14,且它们原本就在 10 的右子树里,所以都 > 10,接到 10 的右孩子位置合法。

5.5 情况 4 样例:删除 3(两个孩子的核心难点)

先说清楚为什么不能直接删:

3 有两个孩子------左孩子 1、右孩子 6。如果直接删除 3,1 和 6 这两棵子树就无处安放:

  • 1 接到 8 的左边?那 6、4、7 怎么办?
  • 6 接到 8 的左边?那 1 怎么办?

所以必须用替换法:找一个「替身」结点来顶替被删结点的位置。

替身怎么选?

找 N 左子树的值最大结点 R(最右结点),或者 N 右子树的值最小结点 R(最左结点)替代 N。

因为这两个结点中任意一个,放到 N 的位置,都满足二叉搜索树的规则。

为什么恰好是这两个? 这是本节最漂亮的推理:

要把 R 放到 N 的位置,R 必须满足:

  • 大于 N 的整棵左子树的所有值;
  • 小于 N 的整棵右子树的所有值。

而在 N 的左子树里,最大的那个值(最右结点)恰好 ≤ 左子树其他所有值,同时又...它是左子树的一部分,本来就在 N 左边;把它拿上来,左子树剩下的值都 ≤ 它,右边全部 ≥ N ≥ 它,条件全满足。

同理,N 右子树里最小的那个值(最左结点),也天然满足所有条件。

删除 3 的完整过程(本例取「右子树最左结点」作为替身):

复制代码
步骤 1:定位                         步骤 2:找替身
      8                                  8
     / \                                / \
    3   10                             3   10      cur = 3
   / \    \                           / \    \     replace 从 cur->_right = 6 出发
  1   6    14                        1   6    14   一路向左
     / \   /                            / \   /
    4   7 13                           4   7 13
                                        ↑
                                    replace 停在 4(6 的左孩子,6 没有左孩子了)
                                    标记为 R

步骤 3:把替身的值搬到 N 的位置      步骤 4:删除替身结点 4
      8                                  8
     / \                                / \
    4   10                             4   10
   / \    \                           / \    \
  1   6    14                        1   6    14
     / \   /                            /     /
    4   7 13                            7    13

逐步说明:

步 动作 说明
1 查找 3:8 > 3 → 左到 3,找到 parent = 8,cur = 3
2 判断左右都不为空 → 走替换法分支 需要找右子树的最左结点
3 Node* replaceParent = cur; Node* replace = cur->_right; 替身从 3 的右孩子 6 出发
4 while (replace->_left) { replaceParent = replace; replace = replace->_left; } 一路向左:6 → 4(4 没有左孩子),停。所以 R = 4 ,replaceParent = 6
5 cur->_key = replace->_key; 把 4 的值覆盖 到 3 的位置。此时树里出现两个 4,但形状没变,所以 BST 性质依然成立
6 if (replaceParent->_left == replace) replaceParent->_left = replace->_right; 判断 R 是它父亲的左孩子还是右孩子。此处 4 是 6 的左 孩子 → 让 6 的左孩子指向 4 的右孩子(4 没有右孩子,所以变成 nullptr)
7 delete replace; 删除真正的 4 结点

✅ 关键洞察 :「替代 N 的意思就是 N 和 R 的两个结点的值交换,转而变成删除 R 结点」。我们只搬值、不搬结点,这样指针关系完全不用调整------这是替换法最巧妙的地方。

另一个洞察:R 结点一定符合情况 2 或情况 3 。因为 R 是最左结点,它必然没有左孩子(有的话就还能再往左走);如果 R 恰好也没有右孩子,那就是情况 1(叶子),而情况 1 可以当情况 2 处理。所以删除 R 一定是 O(1) 的摘链操作。

5.6 情况 4 样例二:删除 8(根结点,最危险的边界)

删除根 8,它的两个子结点都在,同样要找右子树的最左结点:

复制代码
       8  ← 要删的根
      / \
     3   10
    / \    \
   1   6    14
      / \   /
     4   7 13

找替身 :从 cur->_right = 10 出发,10 没有左孩子 ,所以循环一次都不执行,replace 直接就是 10 自己!

复制代码
replace = cur->_right = 10
while (replace->_left)  ← 10 没有左孩子,条件为假,循环体不执行

于是 replaceParent 还保持在初始值 cur(也就是 8),replaceParent 和 replace 是父子关系没错,但 replace 就是 cur 的右孩子本身。

接着执行:

  • cur->_key = 10 → 根变成 10;
  • if (replaceParent->_left == replace) → 判断 cur->_left == cur->_right?不成立! 10 是 8 的右孩子;
  • 所以走 else 分支:replaceParent->_right = replace->_right,即把 8(现在装的是 10)的右孩子指向 10 的右孩子(14)。

结果:

复制代码
      10
     / \
    3   14
   / \   /
  1   6 13
     / \
    4   7

⚠️ 课件特别强调的坑 :「这里尤其要注意右子树的根就是最小情况的情况的处理,对应课件图中删除 8 的情况。一定要把 cur 给 rightMinP,否则会报错。」

为什么把 rightMinP 初始化成 cur(而不是 nullptr)是必须的? 因为当右子树的根本身就是最左结点时,while 循环一次都不执行,rightMinP 还是初始值。如果初始值是 nullptr,后面 rightMinP->_left 就会空指针解引用崩溃 。初始化成 cur 之后,上面的 if/else 就能正确判断「replace 是 cur 的左孩子还是右孩子」。

5.7 情况 3 样例:删除 8 后层数变少

板书中还有一张「情况3样例:删除8」的图,标注了 _root 和 cur。它展示的是另一种替换策略 ------取左子树的最右结点 来替代。这也是可行的(课件原文说「找 N 左子树的值最大结点 R(最右结点)或者 N 右子树的值最小结点 R(最左结点)」)。

两种策略的差别,以及为什么本课代码选了「右子树最左结点」------下一节统一说明。


六、完整代码实现与三个最容易写错的细节

6.1 结点结构

复制代码
template<class K>
struct BSTNode
{
    K _key;
    BSTNode<K>* _left;
    BSTNode<K>* _right;

    BSTNode(const K& key)
        : _key(key)
        , _left(nullptr)
        , _right(nullptr)
    {}
};

注意 _left 和 _right 都初始化为 nullptr。这不是可选项------如果漏了,插入时判断 cur->_left == nullptr 就会读到随机值,删除逻辑彻底失效。

6.2 Insert(已在第三章讲解)

复制代码
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;
}

6.3 Find(已在第四章讲解)

复制代码
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;
}

6.4 Erase:删除的核心代码

复制代码
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                                   // 找到了,开始删除
        {
            // ---- 0-1 个孩子的情况 ----
            // 删除情况 1 2 3 均可以直接删除,改变父亲对应孩子指针指向即可
            if (cur->_left == nullptr)
            {
                if (parent == nullptr)         // 删的是根
                {
                    _root = cur->_right;
                }
                else
                {
                    if (parent->_left == cur)  parent->_left  = cur->_right;
                    else                       parent->_right = cur->_right;
                }
                delete cur;
                return true;
            }
            else if (cur->_right == nullptr)
            {
                if (parent == nullptr)         // 删的是根
                {
                    _root = cur->_left;
                }
                else
                {
                    if (parent->_left == cur)  parent->_left  = cur->_left;
                    else                       parent->_right = cur->_left;
                }
                delete cur;
                return true;
            }
            else
            {
                // ---- 2 个孩子的情况:替换法删除 ----
                // 假设这里我们取右子树的最小结点作为替代结点去删除
                // 这里尤其要注意右子树的根就是最小情况的情况的处理,对应课件图中删除8的情况
                // 一定要把 cur 给 rightMinP,否则会报错。
                Node* rightMinP = cur;
                Node* rightMin  = cur->_right;
                while (rightMin->_left)
                {
                    rightMinP = rightMin;
                    rightMin  = rightMin->_left;
                }
                cur->_key = rightMin->_key;

                if (rightMinP->_left == rightMin)
                    rightMinP->_left  = rightMin->_right;
                else
                    rightMinP->_right = rightMin->_right;

                delete rightMin;
                return true;
            }
        }
    }
    return false;                              // 没找到
}

6.5 三个最容易写错的细节(重点)

细节 1:parent == nullptr 的根结点特判

问题 :删除的如果是根结点 ,parent 就是 nullptr,此时 parent->_left 会崩溃。

解决 :两个「0-1 个孩子」的分支里都要先判断 if (parent == nullptr) { _root = cur->_right(或 _left); }。

为什么这么写是对的:删根时,根的位置由它的唯一孩子接管------这正是「把父亲的孩子指针指向 N 的孩子」在「父亲不存在」时的自然延伸。

细节 2:rightMinP 必须初始化为 cur,不能是 nullptr

这是课件红字强调 的坑,5.6 节已经详细分析:当右子树的根就是最左结点(例如删除根 8,右孩子 10 没有左孩子)时,while 不执行,rightMinP 保持初始值。若初值为 nullptr,紧接着的 rightMinP->_left 直接崩溃。

细节 3:摘链判断要看 rightMinP->_left == rightMin,且两个分支都要处理

找到的 rightMin 可能是 rightMinP 的左 孩子(常规情况),也可能是右孩子(上面那个边界情况)。所以:

复制代码
if (rightMinP->_left == rightMin)  rightMinP->_left  = rightMin->_right;
else                               rightMinP->_right = rightMin->_right;

这一行在做什么? 让 rightMin 的父结点绕过 rightMin,直接指向 rightMin 的右孩子 (rightMin 没有左孩子,所以只能接右孩子)。然后 delete rightMin。

🔍 一个耐人寻味的观察 :仔细看,这段删除「2 个孩子」的代码其实只处理了 rightMin->_right ,没有判断 rightMin->_right 是否为空------因为它不需要判断:无论空不空,直接接上都是对的。这是 BST 删除代码里一个很小的优雅之处。

6.6 中序遍历验证:一眼确认你的 BST 是对的

复制代码
void InOrder()
{
    _InOrder(_root);
    cout << endl;
}

private:
void _InOrder(Node* root)
{
    if (root == nullptr)
        return;
    _InOrder(root->_left);
    cout << root->_key << " ";
    _InOrder(root->_right);
}

为什么用中序验证? 因为 BST 的中序遍历必然是升序。用本文那棵树跑一遍:

复制代码
1 3 4 6 7 8 10 13 14

如果输出不是升序,说明你的树已经坏了。 这是调试 BST 最快的手段------比盯着指针看有效得多。

更进一步 :InOrder 还告诉你一个事实------BST 天然就是「排序 + 有序存储」二合一。你什么都不用做,中序遍历就是排序结果,而且不需要像快排那样额外操作。这就是第七章那些应用场景的底气。

cpp 复制代码
namespace BinarySearchTree
{
    //定义结点类
    template<class K,class V>
    struct BSNode
    {
        BSNode<K, V>* _left;
        BSNode<K, V>* _right;
        K _key;
        V _value;
        BSNode(const K& key, const V& value)
        {
            _key = key;
            _value = value;
            _left = nullptr;
            _right = nullptr;
        }
    };

    //开始定义整个二叉搜索树
    template<class K,class V>
    class BST
    {
        typedef BSNode<K, V> Node;
    public:
        //构造函数
        BST() = default;//强制生成默认构造就好!!
        //拷贝构造
        BST(const BST&B)//拷贝构造时只能用引用,不能用指针啊,注意一下
        {
            _root = Copy(B._root);
        }
        //赋值拷贝
        BST& operator=(BST B)//不能写成const
        {
            std::swap(_root, B._root);
            return *this;
        }
        ~BST()
        {
            Destory(_root);
            _root = nullptr;
        }

        bool Insert(const K& key, const V& value)
        {
            //_root为空
            if (_root == nullptr)
            {
                _root = new Node(key, value);
                return true;
            }
            //不为空,开始递归寻找
            Node* parent = nullptr;
            Node* cur = _root;//从根开始走
            while (cur)
            {
                //当前的cur中的值小于我们要插入的值,所以得往右边走
                if (cur->_key < key)
                {
                    parent = cur;
                    cur = cur->_right;
                }
                else if (cur->_key > key)
                {
                    parent = cur;
                    cur = cur->_left;
                }
                else
                {
                    cur->_value = value;
                    return true;
                }
            }
            //找到了,开始更新cur,注意的是此时的cur肯定为空
            cur = new Node(key, value);
            //开始进行插入
            if (parent->_key < key)
            {
                parent->_right = cur;//如果说parent中的值小于cur,就去右边
            }
            else
            {
                parent->_left = cur;//如果说parent中的值大于cur,就去左边。
            }
            return true;
        }
        //查找
        V* 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->_value);
                }
            }
            return nullptr;
        }
        //删除
        bool Erase(const K& key)
        {
            if (_root == nullptr)
            {
                return false;
            }
            Node* cur = _root;
            Node* parent = nullptr;
            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)
                    {
                        //如果cur就是根
                        if (cur == _root)
                        {
                            _root = cur->_right;
                        }
                        else
                        {
                            //判断cur是parent的左孩子还是右孩子
                            if (cur == parent->_right)
                            {
                                parent->_right = cur->_right;
                            }
                            else
                            {
                                parent->_left = cur->_right;
                            }
                        }
                        delete cur;
                    }
                    else if (cur->_right == nullptr)
                    {
                        if (cur == _root)
                        {
                            _root = cur->_left;
                        }
                        else
                        {
                            if (cur == parent->_left)
                            {
                                parent->_left = cur->_left;
                            }
                            else
                            {
                                parent->_right = cur->_left;
                            }
                        }
                        delete cur;
                    }
                    else
                    {
                        Node* replaceparent = cur;
                        Node* replace = cur->_right;
                        while (replace->_left)
                        {
                            replaceparent = replace;
                            replace = replace->_left;
                        }
                        //找到了最小值
                        cur->_key = replace->_key;
                        cur->_value = replace->_value;
                        if (replaceparent->_left == replace)
                        {
                            replaceparent->_left = replace->_right;
                        }
                        else
                        {
                            replaceparent->_right = replace->_right;
                        }
                        delete replace;
                    }
                    return true;
                }
            }
            return false;
        }
        void InOrder()
        {
             _Inorder(_root);
             cout << endl;
        }

    private:
        //递归拷贝
        Node*Copy(Node* root)
        {
            if (root == nullptr)
            {
                return nullptr;
            }
            Node* newroot = new Node(root->_key, root->_value);
            newroot->_left = Copy(root->_left);
            newroot->_right = Copy(root->_right);
            return newroot;
        }
        //递归中序遍历
        void _Inorder(Node* root)
        {
            if (root == nullptr)
            {
                return;
            }
            _Inorder(root->_left);
            cout << root->_key << ":" << root->_value << endl;
            _Inorder(root->_right);
        }
        //递归后续销毁,左子树,右子树,根
        void Destory(Node* root)
        {
            if (root == nullptr)
            {
                return;
            }
            Destory(root->_left);
            Destory(root->_right);
            delete root;
        }
        Node*_root=nullptr;//指针
    };
}

七、key 和 key/value 两种使用场景

7.1 key 搜索场景

只有 key 作为关键码 ,结构中只需要存储 key 即可,关键码即为需要搜索到的值。搜索场景只需要判断 key 在不在。

⚠️ key 的搜索场景实现的二叉搜索树支持增删查,但不支持修改------修改 key 就破坏搜索树结构了。

场景 1:小区无人值守车库 小区车库买了车位的业主车才能进小区。物业会把买了车位的业主的车牌号录入后台系统 ;车辆进入时扫描车牌在不在系统中,在则抬杆,不在则提示非本小区车辆,无法进入。

场景 2:英文文章拼写检查 将词库中所有单词放入二叉搜索树;读取文章中的单词,查找是否在二叉搜索树中,不在则波浪线标红提示。

这两个场景的共同点:它们只关心「存在性」 ,不需要附带任何额外数据。所以结点里一个 _key 就够了。

7.2 key/value 搜索场景

每一个关键码 key,都有与之对应的值 value,value 可以是任意类型对象。树的结构中(结点)除了需要存储 key,还要存储对应的 value。

增/删/查还是以 key 为关键字走二叉搜索树的规则进行比较,可以快速查找到 key 对应的 value。

注意:key/value 的搜索场景实现的二叉搜索树支持修改,但不支持修改 key (修改 key 破坏搜索树性质了),可以修改 value。

场景 1:简单中英互译字典 树的结构中(结点)存储 key(英文)和 value(中文)。搜索时输入英文,则同时查找到了英文对应的中文。

场景 2:商场无人值守车库 入口进场时扫描车牌,记录车牌和入场时间 ;出口离场时,扫描车牌,查找入场时间,用 当前时间 - 入场时间 计算出停车时长,计算出停车费用,缴费后抬杆,车辆离场。

和 7.1 的「小区车库」对比一下:小区车库只需要判断「是不是业主」,商场车库需要「入场时间」这个附加数据------这就是 key 与 key/value 的分野。板书上贴的正是「停车场 / 本岗亭无人值守 请自助缴停车费」的照片。

场景 3:统计一篇文章中单词出现的次数 读取一个单词,查找单词是否存在:

  • 不存在 → 说明第一次出现,插入 (单词, 1);
  • 存在 → ++单词对应的次数。

对应的代码:

复制代码
int main()
{
    string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果",
                     "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
    BSTree<string, int> countTree;
    for (const auto& str : arr)
    {
        // 先查找水果在不在搜索树中
        // 1、不在,说明水果第一次出现,则插入<水果, 1>
        // 2、在,则查找到的结点中水果对应的次数++
        auto ret = countTree.Find(str);
        if (ret == NULL)
        {
            countTree.Insert(str, 1);
        }
        else
        {
            ret->_value++;          // ← 这里体现了「可以改 value,不能改 key」
        }
    }
    countTree.InOrder();
    return 0;
}

字典查询的完整示例:

复制代码
int main()
{
    BSTree<string, string> dict;
    dict.Insert("left",   "左边");
    dict.Insert("right",  "右边");
    dict.Insert("insert", "插入");
    dict.Insert("string", "字符串");

    string str;
    while (cin >> str)
    {
        auto ret = dict.Find(str);
        if (ret)
            cout << "->" << ret->_value << endl;
        else
            cout << "无此单词,请重新输入" << endl;
    }
    return 0;
}

7.3 key/value 版本的关键差异

结点定义变了 (多一个 _value):

复制代码
template<class K, class V>
struct BSTNode
{
    K _key;
    V _value;
    BSTNode<K, V>* _left;
    BSTNode<K, V>* _right;

    BSTNode(const K& key, const V& value)
        : _key(key), _value(value), _left(nullptr), _right(nullptr)
    {}
};

Find 的返回值变了 ------key 版返回 bool(只关心在不在),key/value 版返回 Node*(需要拿到 value 去读或改):

复制代码
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;      // 返回结点,调用者才能访问 _value
    }
    return nullptr;
}

新增了深拷贝与析构(因为要管理堆上的结点):

复制代码
BSTree() = default;

BSTree(const BSTree<K, V>& t)          // 拷贝构造:深拷贝
{
    _root = Copy(t._root);
}

BSTree<K, V>& operator=(BSTree<K, V> t)  // 现代写法:拷贝并交换
{
    swap(_root, t._root);
    return *this;
}

~BSTree()
{
    Destroy(_root);
    _root = nullptr;
}

private:
void Destroy(Node* root)               // 后序释放
{
    if (root == nullptr)
        return;
    Destroy(root->_left);
    Destroy(root->_right);
    delete root;                        // ← 子结点都释放完,最后释放自己
}

Node* Copy(Node* root)                 // 前序拷贝
{
    if (root == nullptr)
        return nullptr;
    Node* newRoot = new Node(root->_key, root->_value);
    newRoot->_left  = Copy(root->_left);
    newRoot->_right = Copy(root->_right);
    return newRoot;
}

💡 为什么 Destroy 必须是后序、Copy 必须是前序?

  • Destroy 若先 delete root,就再也找不到 root->_left/_right 了,内存泄漏;
  • Copy 必须先创建 newRoot,才能把两个孩子挂到它身上。

这和上一章「二叉树销毁必须后序」是同一个道理------顺序不是约定,是逻辑必然。
💡 operator= 用「传值 + swap」的写法,把拷贝构造和赋值合并,避免了自赋值和异常安全问题。swap 之后形参 t 在函数结束时析构,顺手释放了旧数据。


八、BST 的终点:为什么需要 AVL 树和红黑树

回到第二章那张对比图。BST 的尴尬在于:

需求 数组+二分 普通 BST 平衡搜索树
查找 O(logN) O(N) 最坏 O(logN)
插入 O(N)(要挪数据) O(N) 最坏 O(logN)
删除 O(N)(要挪数据) O(N) 最坏 O(logN)

BST 的结构本身是好的 ,问题出在它可能长歪:

  • 随机数据插入 → 树高约 1.39 log₂N,表现良好;
  • 有序数据插入 → 退化成链,O(N)。

于是人们给 BST 加了一条「必须保持平衡」的约束,就有了:

  • AVL 树:严格平衡(左右子树高度差 ≤ 1),查找最快,但插入删除需要频繁旋转;
  • 红黑树 :近似平衡,插入删除的旋转次数少,工程上最常用(map/set 底层);
  • B 树系列:为磁盘设计,一个结点存多个 key,减少 IO。

哈希表是另一条路------O(1) 查找,但要求能哈希、不保持有序、且退化时性能会掉到 O(N)。

📌 所以学 BST 的意义不在于直接用它,而在于:

  1. 它是所有平衡树和 map/set 的基础模型;
  2. 插入、查找、删除的每一步操作,都是「在维护中序有序这条不变量」------这个思维贯穿整个树结构;
  3. 删除的四种情况,是树结构里最经典的分支分析,map::erase 就是它。

附:一页速查

项目 结论
核心不变量 中序遍历升序;左子树所有值 ≤ 根 ≤ 右子树所有值
查找 从根出发,大往右、小往左;最多走树高次;走到空即不存在
插入 查找 + 记录父结点 + 挂到空位;空树特判
删除 · 0 个孩子 父亲指针置空,直接删
删除 · 1 个孩子 父亲的对应指针指向「那个孩子」
删除 · 2 个孩子 替换法:取左子树最右 或 右子树最左,只搬值,再删替身
替身为什么合格 左子树最大 ≤ 左子树全部;右子树最小 ≤ 右子树全部
替身一定好删 它必然没有左孩子(或没有右孩子)→ 退化成 0/1 个孩子情况
三大坑 ① 根结点 parent == nullptr 特判;② rightMinP 初值必须是 cur;③ 摘链要分左右孩子两种分支
复杂度 最优 O(log₂N),最差 O(N)(退化成单支树)
重复值 map/set 不支持;multimap/multiset 支持,且要统一左右走向
能否修改 key 不能改(破坏结构);key/value 版可以改 value
验证正确性 跑一遍中序遍历,看是不是升序
相关推荐
H.莓飛1 小时前
【数据结构】堆
linux·数据结构·算法
纪念 2291 小时前
C++算法(二)
开发语言·c++·算法
lie..1 小时前
30天从零开始学AI应用开发(Day 16):Embedding 是什么?用人话讲明白“向量检索”
人工智能·算法·embedding
朝朝辞暮i12 小时前
C++ 第 21 课:struct —— 把一组相关数据打包在一起
开发语言·c++·算法
青梅橘子皮12 小时前
Blue---二分算法(二分查找与二分答案)
算法
长河12 小时前
Java的GC(垃圾回收)中,标记无用对象的核心方法是可达性分析算法
java·jvm·算法
月光船幽幽13 小时前
加性偏移外推提升参数识别可靠性
python·算法
H.莓飛13 小时前
【数据结构】队列
linux·数据结构·centos
Ivanqhz13 小时前
层归一化、残差、前馈网络与激活函数简述
服务器·数据库·人工智能·深度学习·算法