BST的退化问题与平衡思想
极难2从歪脖子树到平衡树:BST退化与自救指南
你有没有遇到过这样的场景:你在手机通讯录里按姓名首字母排序存朋友,结果新朋友的名字总是“张”开头,于是所有“张”都排在一起,要找某个姓张的朋友就得一个一个翻?二叉搜索树也会遇到类似的问题——当插入的数据是递增或递减的,树就会变得像一根“歪脖子”链子,查找速度慢得可怜。今天我们就来聊聊这个退化问题,以及人们如何想出“平衡树”这个聪明办法。
1 从排队买票说起
想象一下,你去游乐园排队买票。如果人们是按身高从矮到高依次排成一列,那么新来一个高个子,他只能排在队尾。这时候要想找某个身高的人,你必须从队头开始一个一个看,非常慢。但如果大家不是按身高排成一条直线,而是像二叉树那样,让矮个子和高个子分开两边站,那么查找就会快得多。
二叉搜索树也是类似。如果你插入的数据恰好是递增的,比如依次插入1、2、3、4、5……那么每次新节点都会成为当前最右边节点的右孩子,树就会变成一条向右倾斜的链表。如下图所示:
1
\
2
\
3
\
4
\
5
这种树的高度就是n(节点个数),查找一个节点需要比较n次,和顺序查找一样慢。这就是二叉搜索树的退化问题。
生活的另一个例子:你有一本小本子记录每周的零花钱收入,每天都在末尾加上新的一笔,那么要找到某一天的钱数,你必须从头看到尾。但如果每天的钱数都按规律分开放,比如110号放一堆,1120号放另一堆,每堆里再分……就能瞬间找到。
2 数据结构原理
2.1 退化的根源
二叉搜索树的性能取决于树的高度。理想情况下,一棵有n个节点的树,高度为log₂(n)(平衡时)。但实际中,如果插入的数据是排序好的(升序或降序),树就会严重偏向一侧,高度变成n,查找效率退化为O(n)。
更糟糕的是,实际应用中常常会遇到近乎有序的数据,比如时间序列、序号等。如果不做任何处理,BST就会退化,失去“快速查找”的优势。
为什么会这样? 因为二叉搜索树的插入规则是:比根小放左边,比根大放右边。如果数据一直递增,每次都比当前所有节点大,所以每次都往最右边跑,自然就变成了向右的链子。反之,递减数据会变成向左的链子。
2.2 平衡思想
为了防止退化,人们提出了“平衡”的概念:让树尽量保持左右子树高度相差不大,使得树的高度保持在O(log n)量级。这样的树称为平衡树。
平衡树有很多种,比如AVL树、红黑树、Treap、Splay树等。它们的基本思想是:在插入或删除节点后,通过一些局部调整(如旋转)来恢复树的平衡。
想象一个跷跷板:如果左边太重(左子树太高),就把左边的部分往右边挪一挪,让两边高度差不多。旋转就是干这样的事。
2.3 什么是旋转?
旋转是平衡树中最基本操作。它可以在不破坏二叉搜索树性质的前提下,改变树的结构,使树变得平衡。有两种旋转:左旋和右旋。
右旋(以节点A为轴):把A的左孩子B提升到A的位置,A变成B的右孩子,B原来的右孩子变成A的左孩子。
A B
/ \ / \
B C 右旋 D A
/ \ -----> / \
D E E C
左旋:对称,把A的右孩子B提升到A的位置,A变成B的左孩子,B原来的左孩子变成A的右孩子。
A B
/ \ / \
C B 左旋 A D
/ \ -----> / \
E D C E
旋转后,中序遍历结果不变(左-根-右的顺序仍保持递增),所以BST的性质没有被破坏。
通过旋转,我们可以让树从“瘦高”变为“矮胖”,降低高度。
用数字举例:假设你有以下退化树(向右歪):
1
\
2
\
3
我们现在对节点2进行左旋试试(前提是2有右孩子3)?但左旋通常需要不平衡节点。更常见的场景:比如1是根,它的右子树高度为2,左子树高度为0,高度差为2,已经不平衡。我们可以对1进行左旋:
左旋前:
1
\
2
\
3
左旋步骤:以1为轴,将它的右孩子2提升为根,1变成2的左孩子,2原来的左孩子(这里没有)变成1的右孩子。结果:
2
/ \
1 3
树变平衡了!高度从3降到2。
2.4 ASCII示意
假设我们有一棵向右侧退化的树:
1
\
2
\
3
\
4
现在对节点2进行左旋(实际上是针对根为2的子树?但为了整体平衡,通常需要从下往上调整)。例如,我们可以对节点1进行左旋?但左旋需要该节点的右孩子存在且不平衡。为了简单,我们只展示旋转效果。
更常见的场景是AVL树中,当插入导致不平衡时,根据情况做单旋或双旋。这里我们先理解旋转的概念即可。
3 常见错误:新手踩坑指南
错误1:插入后不检查平衡
许多同学写完BST插入代码后,发现查找很慢,以为是自己写错了。其实是没有在插入后检查树是否平衡,导致退化。平衡树必须在每次插入或删除后主动调整。
错误2:旋转时指针搞乱
旋转操作看似简单,但新手容易搞错指针指向。比如左旋时,忘记把B的左孩子(E)挂到A的右孩子上,导致树分裂成两截。建议先在纸上画图,一步步写代码。
错误3:混淆左旋和右旋的对称性
左旋和右旋是对称的,但新手常把方向记反。可以这样记:左旋是让右孩子“升”上来,右旋是让左孩子“升”上来。想象你用左手把左边的孩子往上提(右旋),用右手把右边的孩子往上提(左旋)。
错误4:忘记更新高度或平衡因子
在AVL树中,旋转后节点高度会变化,必须重新计算。如果忘记更新,后续判断平衡就会出错。每做一次旋转,都要更新受影响节点的高度。
4 完整示例:用旋转拯救退化树
下面我们用Python写一个简单的程序,演示如何通过左旋和右旋将一棵退化的树变成平衡树。虽然这不是完整的AVL树(不会自动检测不平衡),但能让你直观看到旋转的效果。
class TreeNode:
def __init__(self, val):
self.val = val # 节点值
self.left = None # 左孩子
self.right = None # 右孩子
def height(node):
"""计算树的高度(节点数)"""
if node is None:
return 0
return 1 + max(height(node.left), height(node.right))
def is_balanced(node):
"""判断是否平衡(简单定义:左右子树高度差<=1)"""
if node is None:
return True
left_h = height(node.left)
right_h = height(node.right)
if abs(left_h - right_h) > 1:
return False
return is_balanced(node.left) and is_balanced(node.right)
def left_rotate(root):
"""对根节点进行左旋,返回新的根"""
new_root = root.right # 新根是原根的右孩子
root.right = new_root.left # 原根的右孩子变成新根的左孩子
new_root.left = root # 新根的左孩子变成原根
return new_root
def right_rotate(root):
"""对根节点进行右旋,返回新的根"""
new_root = root.left # 新根是原根的左孩子
root.left = new_root.right # 原根的左孩子变成新根的右孩子
new_root.right = root # 新根的右孩子变成原根
return new_root
# 构造一棵退化的树:1 -> 2 -> 3 -> 4(向右斜)
root = TreeNode(1)
root.right = TreeNode(2)
root.right.right = TreeNode(3)
root.right.right.right = TreeNode(4)
print("旋转前:")
print(" 树高度:", height(root)) # 4
print(" 是否平衡:", is_balanced(root)) # False
# 演示手动旋转修复:先对节点2左旋(即对以2为根的子树左旋),然后对节点1左旋?
# 为了简单,我们直接对根节点1进行左旋,但1的右孩子只有2,左旋后得到:
# 2
# / \
# 1 3
# \
# 4
# 然后对新的根节点2的左孩子?实际上需要继续调整。这里仅展示一次旋转的效果。
print("\n进行一次左旋(对根节点1)后:")
root = left_rotate(root)
print(" 树高度:", height(root)) # 3
print(" 是否平衡:", is_balanced(root)) # 依然不平衡(右子树高度2,左子树1,差1?等等,左子树高度2?)
# 我们输出树的结构
def inorder(node):
if node:
inorder(node.left)
print(node.val, end=" ")
inorder(node.right)
print(" 中序遍历:", end=" ")
inorder(root)
print()
# 现在树变成:
# 2
# / \
# 1 3
# \
# 4
# 高度为3,仍然需要进一步旋转(对3左旋)。这里不继续,只为演示。
# 一个更好的例子:构建一棵完全退化的树(1-2-3),一次左旋即可平衡
print("\n更好的示范:一棵高度为3的退化树1->2->3")
root2 = TreeNode(1)
root2.right = TreeNode(2)
root2.right.right = TreeNode(3)
print(" 旋转前高度:", height(root2)) # 3
root2 = left_rotate(root2) # 一次左旋后变成平衡树
print(" 一次左旋后高度:", height(root2)) # 2
print(" 中序遍历:", end=" ")
inorder(root2)
print()
print(" 是否平衡:", is_balanced(root2)) # True
运行这段代码,你会看到退化树(1-2-3)经过一次左旋后变成了高度为2的平衡树。这就是旋转的威力。
5 平衡树的思想
为了克服退化,平衡树在每次插入或删除后,会检查并修复不平衡。主要有以下方法:
- AVL树:严格保持左右子树高度差不超过1,通过四种旋转(左旋、右旋、左右双旋、右左双旋)调整。
- 红黑树:通过颜色约束和旋转保持近似平衡,保证最长路径不超过最短路径的两倍。
- Treap:结合二叉搜索树和堆,通过随机优先级和旋转保持形状像随机二叉树,期望高度为O(log n)。
- Splay树:通过将访问的节点旋转到根,让经常访问的节点靠近根,虽然最坏情况是O(n),但均摊是O(log n)。
这些平衡树各有特点,但核心都是旋转。理解了旋转,就掌握了平衡树的钥匙。
6 总结要点
- 二叉搜索树如果插入有序数据会退化为链表,查找效率O(n)。
- 退化的根本原因是树变得高瘦,失去了二分查找的优势。
- 为了解决退化,人们提出了平衡树,通过旋转调整树的结构,保持树高接近log(n)。
- 旋转是平衡树的基本操作,分为左旋和右旋,不改变中序遍历顺序。
- 后续会学习具体的平衡树如AVL和Treap,它们就是利用旋转来维持平衡。
现在你已经明白了为什么需要平衡树,下一篇文章将详细介绍AVL树及其旋转操作。
相关指引
如果你对平衡树感兴趣,可以继续学习:
- AVL树:最经典的严格平衡树,适合读多写少的场景。
- 红黑树:C++ STL中map、set的底层实现,平衡条件更宽松,但旋转更少。
- Treap:用随机化保证平衡,实现简单,适合竞赛。
- B树/B+树:数据库索引中常用,能处理大量数据。
从“歪脖子树”到“平衡树”,你已经在算法路上迈出了坚实的一步。继续加油!
例题精讲
二叉搜索树(BST)在什么情况下会退化成类似于链表的结构?
平衡二叉搜索树(如AVL树)通过旋转操作确保左右子树高度差不超过1,从而保证查找、插入、删除操作的最坏时间复杂度为O(log n)。
实现一个函数判断BST是否平衡(高度差不超过1)。请补全以下代码:
int getHeight(TreeNode* node) {
if (node == nullptr) return 0;
int left = getHeight(node->left);
if (left == -1) return -1;
int right = getHeight(node->right);
if (right == -1) return -1;
if (___) return -1;
return max(left, right) + 1;
}
bool isBalanced(TreeNode* root) {
return getHeight(root) != -1;
}以下关于BST退化的说法,正确的是?
任何二叉搜索树都可以通过简单的右旋和左旋操作转化为平衡树。