二叉搜索树(BST)入门指南:从原理到代码实现
前言
最近在准备面试,学习到二叉搜索树这个经典数据结构。说实话,刚开始看代码的时候,我也是一脸懵逼:为什么插入节点要用 root.left = insertIntoBst(root.left, n)?为什么删除节点时要分开判断左子树和右子树?
经过一番折腾,总算搞明白了。今天就把我的学习笔记分享出来,希望能帮助到同样在学习二叉搜索树的同学。
什么是二叉搜索树?
简单来说,二叉搜索树就是一种有规矩的二叉树:
- 左子树上所有节点的值都小于根节点
- 右子树上所有节点的值都大于根节点
- 左子树和右子树本身也是二叉搜索树
举个例子:
6
/ \
3 8
/ \ / \
1 4 7 9
看到没?6的左子树(3,1,4)都小于6,右子树(8,7,9)都大于6。这就是一棵标准的二叉搜索树。
一、查找节点:最简单的开始
先来看看查找操作,这是最直观的:
function search(root, n) {
if (!root) return // 没找到
if (root.val === n) {
console.log('找到节点', root)
return root
} else if (n < root.val) {
search(root.left, n) // 去左边找
} else {
search(root.right, n) // 去右边找
}
}
逻辑很简单:
- 比当前节点小?往左走
- 比当前节点大?往右走
- 相等?找到了!
这就像在图书馆找书,知道书是按编号排序的,要找的书比当前书架小就往左找,大就往右找。
二、插入节点:为什么要用 root.left = xxx?
插入操作稍微复杂一点,先看代码:
function insertIntoBst(root, n) {
// 找到空位了,插入新节点
if (!root) {
root = new TreeNode(n)
return root
}
// 比当前节点小,往左子树插
if (n < root.val) {
root.left = insertIntoBst(root.left, n)
}
// 比当前节点大,往右子树插
else {
root.right = insertIntoBst(root.right, n)
}
return root
}
灵魂拷问:为什么要写成 root.left = xxx?
这个问题我当时想了很久。假设我们要在下面这棵树插入4:
6
/ \
3 8
/ \
7 9
插入过程是这样的:
- 4 < 6,往左走,调用
insertIntoBst(3, 4) - 4 > 3,往右走,调用
insertIntoBst(null, 4) - 发现是空位置,创建新节点4,返回这个节点
- 关键来了:回到节点3,执行
root.right = 返回的新节点(4)
为什么是 root.right = 而不是直接 insertIntoBst(root.right, 4)?
打个比方:
- 你让朋友去帮你买个东西(调用函数)
- 朋友买回来后,你得伸手接住(用 = 接收返回值)
- 如果不伸手接,东西就掉地上了(新节点创建了但没连到树上)
写成 root.right = insertIntoBst(root.right, 4) 就是在说:
- "我的右边,现在是插入新节点后的结果"
- 这样父节点才能正确地指向新的子树
三、删除节点:最复杂的操作
删除节点有三种情况,像个"三选一"的游戏:
情况1:删除叶子节点(最简单)
删除4:
6 6
/ \ → / \
3 8 3 8
\ \
4 (已删除)
直接返回null,父节点指向null就行了。
情况2:删除只有一个子节点的节点(也比较简单)
删除3:
6 6
/ \ → / \
3 8 4 8
\
4
直接用子节点顶替:返回那个子节点。
情况3:删除有两个子节点的节点(最复杂)
删除6:
6 7
/ \ / \
3 8 → 3 8
/ \ \ / \ \
2 5 9 2 5 9
(用右子树最小值7替换6)
这时候不能直接删,因为删了6,谁来当新的根?必须找一个"替身":
- 要么找左子树的最大值(最右边的节点)
- 要么找右子树的最小值(最左边的节点)
这两个值都能保持BST的性质。
代码实现及灵魂拷问
function deleteNode(root, n) {
if (!root) return null
if (n === root.val) {
// 情况1:叶子节点
if (!root.left && !root.right) {
return null
}
// 情况2和3:有左子树(可能也有右子树)
else if (root.left) {
const maxLeft = findMax(root.left) // 找左子树最大值
root.val = maxLeft.val // 替换值
root.left = deleteNode(root.left, maxLeft.val) // 删除被借用的节点
}
// 情况2(只有右子树)
else {
const minRight = findMin(root.right) // 找右子树最小值
root.val = minRight.val // 替换值
root.right = deleteNode(root.right, minRight.val) // 删除被借用的节点
}
} else if (n < root.val) {
root.left = deleteNode(root.left, n)
} else {
root.right = deleteNode(root.right, n)
}
return root
}
灵魂拷问:为什么要分开判断左子树和右子树?
问得好!你看代码里是这么写的:
else if (root.left) {
// 用左子树最大值
} else {
// 用右子树最小值
}
为什么不是这样写?
if (root.left && root.right) {
// 处理左右都有
} else if (root.left && !root.right) {
// 处理只有左
} else if (!root.left && root.right) {
// 处理只有右
}
因为原代码的写法更聪明!仔细想想:
当 root.left 存在时,包含了两种可能:
- 只有左子树(没有右子树)
- 左右子树都有
这两种情况都可以用同一种方式处理:
- 找左子树的最大值替换当前节点
- 然后去左子树删除那个最大值
当 root.left 不存在时:
- 一定是只有右子树(因为叶子节点已经在前面处理了)
- 直接用右子树的最小值替换就行
这样写的好处:
- 代码更简洁
- 逻辑更统一
- 少写一个判断分支
- 不容易出错(不用重复写类似的逻辑)
时间复杂度分析
- 平均情况:O(log n) —— 树比较平衡的时候
- 最坏情况:O(n) —— 树退化成链表的时候
比如按顺序插入1,2,3,4,5:
1
\
2
\
3
\
4
\
5
这就变成了链表,查找效率就低了。
总结
二叉搜索树的三个核心操作:
- 查找:比大小,决定往左还是往右
- 插入:找到空位插入,记得用
root.left =接收返回值 - 删除:三种情况处理,有两个子节点时找替身
最关键的领悟:递归函数的返回值一定要被父节点接收,就像接力赛,每一棒都要交接清楚!
希望这篇文章对你有帮助。如果你也在学习算法面试题,欢迎一起交流讨论!