一、概念:二叉搜索树到底是什么
二叉搜索树 (Binary Search Tree)又称二叉排序树。它或者是一棵空树,或者是具有以下性质的二叉树:
- 若它的左子树不为空 ,则左子树上所有结点的值都小于等于根结点的值;
- 若它的右子树不为空 ,则右子树上所有结点的值都大于等于根结点的值;
- 它的左右子树也分别为二叉搜索树。
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) 级别的查找效率,但它有两大缺陷:
- 需要存储在支持下标随机访问的结构中,并且有序;
- 插入和删除数据效率很低 ------因为存储在下标随机访问的结构中(数组),插入和删除一般需要挪动数据,代价是 O(N)。
板书的对比图把这条链路画得很清楚:
数组 ──────────────→ 数组 + 排序 + 二分查找 查找 O(logN)
↑
数组不方便插入删除
二叉搜索树 ────────→ 查找最坏 O(N) ← 退化问题
平衡搜索树 查找 O(logN)
AVL Tree / 红黑树 / B树系列
哈希表 查找 O(1)
这里也就体现出了平衡二叉搜索树的价值:它同时拿到了「查找快」和「插入删除快」。而理解它的前提,就是先把普通 BST 的三种操作吃透------这正是本文后面五节要做的事。
三、插入:一条路径走到空位,然后挂上去
3.1 插入的具体过程
- 树为空 ,则直接新增结点,赋值给
root指针; - 树不空 ,按二叉搜索树性质:插入值比当前结点大往右走 ,比当前结点小往左走,找到空位置,插入新结点;
- 如果支持插入相等的值 ,插入值跟当前结点相等的值可以往右走,也可以往左走,找到空位置插入新结点。(要注意的是要保持逻辑一致性,插入相等的值不要一会儿往右走,一会儿往左走。)
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 查找的过程
- 从根开始比较:查找
x,x比根的值大则往右边走查找,x比根值小则往左边走查找; - 最多查找高度次,走到空还没找到,这个值不存在;
- 如果不支持插入相等的值,找到
x即可返回; - 如果支持插入相等的值(意味着有多个
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):
- 要删除结点 N 左右孩子均为空;
- 要删除的结点 N 左孩子为空,右孩子结点不为空;
- 要删除的结点 N 右孩子为空,左孩子结点不为空;
- 要删除的结点 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
步骤:
- 从根 8 开始查找 1:8 > 1 → 左到 3;3 > 1 → 左到 1,找到;
- 此时
parent = 3,cur = 1; - 判断
cur->_left == nullptr成立 → 走「左孩子为空」分支; - 判断
parent->_left == cur(3 的左孩子是 1)成立 →parent->_left = cur->_right(即置为nullptr); 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 的意义不在于直接用它,而在于:
- 它是所有平衡树和
map/set的基础模型;- 插入、查找、删除的每一步操作,都是「在维护中序有序这条不变量」------这个思维贯穿整个树结构;
- 删除的四种情况,是树结构里最经典的分支分析,
map::erase就是它。
附:一页速查
| 项目 | 结论 |
|---|---|
| 核心不变量 | 中序遍历升序;左子树所有值 ≤ 根 ≤ 右子树所有值 |
| 查找 | 从根出发,大往右、小往左;最多走树高次;走到空即不存在 |
| 插入 | 查找 + 记录父结点 + 挂到空位;空树特判 |
| 删除 · 0 个孩子 | 父亲指针置空,直接删 |
| 删除 · 1 个孩子 | 父亲的对应指针指向「那个孩子」 |
| 删除 · 2 个孩子 | 替换法:取左子树最右 或 右子树最左,只搬值,再删替身 |
| 替身为什么合格 | 左子树最大 ≤ 左子树全部;右子树最小 ≤ 右子树全部 |
| 替身一定好删 | 它必然没有左孩子(或没有右孩子)→ 退化成 0/1 个孩子情况 |
| 三大坑 | ① 根结点 parent == nullptr 特判;② rightMinP 初值必须是 cur;③ 摘链要分左右孩子两种分支 |
| 复杂度 | 最优 O(log₂N),最差 O(N)(退化成单支树) |
| 重复值 | map/set 不支持;multimap/multiset 支持,且要统一左右走向 |
| 能否修改 | key 不能改(破坏结构);key/value 版可以改 value |
| 验证正确性 | 跑一遍中序遍历,看是不是升序 |