深入理解二叉搜索树:查找、插入与删除的完整实现

36 阅读6分钟

在数据结构的世界中,二叉搜索树(Binary Search Tree, BST) 是一种兼具逻辑清晰性与高效操作能力的重要结构。它不仅天然支持有序存储,还能在理想情况下以对数时间复杂度完成核心操作。本文将围绕 BST 的三大基本操作——查找、插入与删除,结合具体代码实现,深入剖析其递归机制、边界处理以及性能特性。


什么是二叉搜索树?

二叉搜索树是一种特殊的二叉树,满足以下性质:

  • 若左子树非空,则左子树上所有节点的值均 小于 根节点的值;
  • 若右子树非空,则右子树上所有节点的值均 大于 根节点的值;
  • 左、右子树本身也必须是二叉搜索树。

这一结构性质使得 BST 天然具备“排序”能力。例如,对 BST 进行中序遍历,即可得到一个严格递增的序列。

注意:BST 不允许重复值(或需明确定义重复值的处理规则),否则会破坏其有序性。


查找操作:从根出发,逐层逼近目标

查找是 BST 最基础的操作。其核心思想非常直观:利用 BST 的有序性,每次比较当前节点值与目标值,决定向左或向右继续搜索

function search(root, n) {
  if (!root) {
    return; // 空树,未找到
  }

  if (root.val === n) {
    console.log('目标节点', root);
    return root;
  } else if (root.val > n) {
    // 目标值更小,进入左子树
    return search(root.left, n); // 注意:此处应显式 return
  } else {
    // 目标值更大,进入右子树
    return search(root.right, n);
  }
}

关键细节:递归返回值

原始代码中存在一个常见疏漏:在递归调用 search(root.left, n)search(root.right, n)未使用 return。这会导致即使在子树中找到了目标节点,结果也无法传递回上层调用者,最终函数返回 undefined

正确的做法是在递归调用前加上 return,确保结果能够逐层向上返回。

时间复杂度分析

  • 平均情况:树接近平衡,高度为 O(logn)O(\log n),查找时间为 O(logn)O(\log n)
  • 最坏情况:树退化为链表(如连续插入递增序列),高度为 O(n)O(n),查找退化为线性扫描,时间复杂度为 O(n)O(n)

因此,BST 的效率高度依赖于其形状是否平衡


插入操作:递归定位 + 回溯重建指针

插入新节点的目标是:在保持 BST 性质的前提下,将新值放置到合适位置。由于 BST 中每个值的位置是唯一的(由大小关系决定),插入过程本质上是“查找插入点”的过程。

function insertIntoBst(root, n) {
  if (!root) {
    // 到达空位置,创建新节点
    return new TreeNode(n);
  }

  if (root.val > n) {
    // 新值更小,应插入左子树
    root.left = insertIntoBst(root.left, n);
  } else {
    // 新值更大,应插入右子树
    root.right = insertIntoBst(root.right, n);
  }

  return root;
}

递归机制解析

以插入 5 到如下树为例:

        6
      /   \
     3     8
    / \   / \
   1   4 7   9
  1. 从根 6 开始,5 < 6 → 进入左子树(3);
  2. 5 > 3 → 进入右子树(4);
  3. 5 > 4 → 进入右子树(null);
  4. null 处创建新节点 5 并返回;
  5. 回溯过程中,每一层将返回的新子树重新赋值给 leftright,从而重建路径上的指针

这种“递归向下找位置,回溯向上接节点”的模式,是 BST 插入操作的精髓。它保证了树结构在插入后依然合法,且无需额外调整。

为什么不能直接修改 root.left

因为 JavaScript 中函数参数是按值传递的。如果在某一层直接写 root.left = new TreeNode(n) 而不通过返回值赋值,上层调用者无法感知到子树的变化。通过返回新子树并赋值,才能确保父节点正确链接到更新后的子树。


删除操作:三种情况,巧妙替换

删除是 BST 中最复杂的操作,需考虑三种情形:

  1. 待删节点为叶子节点:直接移除;
  2. 待删节点只有一个子树:用子树替代该节点;
  3. 待删节点有两个子树:需找到中序前驱(左子树最大值)中序后继(右子树最小值) 替代其值,再递归删除被借用的节点。
function deleteNode(root, n) {
  if (!root) return null;

  if (root.val === n) {
    // 情况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); // 删除被借用的节点
    }
    // 只有右子树
    else {
      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;
}

为何选择左子树最大值或右子树最小值?

  • 左子树最大值(中序前驱)是小于当前节点的最大值;
  • 右子树最小值(中序后继)是大于当前节点的最小值;
  • 用它们替换当前节点值,不会破坏 BST 的全局有序性

此外,这两个候选节点必定是叶子节点或仅有单个子节点(否则就不是“最大”或“最小”),因此删除它们时最多触发前两种简单情况,避免递归爆炸。

示例:删除节点 3

原树:

        6
      /   \
     3     8
    / \   / \
   1   4 7   9
  • 3 有左右子树;

  • 左子树最大值为 4

  • 3 的值替换为 4

  • 再删除原 4 节点(此时它是叶子);

  • 结果:

            6
          /   \
         4     8
        /     / \
       1     7   9
    

性能与局限:何时 BST 不再高效?

尽管 BST 在平均情况下表现优异,但其最坏时间复杂度为 O(n)O(n) ,发生在树严重不平衡时。例如:

  • 依次插入 1, 2, 3, 4, 5,形成右斜树;
  • 此时 BST 退化为链表,所有操作等同于线性扫描。

为解决此问题,后续发展出自平衡二叉搜索树,如 AVL 树、红黑树等,通过旋转操作维持树高在 O(logn)O(\log n) 级别。

但在许多实际场景(如随机插入、数据规模适中),普通 BST 仍因其实现简单、常数因子小而被广泛使用。


总结

二叉搜索树以其简洁的结构和高效的平均性能,成为理解更高级树结构的基石。通过递归实现的查找、插入与删除操作,不仅逻辑清晰,还充分体现了“分治”与“回溯”的编程思想。

  • 查找:利用有序性快速定位;
  • 插入:递归到底创建节点,回溯重建指针;
  • 删除:分类处理,用前驱/后继保持结构合法。

掌握这些核心操作,不仅能应对面试算法题,更为学习数据库索引(如 B+ 树)、集合类容器(如 TreeSet)等高级应用打下坚实基础。在实际工程中,若需强保证性能,可考虑使用语言内置的平衡树实现;而在教学或轻量级场景中,手写 BST 依然是极佳的实践选择。