编码 · 数据结构
二叉搜索树详解:原理、代码与时间复杂度
简短回答
二叉搜索树(BST)是一种二叉树,其中每个节点的左子树只包含更小的键,右子树只包含更大的键。由于这条规则,查找、插入和删除都只沿着从根出发的一条路径进行,时间为 O(h),其中 h 是树的高度:树平衡时是 O(log n),退化成一条链时则是 O(n)。对它做中序遍历会按从小到大的顺序访问所有键。
二叉搜索树解决什么问题
有序数组可以快速二分查找,但插入新元素时要移动其后的所有元素。哈希表查找和插入都很快,但不保持顺序,因此无法回答“比 42 大的最小键是什么?”或“列出 10 到 20 之间的所有键”。二叉搜索树既保持键有序,又支持快速插入和删除。它是有序映射和有序集合的基础,例如 Java 的 TreeMap 和 C++ 的 std::map,它们使用的是自平衡的变体。
用大白话理解这条规则
每个节点有一个键、一个可选的左孩子和一个可选的右孩子。对每个节点来说:左子树中的所有键都更小,右子树中的所有键都更大。要查找一个键,从根开始比较:键更小就往左走,更大就往右走,相等或走出树外就停止。
示例演练:构建并查找一棵树
按 8、3、10、1、6、14、4 的顺序插入。每个新键从根开始向下走,在走出树外的位置成为叶子。
| 插入 | 经过的路径 | 放置位置 |
|---|---|---|
| 8 | 空树 | 根 |
| 3 | 8:更小,向左 | 8 的左孩子 |
| 10 | 8:更大,向右 | 8 的右孩子 |
| 1 | 8 向左,3 向左 | 3 的左孩子 |
| 6 | 8 向左,3 向右 | 3 的右孩子 |
| 14 | 8 向右,10 向右 | 10 的右孩子 |
| 4 | 8 向左,3 向右,6 向左 | 6 的左孩子 |
查找 4 依次与 8、3、6、4 比较:在有七个键的树中比较了四次。查找 7 时依次与 8、3、6 比较,然后发现 6 没有右孩子,所以 7 不在树中。
Python 实现
插入和查找都是简短的循环。删除有三种情况:节点没有孩子(直接移除)、有一个孩子(用这个孩子替换它)、有两个孩子(复制它的中序后继,即右子树中最小的键,然后删除那个后继)。
class Node:
def __init__(self, key):
self.key, self.left, self.right = key, None, None
def insert(root, key):
if root is None:
return Node(key)
if key < root.key:
root.left = insert(root.left, key)
elif key > root.key:
root.right = insert(root.right, key)
return root # duplicates are ignored
def contains(root, key):
while root and root.key != key:
root = root.left if key < root.key else root.right
return root is not None
def delete(root, key):
if root is None:
return None
if key < root.key:
root.left = delete(root.left, key)
elif key > root.key:
root.right = delete(root.right, key)
else:
if root.left is None:
return root.right
if root.right is None:
return root.left
succ = root.right
while succ.left:
succ = succ.left
root.key = succ.key
root.right = delete(root.right, succ.key)
return root
def inorder(root):
return inorder(root.left) + [root.key] + inorder(root.right) if root else []root = None
for k in [8, 3, 10, 1, 6, 14, 4]:
root = insert(root, k)
print(inorder(root)) # [1, 3, 4, 6, 8, 10, 14]
print(contains(root, 4), contains(root, 7)) # True False
root = delete(root, 3) # node with two children
print(inorder(root), root.left.key) # [1, 4, 6, 8, 10, 14] 4时间与空间复杂度
| 操作 | 平衡的树 | 最坏情况(链) | 原因 |
|---|---|---|---|
| 查找 | O(log n) | O(n) | 每层比较一次 |
| 插入 | O(log n) | O(n) | 走到叶子位置 |
| 删除 | O(log n) | O(n) | 找到节点,可能还要找它的后继 |
| 最小 / 最大 | O(log n) | O(n) | 沿着左(或右)链接走到尽头 |
| 中序遍历 | O(n) | O(n) | 每个节点一次 |
| 空间 | O(n) | O(n) | 每个键一个节点;递归使用 O(h) 的栈 |
最坏情况是真实存在的,而不是理论上的:把已排好序的键(1, 2, 3, ……)插入普通 BST,每个节点都只有右孩子,树就变成了链表。随机的插入顺序则给出期望高度 O(log n)。
平衡变体与常见错误
自平衡树在插入和删除之后通过旋转重新调整结构,使高度保持在 O(log n)。
| 变体 | 平衡思路 | 应用场景 |
|---|---|---|
| AVL 树 | 两棵子树的高度相差不超过 1 | 查找密集的负载 |
| 红黑树 | 颜色规则限制最长路径不超过最短路径的两倍 | Java TreeMap、常见的 C++ std::map 实现、Linux 内核 |
| B 树 / B+ 树 | 每个节点存放许多键,所有叶子在同一深度 | 数据库和文件系统 |
| Treap、伸展树 | 随机优先级,或把被访问的键移到根部 | 更简单的平衡树、缓存 |
- 只检查父子节点就验证 BST。应改为向下传递一个有效的 (low, high) 范围。
- 忘记从递归的插入和删除中返回(可能是新的)根节点,导致修改丢失。
- 在退化的树上深度递归:Python 默认递归限制约为 1000,所以 5000 个节点的链会让递归代码崩溃。请使用循环,或保持树平衡。
- 没有明确重复键的策略:要决定重复键是忽略、计数,还是总放在某一侧。
何时使用 BST,以及典型面试题
当你需要有序数据并且频繁插入和删除时,请使用平衡 BST:范围查询、向下取整和向上取整查找、排名统计,或按顺序遍历。如果只需要精确键查找,请使用哈希表。在 Python 中,标准库没有树形映射;常见选择是用 bisect 模块配合有序列表处理小规模或基本不变的数据,或者使用第三方包 sortedcontainers。
典型面试题:验证 BST、找第 k 小的元素(中序遍历)、最近公共祖先、把有序数组转换为平衡 BST(选中间元素作根),以及找出某个节点的中序后继。
常见问题
二叉树和二叉搜索树有什么区别?
二叉树只限制每个节点最多有两个孩子。二叉搜索树则增加了有序规则:对每棵子树,较小的键在左边,较大的键在右边。
为什么 BST 的查找是 O(log n)?
每次比较都向下走一层,并丢弃一棵子树。有 n 个节点的平衡树大约有 log2(n) 层,所以查找大约需要这么多次比较。
把有序数据插入 BST 会怎样?
如果不做平衡,每个新键都会成为前一个键的右孩子,高度变成 n,所有操作都退化为 O(n)。AVL 树和红黑树这类自平衡树可以避免这种情况。
如何按有序顺序取出 BST 的键?
做中序遍历:先访问左子树,再访问节点,最后访问右子树。耗时 O(n)。
Python 有内置的二叉搜索树吗?
没有。Python 有 dict 和 set(哈希表),以及用于有序列表的 bisect 模块。有序的树形容器来自第三方包。