跳到内容

第 2 卷 / 第 5 章 / 第 2 课

5.2 二叉搜索树的增删查 ​

前置:二叉树的结构与递归,约 35 分钟。本节采用唯一整数 key,依次实现查找、插入、三类删除、全局验证与范围查询,并始终用树高 h 分析成本。

排序规则是一条全局不变量 ​

BST 给普通二叉树加上一条全局排序规则:

对每个节点,左子树的所有 key 都小于当前 key,右子树的所有 key 都大于当前 key。

“所有”很重要。下面这棵树的直接孩子看起来正确,节点 12 却落在根 10 的左子树里,因此不是 BST。

text
       10
      /  \
     5    15
      \
       12   ← 大于 10,却仍在 10 的左子树

本页把 BST 当作唯一整数 key 的集合。重复插入保留原树并返回 False,size 不增长。若业务需要同 key 多条记录,可以把值改成列表,或为节点增加 count;不要在没有说明时任意把相等 key 塞到某一侧。

查找:每次只走一个子树 ​

在样例树中查找 60:

text
50 → 70 → 60
  60 > 50,向右
  60 < 70,向左

本页沿用按边计高,非空树高为 h。查询最多检查 h+1 个节点,时间为 O(h + 1);空树查询为常数时间。只有在 h = O(log n) 时,才可写成 O(log n)。

查找失败也沿一条路径走到空链接。它不需要遍历另一侧,因为 BST 不变量已经排除了那里的 key。

插入:沿查找路径接到空位 ​

插入与失败查找走同一条路。区别是,走到空链接后创建节点,并把链接接回父节点。

重复 key 在本页不创建节点。这个决定会影响 size、删除语义、验证边界和中序结果,所以代码把它集中在 insert 里。

删除:父节点应接回什么 ​

找到目标节点后,按孩子数量处理:

没有孩子 ​

返回空链接。父节点不再指向目标。

只有一个孩子 ​

返回唯一孩子。父节点越过目标,直接连到其子树。

有两个孩子 ​

目标的左右子树都要保留。代码选择右子树中最小的 key,也就是中序后继:

  1. 把后继 key 复制到目标节点。
  2. 在右子树中删除原后继节点。
  3. 把更新后的右子树接回。

后继没有左孩子,因此第二次删除只会落入“没有孩子”或“一个孩子”。

完整实现 ​

这段程序可以独立运行。公开方法负责 root 与 size,删除的递归辅助函数返回更新后的子树根;查找和插入使用循环。入口检查整数 key,避免 NaN 一类既不小于也不大于的值误走删除分支。

python
def require_int_key(key):
    if not isinstance(key, int):
        raise TypeError("key must be an integer")

class TreeNode:
    def __init__(self, key):
        require_int_key(key)
        self.key = key
        self.left = None
        self.right = None

def is_valid_bst(root):
    """使用开区间上下界验证严格 BST 不变量。"""
    def validate(node, lower, upper):
        if node is None:
            return True
        if lower is not None and node.key <= lower:
            return False
        if upper is not None and node.key >= upper:
            return False
        return (
            validate(node.left, lower, node.key)
            and validate(node.right, node.key, upper)
        )

    return validate(root, None, None)

def range_query(root, low, high):
    """按升序返回闭区间 [low, high] 内的 key。"""
    require_int_key(low)
    require_int_key(high)
    if low > high:
        return []

    result = []

    def visit(node):
        if node is None:
            return
        if node.key > low:
            visit(node.left)
        if low <= node.key <= high:
            result.append(node.key)
        if node.key < high:
            visit(node.right)

    visit(root)
    return result

class BinarySearchTree:
    def __init__(self):
        self.root = None
        self.size = 0

    def search(self, key):
        require_int_key(key)
        current = self.root
        while current is not None:
            if key == current.key:
                return current
            if key < current.key:
                current = current.left
            else:
                current = current.right
        return None

    def insert(self, key):
        require_int_key(key)
        if self.root is None:
            self.root = TreeNode(key)
            self.size = 1
            return True

        parent = None
        current = self.root
        while current is not None:
            parent = current
            if key == current.key:
                return False
            if key < current.key:
                current = current.left
            else:
                current = current.right

        if key < parent.key:
            parent.left = TreeNode(key)
        else:
            parent.right = TreeNode(key)
        self.size += 1
        return True

    def delete(self, key):
        require_int_key(key)
        self.root, deleted = self._delete(self.root, key)
        if deleted:
            self.size -= 1
        return deleted

    def _delete(self, node, key):
        if node is None:
            return None, False

        if key < node.key:
            node.left, deleted = self._delete(node.left, key)
            return node, deleted
        if key > node.key:
            node.right, deleted = self._delete(node.right, key)
            return node, deleted

        if node.left is None:
            return node.right, True
        if node.right is None:
            return node.left, True

        successor = node.right
        while successor.left is not None:
            successor = successor.left
        node.key = successor.key
        node.right, _ = self._delete(node.right, successor.key)
        return node, True

    def inorder(self):
        result = []

        def visit(node):
            if node is None:
                return
            visit(node.left)
            result.append(node.key)
            visit(node.right)

        visit(self.root)
        return result

    def range_query(self, low, high):
        return range_query(self.root, low, high)

    def is_valid(self):
        return is_valid_bst(self.root)

tree = BinarySearchTree()
for key in [50, 30, 70, 20, 40, 60, 80]:
    assert tree.insert(key)

assert tree.inorder() == [20, 30, 40, 50, 60, 70, 80]
assert tree.size == 7
assert tree.search(60).key == 60
assert tree.search(99) is None
assert not tree.insert(60)
assert tree.size == 7
assert tree.range_query(35, 70) == [40, 50, 60, 70]
assert tree.is_valid()

assert tree.delete(20)       # 叶节点
assert tree.delete(30)       # 此时只有一个孩子 40
assert tree.delete(50)       # 双孩子根节点
assert not tree.delete(999)  # 缺失 key
assert tree.inorder() == [40, 60, 70, 80]
assert tree.size == 4
assert tree.is_valid()

为什么验证必须传上下界 ​

局部检查只能保证:

text
node.left.key < node.key < node.right.key

它约束不了更深后代。验证根 10 的左子树时,所有节点都必须落在 (-∞, 10);进入节点 5 的右子树后,允许区间收紧为 (5, 10)。节点 12 因超过上界而失败。

is_valid_bst 验证的是 key 顺序,前提是输入已经是一棵无环、没有共享孩子的二叉树;它不会检测对象图中的环。代码使用开区间,正好对应严格唯一键规则。若数据结构采用其他重复键策略,边界比较也要同步调整。

范围查询怎样剪枝 ​

查询闭区间 [35, 70] 时:

  • 当前 key 小于等于 35,其左子树不可能产生结果。
  • 当前 key 大于等于 70,其右子树不可能产生结果。
  • 当前 key 落在区间内,按中序位置加入结果。

返回的 key 已经升序排列,不需要二次排序。若输出数量为 k,访问成本是 O(h + k + 1);链状树或覆盖全部 key 的查询最坏仍为 O(n)。返回 k 个 key 需要 Θ(k) 输出空间,递归还要 O(h+1) 辅助空间。low > high 时直接返回空列表。

复杂度与形状 ​

下表按整数比较为单位成本计算;很大的 Python 整数还要计入位数。中序列表的 Θ(n) 输出空间另计,空树操作仍为常数。

操作时间迭代辅助空间递归辅助空间
查找O(h + 1)O(1)若递归实现则 O(h + 1)
插入O(h + 1)O(1)若递归实现则 O(h + 1)
删除O(h + 1)本页递归实现不适用O(h + 1)
中序遍历O(n)O(h + 1)O(h + 1)
范围查询O(h + k + 1),最坏 O(n)取决于实现本页为 O(h + 1)

依次插入 1, 2, 3, 4, 5 会得到:

text
1
 \
  2
   \
    3
     \
      4
       \
        5

这棵树仍满足 BST 不变量,却有 h = n - 1。普通 BST 没有自我平衡机制,输入顺序会决定形状。查找和插入虽然是循环,删除、中序遍历与验证仍递归;很深的链可能触及 Python 递归限制。

BST、哈希表与有序容器 ​

能力普通哈希表未平衡 BST平衡有序容器
精确查找期望 O(1),最坏可线性O(h + 1)O(log n)
升序遍历需另行排序O(n)O(n)
下界/上界通常要扫描O(h + 1)O(log n)
范围查询通常要扫描或维护额外索引O(h + k + 1)O(log n + k)
输入顺序影响树高不适用会平衡规则限制高度

Java TreeMap 公开声明采用红黑树并为基本操作提供对数时间保证。C++ 标准对 set、map 等有序关联容器规定比较器顺序和对数复杂度,但不要求某一种节点布局。Python dict 的迭代顺序是插入顺序,不是 key 的排序顺序。

常见错误 ​

删除后没有更新根 ​

根可能被唯一孩子替代,也可能变为空树。类方法要写 self.root = ...,函数式接口要让调用者接住返回的新根。

双孩子删除后留下两个相同 key ​

复制后继 key 后,还要从右子树删除原后继节点。只复制不删除会违反唯一键契约。还有节点身份问题:外部若保存了目标节点引用,删除后这个对象可能仍在树中,但 key 已变成后继 key;被摘除的反而是原后继节点。本例不保证“节点引用永久对应同一个 key”,调用者也不能绕过树直接改写 key 或链接。

重复插入仍增加 size ​

先判断相等,再创建节点和增加计数。本页 insert 用布尔返回值明确告诉调用者是否发生结构变化。

用 float("-inf") 当通用边界 ​

浮点无穷适用于数值 key,却不适用于字符串或自定义可比较对象。代码用 None 表示无界,再仅在边界存在时比较。

把实现细节当作语言保证 ​

某个 C++ 标准库常用红黑树,不代表 C++ 标准强制它这样实现。专业文档应引用公开复杂度和顺序契约。

调试顺序 ​

  1. 写下重复键策略和比较器语义。
  2. 每次变更后做中序遍历,确认严格递增。
  3. 同时检查 size 与中序结果长度。
  4. 分别测试空树、单节点、叶、单孩子、双孩子根和缺失 key。
  5. 构造深层违规节点,测试上下界验证。
  6. 插入有序序列,记录高度并复核复杂度说明。

动手检验全局不变量 ​

  1. 实现 min_key、max_key、floor 和 ceiling。
  2. 把重复键策略改为节点计数,列出需要同步修改的方法。
  3. 为 delete 写五组断言,覆盖三种孩子数量、根与缺失 key。
  4. 写一个错误的局部验证器,用深层违规树证明它会误判。
  5. 比较随机顺序与升序插入后的树高,不要只比较运行时间。

核对全局顺序与树高 ​

BST 的判断条件是整棵子树的上下界,不是只看直接孩子。查找和插入沿一条路径前进,双孩子删除还会寻找并移除后继;总成本仍是 O(h + 1);普通 BST 若被有序输入压成长链,并没有对数保证。删除时最值得反复测试的是根节点与双孩子节点,因为调用者必须接住新的子树根。

接下来去 树的遍历,把“走哪边”和“何时访问当前节点”分开。有序输入造成的高度问题,则留给 第 6 章:平衡树用旋转和颜色约束处理。

官方参考 ​

Built with VitePress | Software Systems Atlas