面试被BST虐哭了?这两个问题搞懂就能拿捏面试官!

4 阅读5分钟

二叉搜索树(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

插入过程是这样的:

  1. 4 < 6,往左走,调用 insertIntoBst(3, 4)
  2. 4 > 3,往右走,调用 insertIntoBst(null, 4)
  3. 发现是空位置,创建新节点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 存在时,包含了两种可能:

  1. 只有左子树(没有右子树)
  2. 左右子树都有

这两种情况都可以用同一种方式处理

  • 找左子树的最大值替换当前节点
  • 然后去左子树删除那个最大值

root.left 不存在时

  • 一定是只有右子树(因为叶子节点已经在前面处理了)
  • 直接用右子树的最小值替换就行

这样写的好处:

  • 代码更简洁
  • 逻辑更统一
  • 少写一个判断分支
  • 不容易出错(不用重复写类似的逻辑)

时间复杂度分析

  • 平均情况:O(log n) —— 树比较平衡的时候
  • 最坏情况:O(n) —— 树退化成链表的时候

比如按顺序插入1,2,3,4,5:

1
 \
  2
   \
    3
     \
      4
       \
        5

这就变成了链表,查找效率就低了。

总结

二叉搜索树的三个核心操作:

  1. 查找:比大小,决定往左还是往右
  2. 插入:找到空位插入,记得用 root.left = 接收返回值
  3. 删除:三种情况处理,有两个子节点时找替身

最关键的领悟:递归函数的返回值一定要被父节点接收,就像接力赛,每一棒都要交接清楚!

希望这篇文章对你有帮助。如果你也在学习算法面试题,欢迎一起交流讨论!