CC++ & Algorithm

BST的退化问题与平衡思想

极难2
语言版本:通用
概述:用排队买票的例子说明按顺序插入数据会导致BST退化为链表,引出平衡树的思想,即通过旋转等操作让树保持矮胖。

从歪脖子树到平衡树: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+树:数据库索引中常用,能处理大量数据。

从“歪脖子树”到“平衡树”,你已经在算法路上迈出了坚实的一步。继续加油!

例题精讲

1单选题

二叉搜索树(BST)在什么情况下会退化成类似于链表的结构?

A插入随机序列的数据
B插入有序或接近有序的序列数据
C删除所有叶子节点
D进行中序遍历
2判断题

平衡二叉搜索树(如AVL树)通过旋转操作确保左右子树高度差不超过1,从而保证查找、插入、删除操作的最坏时间复杂度为O(log n)。

3填空题
实现一个函数判断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;
}
4单选题

以下关于BST退化的说法,正确的是?

A退化的根本原因是节点值重复
B退化可以通过在插入时随机化旋转来完全避免
C退化的直接后果是查找效率下降为O(n)
D退化只会发生在插入操作中,删除不会导致退化
5判断题

任何二叉搜索树都可以通过简单的右旋和左旋操作转化为平衡树。