引言
你是否曾好奇:计算机是如何在成千上万的数据中快速找到某个数字的?
答案之一,就是——二叉搜索树(Binary Search Tree, BST)!
它结构优雅、逻辑清晰,是理解更高级数据结构(如红黑树、B树)的基石。
今天,我们将结合readme.md的理论说明和1.js、2.js、3.js的三段核心代码,逐行注释 + 深入讲解,带你从零构建对 BST 的完整认知。
即使你是编程新手,也能轻松看懂!
第一部分:什么是二叉搜索树?
二叉搜索树(排序二叉树)满足:左子树 < 根节点 < 右子树。
二叉搜索树支持高效查找。平均时间复杂度为 O(log n) ,最坏时间复杂度为 O(n) 。
二叉搜索树的定义是一棵:
空树;
或由根节点、左子树、右子树组成的树,同时:
- 左子树和右子树都是二叉搜索树;
- 左子树的所有节点的值均小于根节点的值;
- 右子树的所有节点的值均大于根节点的值。
- 递归定义:一棵 BST 要么是空的,要么它的左右子树也必须是 BST。
- 有序性:这是 BST 的灵魂!左边小,右边大,这个规则贯穿始终。
- 高效性:因为有序,我们每次比较都能“砍掉一半”搜索空间,所以平均速度很快(像二分查找)。
- 最坏情况:如果插入顺序是单调递增或递减(比如 1→2→3→4),树会退化成一条链,此时性能退化为 O(n),和数组遍历一样慢。
类比理解:想象你在玩“猜数字”游戏(1~100)。每次你猜一个数,对方告诉你“大了”或“小了”。BST 就像这个游戏的决策树——每一步都帮你缩小范围!
第二部分:准备工具箱——定义树节点(来自 1.js)
在操作 BST 之前,我们需要先定义“节点”这个基本单元。来看 1.js 开头的部分:
class TreeNode {
constructor(val) {
this.val = val;
this.left = null;
this.right = null;
}
}
逐行解析:
class TreeNode:定义一个名为TreeNode的类,代表树中的一个节点。constructor(val):构造函数,接收一个值val。this.val = val;:将传入的值保存为当前节点的值。this.left = null;:初始化左孩子为null(表示暂时没有左子节点)。this.right = null;:初始化右孩子为null(表示暂时没有右子节点)。
✅ 这就是 BST 的“原子” !所有复杂的树,都是由这样的小节点通过
left和right指针连接而成。
接着,1.js 手动构建了一棵测试用的小树:
const root = new TreeNode(6);
root.left = new TreeNode(3);
root.right = new TreeNode(8);
root.left.left = new TreeNode(1);
root.left.right = new TreeNode(4);
root.right.left = new TreeNode(7);
root.right.right = new TreeNode(9);
这棵树的结构如下:
6
/ \
3 8
/ \ / \
1 4 7 9
- 根是 6;
- 左子树:3(<6),其下有 1(<3)和 4(>3);
- 右子树:8(>6),其下有 7(<8)和 9(>8)。
✅ 完全符合 BST 规则!
第三部分:操作一 —— 查找(Search)【来自 1.js】
原始代码
// 查找
function search(root, n) {
if (!root) { // 如果当前节点为空(已走到叶子的下一层),说明没找到
return;
}
if (root.val === n) { // 如果当前节点的值等于目标值,找到了!
console.log('目标节点', root)
} else if (root.val > n) { // 如果当前节点的值大于目标值 → 目标一定在左子树
search(root.left, n); // 递归地在左子树中查找
} else { // 否则(当前节点值 < 目标值)→ 目标一定在右子树
search(root.right, n); // 递归地在右子树中查找
}
}
逻辑详解:
这是一个典型的递归查找过程,思路完全遵循 BST 的有序性:
- 边界条件:如果
root是null,说明已经走到了树的“尽头”,但还没找到,直接返回(结束递归)。 - 命中:如果当前节点的值等于
n,打印出来,任务完成! - 向左走:如果当前值太大(
root.val > n),根据 BST 规则,目标只能在左子树。 - 向右走:如果当前值太小(
root.val < n),目标只能在右子树。
举个例子:search(root, 7)
- 当前节点:6 → 6 < 7 → 去右子树(8)
- 当前节点:8 → 8 > 7 → 去左子树(7)
- 当前节点:7 → 7 === 7 → 找到!输出
目标节点 TreeNode{val:7, ...}
时间复杂度:每次递归都进入下一层,最多走树的高度。
- 平衡时:高度 ≈ log₂n → O(log n)
- 退化成链:高度 = n → O(n)
第四部分:操作二 —— 插入(Insert)【来自 2.js】
原始代码
// 插入
function insertIntoBst(root, n) {
if (!root) { // 如果当前位置为空,说明找到了插入位置
root = new TreeNode(n); // 创建新节点
return root; // 返回这个新节点,让父节点的 left/right 指向它
}
if (root.val > n) { // 如果新值小于当前节点 → 应插入左子树
root.left = insertIntoBst(root.left, n); // 递归插入左子树,并更新左指针
} else { // 否则(新值 >= 当前节点)→ 插入右子树
root.right = insertIntoBst(root.right, n); // 递归插入右子树,并更新右指针
}
return root; // 返回当前子树的根(用于向上连接)
}
逻辑详解:
插入的本质是先查找插入位置,再创建节点。
-
找到空位:当
root === null时,说明我们走到了一个“可以安家”的地方,于是新建节点并返回。 -
决定方向:
- 如果
n < root.val→ 往左走; - 否则(包括相等)→ 往右走(注意:这里允许重复值插入到右子树,实际应用中可根据需求修改)。
- 如果
-
递归连接:函数返回的是“更新后的子树根”,所以要用
root.left = ...或root.right = ...把新节点“挂”回去。
举个例子:插入 5
- 从根 6 开始:5 < 6 → 进入左子树(3)
- 在 3:5 > 3 → 进入右子树(4)
- 在 4:5 > 4 → 进入右子树(
null) - 遇到
null→ 创建new TreeNode(5),返回 - 回溯:4 的
right指向 5;3 的right仍是 4;6 的left仍是 3
最终树变成:
6
/ \
3 8
/ \ / \
1 4 7 9
\
5 ← 新增!
✅ 依然满足 BST 规则!
💡 注意:这个插入函数不会改变已有节点的结构,只是在叶子处添加新节点,非常安全。
第五部分:操作三 —— 删除(Delete)【来自 3.js】
删除是 BST 中最难的操作,因为要在删除后依然保持 BST 的有序性。我们来看看 3.js 的实现。
原始代码
// 删除
function deleteNode(root, n) {
if (!root) { // 空树,直接返回(没找到要删的节点)
return root;
}
if (root.val === n) { // 找到了要删除的节点!
if (!root.left && !root.right) { // 情况1:叶子节点(无左右孩子)
root = null; // 直接删除,返回 null
} else if (root.left) { // 情况2:有左子树(不管有没有右子树)
// 左子树最大的结点找出来,代替这个结点
const maxLeft = findMax(root.left);
root.val = maxLeft.val; // 用左子树的最大值覆盖当前节点的值
// 删除左子树最大的结点(它一定没有右孩子,可能有左孩子)
root.left = deleteNode(root.left, maxLeft.val);
} else { // 情况3:只有右子树(没有左子树)
// 右子树最小的结点找出来,代替这个结点
const minRight = findMin(root.right);
root.val = minRight.val; // 用右子树的最小值覆盖当前节点的值
// 删除右子树最小的结点(它一定没有左孩子,可能有右孩子)
root.right = deleteNode(root.right, minRight.val);
}
} else if (root.val > n) { // 目标值更小 → 去左子树删
root.left = deleteNode(root.left, n);
} else { // 目标值更大 → 去右子树删
root.right = deleteNode(root.right, n);
}
return root; // 返回更新后的子树根
}
// 寻找左子树最大值(一直往右走到底)
function findMax(root) {
while (root.right) { // 只要有右孩子,就继续往右
root = root.right;
}
return root; // 最右边的节点就是最大值
}
// 寻找右子树的最小值(一直往左走到底)
function findMin(root) {
while (root.left) { // 只要有左孩子,就继续往左
root = root.left;
}
return root; // 最左边的节点就是最小值
}
删除的三种情况详解
✅ 情况1:删除叶子节点(如 1、4、7、9)
- 直接设为
null,父节点的指针自然断开。 - 例如删
1:root.left.left = null。
✅ 情况2:删除有左子树的节点(如 3、6)
-
策略:用左子树中的最大值来替代当前节点。
-
为什么?
- 左子树最大值
<原根(因为是左子树); - 且它
>左子树所有其他节点; - 替换后,整棵树依然满足 BST 规则!
- 左子树最大值
-
如何找左子树最大值? →
findMax(root.left):从左子树根开始,一直往右走到底。 -
替换后怎么办? → 那个最大值节点现在“身兼两职”,需要从原位置删除。但它一定没有右孩子(否则还能往右走),所以删除它最多只涉及一个子树,递归调用
deleteNode即可。
例子:删除
3
- 左子树是
[1, 4],最大值是4;- 把
3改成4;- 然后去左子树删掉原来的
4(它是叶子,直接删);- 结果:3 的位置变成 4,1 成为它的左孩子。
✅ 情况3:删除只有右子树的节点(如 8)
-
策略:用右子树中的最小值来替代。
-
为什么?
- 右子树最小值
>原根; - 且
<右子树所有其他节点; - 替换后依然合法!
- 右子树最小值
-
如何找? →
findMin(root.right):一直往左走到底。
例子:删除
8
- 右子树是
[7,9],最小值是7;- 把
8改成7;- 删掉原来的
7(叶子);- 结果:8 的位置变成 7,9 成为它的右孩子。
第六部分:总结与思考
📊 BST 核心操作对比
| 操作 | 思路 | 时间复杂度(平均) | 时间复杂度(最坏) |
|---|---|---|---|
| 查找 | 二分式递归搜索 | O(log n) | O(n) |
| 插入 | 先找位置,再挂新节点 | O(log n) | O(n) |
| 删除 | 分三种情况,用前驱/后继替代 | O(log n) | O(n) |
✅ BST 的优点
- 实现简单,逻辑直观;
- 动态支持插入/删除;
- 中序遍历可得到有序序列(试试对示例树中序遍历:1,3,4,5,6,7,8,9)。
❌ BST 的缺点
- 不平衡问题:最坏情况下退化成链表,性能骤降。
- 不支持重复值的良好处理(需额外设计)。
🌟 进阶方向
为了解决不平衡问题,计算机科学家发明了自平衡二叉搜索树:
- AVL 树:严格平衡,旋转操作保证高度差 ≤1;
- 红黑树:近似平衡,广泛用于 C++ STL(如
map)、JavaTreeMap; - Treap、Splay Tree 等。
学会 BST,你就拿到了通往这些高级结构的“门票”!
结语:动手是最好的学习!
现在,你已经掌握了 BST 的全部核心知识。不妨:
- 复制
1.js、2.js、3.js到浏览器控制台; - 构建那棵示例树;
- 尝试
search(root, 5)(找不到)、insertIntoBst(root, 5)、再search(root, 5)(找到!); - 尝试
deleteNode(root, 3),然后中序遍历看看结果。
编程不是看会的,是写会的!
愿你在数据结构的世界里,越走越远,越玩越嗨!🌳✨
附:完整代码链接lesson_zp/algorithm/bst: AI + 全栈学习仓库
运行它,亲眼见证 BST 的魔力吧!