手撕二叉搜索树(BST):从入门到“入土”...哦不,是入职!

52 阅读6分钟

前言

大家好,我是你们的算法陪练。今天我们要攻克的数据结构是——二叉搜索树 (Binary Search Tree, BST)。 很多同学听到“树”就头大,听到“算法”就腿软。别怕,二叉搜索树其实就是个“守规矩”的强迫症患者。只要你掌握了它的规矩,它就是你手中最锋利的武器。

无论是前端的 DOM 树,还是数据库的索引,树形结构无处不在。而 BST 是所有高级树(如 AVL 树、红黑树)的基石。搞定它,你的算法功底直接上一个台阶!


什么是二叉搜索树?

想象一下,你正在玩一个猜数字游戏。 裁判心里想了个数字 6。 你猜 3,裁判说:“小了”。 你猜 8,裁判说:“大了”。 你每次猜都能排除掉一半的可能性。二叉搜索树的灵魂就在于此:高效查找

核心定义

二叉搜索树首先是一棵二叉树(每个节点最多两个孩子),但它有一个雷打不动的铁律

左子树 < 根节点 < 右子树

具体来说:

  1. 左子树上所有节点的值,都小于根节点的值。
  2. 右子树上所有节点的值,都大于根节点的值。
  3. 左、右子树本身也必须是二叉搜索树。

因为这个特性,二叉搜索树的中序遍历(左-根-右)出来的结果,就是一个有序数组!是不是很神奇?


为什么我们需要它?

你可能会问:“我有数组和链表,还要这玩意儿干啥?”

  • 数组:查找快 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 中找东西非常简单,就像那个猜数字游戏:

  1. 从根节点开始。
  2. 如果要找的值等于当前节点值 -> 找到了!
  3. 如果要找的值 < 当前节点值 -> 往找。
  4. 如果要找的值 > 当前节点值 -> 往找。
  5. 如果找道空节点还没找到 -> 不存在。

代码实现

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 最复杂的操作。因为你删了一个节点,还得把剩下的节点重新粘起来,并且不能破坏“左<根<右”的规矩。

我们分三种情况讨论:

  1. 它是叶子节点(没有孩子):最简单,直接删掉(返回 null)。
  2. 它只有一个孩子(只有左或只有右):也好办,让它的孩子“子承父业”,顶替它的位置。
  3. 它有两个孩子:最麻烦!
    • 你走了,谁来当老大?
    • 必须找一个最接近你的人来顶替。
    • 方案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 拿到手软!🚀