前言
大家好,我是你们的算法陪练。今天我们要攻克的数据结构是——二叉搜索树 (Binary Search Tree, BST)。 很多同学听到“树”就头大,听到“算法”就腿软。别怕,二叉搜索树其实就是个“守规矩”的强迫症患者。只要你掌握了它的规矩,它就是你手中最锋利的武器。
无论是前端的 DOM 树,还是数据库的索引,树形结构无处不在。而 BST 是所有高级树(如 AVL 树、红黑树)的基石。搞定它,你的算法功底直接上一个台阶!
什么是二叉搜索树?
想象一下,你正在玩一个猜数字游戏。
裁判心里想了个数字 6。
你猜 3,裁判说:“小了”。
你猜 8,裁判说:“大了”。
你每次猜都能排除掉一半的可能性。二叉搜索树的灵魂就在于此:高效查找。
核心定义
二叉搜索树首先是一棵二叉树(每个节点最多两个孩子),但它有一个雷打不动的铁律:
左子树 < 根节点 < 右子树
具体来说:
- 左子树上所有节点的值,都小于根节点的值。
- 右子树上所有节点的值,都大于根节点的值。
- 左、右子树本身也必须是二叉搜索树。
因为这个特性,二叉搜索树的中序遍历(左-根-右)出来的结果,就是一个有序数组!是不是很神奇?
为什么我们需要它?
你可能会问:“我有数组和链表,还要这玩意儿干啥?”
- 数组:查找快 O(1)(如果是下标访问),但插入/删除慢 O(n)(要挪动元素)。
- 链表:插入/删除快 O(1)(只要知道位置),但查找慢 O(n)(得从头遍历)。
- 二叉搜索树:集众家之长!
- 查找:平均 O(log n)
- 插入:平均 O(log n)
- 删除:平均 O(log n)
当然,这是“平均”情况。如果运气不好,树长歪了变成一条线(链表),那就退化成 O(n) 了。不过别急,那是红黑树要解决的问题,今天我们先搞定 BST。
动手实现:搭建骨架
话不多说,Show me the code。
我们需要一个节点类 TreeNode,用来存放数据和左右指针。
class TreeNode {
constructor(val) {
this.val = val; // 数据域
this.left = null; // 左孩子指针
this.right = null; // 右孩子指针
}
}
这就好比我们造积木,每块积木有自己的数字,还有两个卡槽用来连接下一块积木。
核心操作一:查找 (Search)
在 BST 中找东西非常简单,就像那个猜数字游戏:
- 从根节点开始。
- 如果要找的值等于当前节点值 -> 找到了!
- 如果要找的值 < 当前节点值 -> 往左找。
- 如果要找的值 > 当前节点值 -> 往右找。
- 如果找道空节点还没找到 -> 不存在。
代码实现
function search(root, n) {
// 递归出口:如果节点为空,说明找遍了也没找到
if (!root) {
console.log("未找到节点:", n);
return;
}
// 找到了!
if (root.val === n) {
console.log("目标节点找到:", root);
return root;
}
// 目标值更小,去左边找
else if (root.val > n) {
return search(root.left, n);
}
// 目标值更大,去右边找
else if (root.val < n) {
return search(root.right, n);
}
}
核心操作二:插入 (Insert)
插入操作其实就是“查找失败”的过程。当你在这个树里找这个值,找到最后发现“咦,这里应该是它的位置,但是是空的”,那就在这儿安家!
注意:为了保持 BST 的性质,我们不能随便插。
代码实现
function insertIntoBST(root, n) {
// 如果当前位置是空的,直接在这里创建一个新节点
if (!root) {
return new TreeNode(n);
}
// 递归寻找插入位置
if (root.val > n) {
// 目标值小,插到左子树去
// 别忘了把递归返回的新节点接回 root.left
root.left = insertIntoBST(root.left, n);
} else if (root.val < n) {
// 目标值大,插到右子树去
root.right = insertIntoBST(root.right, n);
}
// 返回当前节点,保持树的结构
return root;
}
小贴士:递归函数中 root.left = ... 这种写法是利用返回值来重新连接父子关系,非常优雅。
核心操作三:删除 (Delete) —— 最终BOSS
删除是 BST 最复杂的操作。因为你删了一个节点,还得把剩下的节点重新粘起来,并且不能破坏“左<根<右”的规矩。
我们分三种情况讨论:
- 它是叶子节点(没有孩子):最简单,直接删掉(返回 null)。
- 它只有一个孩子(只有左或只有右):也好办,让它的孩子“子承父业”,顶替它的位置。
- 它有两个孩子:最麻烦!
- 你走了,谁来当老大?
- 必须找一个最接近你的人来顶替。
- 方案A:找左子树里最大的(前驱节点)。
- 方案B:找右子树里最小的(后继节点)。
- 选中继承人后,把它的值复制过来,然后把那个继承人原来的旧节点删掉。
代码实现
我们先补齐两个辅助函数:找最大和找最小。
// 找子树最小值
function findMin(node) {
while (node.left) node = node.left;
return node;
}
// 找子树最大值
function findMax(node) {
while (node.right) node = node.right;
return node;
}
主删除逻辑:
function deleteNode(root, n) {
if (!root) return root; // 树是空的,或者没找到
if (root.val === n) {
// --- 找到了,准备动手 ---
// 情况1:叶子节点 & 情况2:只有一个孩子
// 这里利用了短路特性,如果左空就返右,右空就返左
// 如果两个都空,自然返回 null
if (!root.left) return root.right;
if (!root.right) return root.left;
// 情况3:有两个孩子
// 策略:既可以找左子树最大,也可以找右子树最小。
// 为了演示,我们根据子树情况灵活处理(或者固定一种也行)
// 演示:使用左子树最大节点替换
const maxLeft = findMax(root.left);
// 1. 值替换:偷天换日
root.val = maxLeft.val;
// 2. 删掉那个被借用的替身节点
root.left = deleteNode(root.left, maxLeft.val);
/*
// 或者使用右子树最小节点替换:
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 if (root.val < n) {
// 去右边删
root.right = deleteNode(root.right, n);
}
return root;
}
完整测试
让我们把这些零件组装起来,跑个测试用例:
// 1. 造树
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
// 2. 查找
search(root, 7); // 输出节点 7
// 3. 插入
insertIntoBST(root, 5);
// 5 应该插在 4 的右边
// 4. 删除
deleteNode(root, 3);
// 3 被删除,左子树最大是 1 (或者4),根据逻辑会发生替换
总结
二叉搜索树(BST)是数据结构中的“瑞士军刀”,它巧妙地利用二分思想,将查找、插入、删除的时间复杂度维持在 O(log n)。
但它也有弱点:如果不管它,它可能会“长歪”。为了解决这个问题,后来人们发明了 AVL树(严格平衡)和 红黑树(大致平衡),它们会在插入删除时自动旋转,保持身材匀称。
掌握了 BST,你就掌握了通向高级数据结构的钥匙。今天的代码建议大家亲自敲一遍,debug 观察一下指针的指引,会有醍醐灌顶的感觉!
祝大家编码愉快,Offer 拿到手软!🚀