跳到主要内容
预计阅读 31 分钟

层层分叉的世界 —— 树与二叉树

树是自然界最优雅的结构——从树干分出枝干,从枝干分出树枝,从树枝长出叶子。计算机科学把这个结构倒过来画,用来解决”层级”和”高效查找”的问题。

📋 开篇自测:你已经知道多少?

  1. 你电脑里的文件夹结构就是一棵树——你能说出根节点、叶子节点、子树各对应什么吗?
  2. 二叉搜索树的查找为什么平均只需要O(log n)?在什么情况下它会退化成O(n)?
  3. 红黑树和AVL树都是”自平衡”的,但Java的HashMap为什么选了红黑树而不是AVL树?

一、为什么需要树结构

前面几章学的数组、链表、栈、队列,都是”一条线”式的线性结构。但现实世界中很多关系并不是线性的:

  • 公司的组织架构:CEO管副总,副总管总监,总监管经理…
  • 操作系统的文件目录:根目录下有多个文件夹,每个文件夹又可以有子文件夹
  • HTML网页的DOM结构:html下面有head和body,body下面有div、p、img…

这些关系天然就是层级式的,用线性结构来表示会非常别扭。而树结构则完美匹配这种”一对多”的层次关系。

树的基本术语

          CEO
        /     \
      VP1      VP2
     / \        |
   Dir1 Dir2   Dir3
   |          / \
  Mgr1     Mgr2 Mgr3
  • 根节点(Root): 最顶上的节点,比如CEO。没有父节点。
  • 叶子节点(Leaf): 没有子节点的节点,比如Mgr1、Dir2、Mgr2、Mgr3。
  • 父节点和子节点: VP1是Dir1的父节点,Dir1是VP1的子节点。
  • 深度(Depth): 从根到某个节点的层数。根节点深度为0。
  • 高度(Height): 从某个节点到最远叶子的层数。整棵树的高度就是根节点的高度。
  • 子树(Subtree): 每个节点和它的所有后代构成一棵子树。

二、二叉树——树家族中的超级明星

二叉树(Binary Tree) 是最常用的树结构,它有一个简单的规则:每个节点最多有两个子节点,分别叫左子节点和右子节点。

class TreeNode:
    """二叉树节点"""
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None

# 手动构建一棵二叉树
#       1
#      / \
#     2   3
#    / \   \
#   4   5   6

root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
root.left.left = TreeNode(4)
root.left.right = TreeNode(5)
root.right.right = TreeNode(6)

三种遍历方式——把树”捋直”

树是二维的,但我们经常需要把它按某种顺序”捋”成一维的序列。三种最基本的遍历方式:

def preorder(node):
    """前序遍历:根 -> 左 -> 右"""
    if node is None:
        return []
    return [node.value] + preorder(node.left) + preorder(node.right)

def inorder(node):
    """中序遍历:左 -> 根 -> 右"""
    if node is None:
        return []
    return inorder(node.left) + [node.value] + inorder(node.right)

def postorder(node):
    """后序遍历:左 -> 右 -> 根"""
    if node is None:
        return []
    return postorder(node.left) + postorder(node.right) + [node.value]

print("前序:", preorder(root))    # [1, 2, 4, 5, 3, 6]
print("中序:", inorder(root))     # [4, 2, 5, 1, 3, 6]
print("后序:", postorder(root))   # [4, 5, 2, 6, 3, 1]

怎么记这三种遍历?“前中后”说的是根节点的位置:

  • 前序:在前面(根-左-右)
  • 中序:在中间(左-根-右)
  • 后序:在后面(左-右-根)

还有一种层序遍历——一层一层地从上到下、从左到右,需要用队列来实现:

from collections import deque

def level_order(root):
    """层序遍历:逐层从左到右"""
    if root is None:
        return []
    result = []
    queue = deque([root])
    while queue:
        node = queue.popleft()
        result.append(node.value)
        if node.left:
            queue.append(node.left)
        if node.right:
            queue.append(node.right)
    return result

print("层序:", level_order(root))  # [1, 2, 3, 4, 5, 6]

🤔 想一想 你的电脑遍历一个文件夹中的所有文件,用的是哪种遍历方式?(提示:os.walk()是层序还是深度优先?)


三、二叉搜索树——让查找变得飞快

普通二叉树没有什么特殊规则,所以查找一个元素还是得遍历所有节点,跟链表差不多。但如果我们加上一条规则呢?

二叉搜索树(BST, Binary Search Tree)的规则: 对于任意节点,它的左子树中所有值都比它小,右子树中所有值都比它大。

        8
       / \
      3   10
     / \    \
    1   6   14
       / \  /
      4  7 13

有了这条规则,查找就变得像翻字典一样高效:

class BST:
    """二叉搜索树"""

    def __init__(self):
        self.root = None

    def insert(self, value):
        """插入一个值"""
        self.root = self._insert(self.root, value)

    def _insert(self, node, value):
        if node is None:
            return TreeNode(value)
        if value < node.value:
            node.left = self._insert(node.left, value)
        elif value > node.value:
            node.right = self._insert(node.right, value)
        return node

    def search(self, value):
        """查找一个值"""
        return self._search(self.root, value)

    def _search(self, node, value):
        if node is None:
            return False
        if value == node.value:
            return True
        elif value < node.value:
            return self._search(node.left, value)
        else:
            return self._search(node.right, value)

    def inorder(self):
        """中序遍历得到有序序列"""
        result = []
        self._inorder(self.root, result)
        return result

    def _inorder(self, node, result):
        if node:
            self._inorder(node.left, result)
            result.append(node.value)
            self._inorder(node.right, result)

# 使用
bst = BST()
for val in [8, 3, 10, 1, 6, 14, 4, 7, 13]:
    bst.insert(val)

print(bst.search(7))     # True
print(bst.search(9))     # False
print(bst.inorder())     # [1, 3, 4, 6, 7, 8, 10, 13, 14] 自动排好序了!

每次查找都排除一半的节点,所以平均时间复杂度是 O(log n)——和二分查找一样快。

BST的致命弱点——退化

如果你把1, 2, 3, 4, 5按顺序插入BST,会得到什么?

1
 \
  2
   \
    3
     \
      4
       \
        5

它退化成了一条链表!查找变成了O(n)。这就像一棵树只朝一个方向长,完全失去了”分叉”带来的效率优势。

解决办法:让树始终保持”平衡”。这就是接下来要讲的AVL树和红黑树。


四、AVL树——严格的平衡主义者

AVL树(以发明者Adelson-Velsky和Landis命名)是第一种自平衡二叉搜索树。它的规则是:任何节点的左右子树高度差不超过1。

一旦插入或删除操作破坏了这个平衡条件,就通过”旋转”来恢复平衡。

四种旋转操作

旋转听起来复杂,但本质上就是调整几个指针的指向。以”右旋”为例:

    失衡前(左边太重)        右旋后
        30                    20
       /                     /  \
      20                   10    30
     /
    10
def right_rotate(root):
    """右旋操作"""
    new_root = root.left
    root.left = new_root.right
    new_root.right = root
    return new_root

def left_rotate(root):
    """左旋操作"""
    new_root = root.right
    root.right = new_root.left
    new_root.left = root
    return new_root

四种失衡情况及对应的旋转:

失衡类型形状修复方法
LL(左-左)歪向左下右旋一次
RR(右-右)歪向右下左旋一次
LR(左-右)左子树歪向右先左旋左子树,再右旋根
RL(右-左)右子树歪向左先右旋右子树,再左旋根

AVL树的完整插入实现

光有旋转函数还不够,我们需要把它们整合到插入操作中。每次插入后,需要从插入位置向上检查每个祖先节点的平衡因子(左子树高度减右子树高度),一旦发现失衡就立即旋转修复。

class AVLNode:
    """AVL树节点(额外存储高度信息)"""
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None
        self.height = 1  # 新节点高度为1

class AVLTree:
    """AVL自平衡二叉搜索树"""

    def __init__(self):
        self.root = None

    def _get_height(self, node):
        """获取节点高度,空节点高度为0"""
        if node is None:
            return 0
        return node.height

    def _get_balance(self, node):
        """获取平衡因子:左子树高度 - 右子树高度"""
        if node is None:
            return 0
        return self._get_height(node.left) - self._get_height(node.right)

    def _update_height(self, node):
        """更新节点高度"""
        node.height = 1 + max(self._get_height(node.left),
                              self._get_height(node.right))

    def _right_rotate(self, z):
        """右旋(处理LL失衡)"""
        y = z.left
        T3 = y.right
        y.right = z
        z.left = T3
        self._update_height(z)
        self._update_height(y)
        return y

    def _left_rotate(self, z):
        """左旋(处理RR失衡)"""
        y = z.right
        T2 = y.left
        y.left = z
        z.right = T2
        self._update_height(z)
        self._update_height(y)
        return y

    def insert(self, value):
        """对外接口:插入一个值"""
        self.root = self._insert(self.root, value)

    def _insert(self, node, value):
        """递归插入并自动平衡"""
        # 第一步:执行标准BST插入
        if node is None:
            return AVLNode(value)
        if value < node.value:
            node.left = self._insert(node.left, value)
        elif value > node.value:
            node.right = self._insert(node.right, value)
        else:
            return node  # 不插入重复值

        # 第二步:更新当前节点的高度
        self._update_height(node)

        # 第三步:计算平衡因子,判断是否失衡
        balance = self._get_balance(node)

        # 四种失衡情况的处理(注意:这里通过比较 value 与子节点值来判断
        # 失衡类型,此方法仅适用于插入操作。删除操作应比较子树的平衡因子):
        # LL型:左子树的左边太高 -> 右旋
        if balance > 1 and value < node.left.value:
            return self._right_rotate(node)

        # RR型:右子树的右边太高 -> 左旋
        if balance < -1 and value > node.right.value:
            return self._left_rotate(node)

        # LR型:左子树的右边太高 -> 先左旋左子树,再右旋
        if balance > 1 and value > node.left.value:
            node.left = self._left_rotate(node.left)
            return self._right_rotate(node)

        # RL型:右子树的左边太高 -> 先右旋右子树,再左旋
        if balance < -1 and value < node.right.value:
            node.right = self._right_rotate(node.right)
            return self._left_rotate(node)

        return node

    def inorder(self):
        """中序遍历(验证BST性质)"""
        result = []
        self._inorder(self.root, result)
        return result

    def _inorder(self, node, result):
        if node:
            self._inorder(node.left, result)
            result.append(node.value)
            self._inorder(node.right, result)

# 验证:插入有序序列,AVL树不会退化
avl = AVLTree()
for val in [1, 2, 3, 4, 5, 6, 7]:
    avl.insert(val)

print(avl.inorder())  # [1, 2, 3, 4, 5, 6, 7]
print(f"根节点: {avl.root.value}")         # 4(自动平衡后的根)
print(f"树高度: {avl.root.height}")         # 3(而非退化成7)

对比一下:同样插入1到7这7个数字,普通BST会退化成高度为7的链表,而AVL树的高度只有3。这就是自平衡的威力。

AVL树保证了任何操作的时间复杂度都是O(log n),因为树的高度始终被控制在log(n)附近。


五、红黑树——实用主义的平衡方案

AVL树虽然平衡性很好,但每次插入和删除都可能触发多次旋转,维护成本较高。红黑树是一种更”宽松”的平衡方案——它不要求左右子树高度差不超过1,而是通过给节点涂上红色或黑色来维持一种”大致平衡”。

红黑树的五条规则:

  1. 每个节点要么是红色,要么是黑色
  2. 根节点必须是黑色
  3. 所有叶子节点(NIL哨兵节点,即不存储数据的空节点)是黑色
  4. 红色节点的两个子节点必须是黑色(不能有连续两个红节点)
  5. 从任意节点到其每个叶子的路径上,黑色节点的数量相同

这五条规则共同保证了:红黑树的最长路径不会超过最短路径的两倍。 所以虽然不像AVL那样严格平衡,但也足够好了——所有操作仍然是O(log n)。

AVL树 vs 红黑树

特性AVL树红黑树
平衡程度严格平衡近似平衡
查找效率略好(树更矮)略差
插入/删除效率较差(旋转多)较好(旋转少)
适用场景查找密集型插入/删除密集型
应用实例数据库索引Java的TreeMap、Linux的进程调度

为什么实际工程中红黑树更常见?因为大多数场景下插入删除操作比查找更频繁,而红黑树在这方面的开销更小。

自平衡的实际效果演示

红黑树的完整实现涉及大量的旋转和变色操作,代码量较大。但我们可以用Python的sortedcontainers库来直观感受有序容器的效果——它底层使用的是”排序列表的列表”(类似B-tree的分块策略),而非红黑树或AVL树,但同样能在插入有序数据时保持O(log n)的性能,不会像普通BST那样退化。

# 方法1:用sortedcontainers直观感受有序容器的效果
# 安装:pip install sortedcontainers
from sortedcontainers import SortedList

sl = SortedList()

# 即使按顺序插入,内部仍保持平衡,查找/插入都是O(log n)
for i in range(1, 100001):
    sl.add(i)

# 查找操作依然飞快(不会退化成O(n))
print(50000 in sl)                 # True,O(log n)
print(sl[0], sl[-1])               # 1 100000
print(sl.bisect_left(50000))       # 49999(第50000个位置)

# 方法2:用我们上面实现的AVL树对比普通BST
import time

# 普通BST插入有序数据——退化成链表,性能极差
bst = BST()
start = time.time()
for i in range(1, 5001):
    bst.insert(i)
bst_time = time.time() - start
print(f"普通BST插入5000个有序元素: {bst_time:.3f}秒")

# AVL树插入有序数据——保持平衡,性能稳定
avl = AVLTree()
start = time.time()
for i in range(1, 5001):
    avl.insert(i)
avl_time = time.time() - start
print(f"AVL树插入5000个有序元素: {avl_time:.3f}秒")
print(f"AVL树高度: {avl.root.height}(理想高度约{13})")
# 你会发现AVL树比退化的BST快得多

这个对比清楚地展示了自平衡的价值:面对有序数据,普通BST退化成链表后性能急剧下降,而自平衡树(无论是AVL树还是红黑树)始终保持O(log n)的高效性能。

🤔 想一想 你可能听说过Java的HashMap在链表长度超过8时会把链表转换成红黑树(前提是表的容量也达到了64,否则只会触发扩容而不是树化)。为什么不是一开始就用红黑树?为什么阈值是8而不是其他数字?


六、堆——另一种特殊的树

堆(Heap) 也是用二叉树的结构,但规则和BST不同:

  • 大顶堆: 每个节点的值都大于等于其子节点的值(根是最大值)
  • 小顶堆: 每个节点的值都小于等于其子节点的值(根是最小值)

堆的最大特点:可以在O(1)时间内获取最大值(或最小值),在O(log n)时间内插入或删除。

堆通常用数组来实现(而不是链式结构),因为完全二叉树有一个巧妙的性质——父子节点的索引存在简单的数学关系:

对于索引为i的节点:
- 父节点索引:(i - 1) // 2
- 左子节点索引:2 * i + 1
- 右子节点索引:2 * i + 2

堆的核心操作是上浮(sift up)下沉(sift down):插入新元素时放到末尾再上浮,取出堆顶时用末尾填顶再下沉。这两个操作保证了堆性质始终被维护。

在Python中,heapq 模块提供了现成的小顶堆实现:

import heapq

data = [5, 3, 8, 1, 2, 7]
heapq.heapify(data)         # 把列表原地转成堆
print(heapq.heappop(data))  # 1
print(heapq.heappop(data))  # 2
heapq.heappush(data, 0)     # 插入0
print(heapq.heappop(data))  # 0

堆是优先队列、堆排序、Top-K问题等场景的核心数据结构。我们会在第12章深入学习堆的完整实现、堆排序算法以及一系列经典应用题。

⚠️ 常见误区

  • 误区1:“二叉搜索树和堆是一回事” —— 完全不同。BST的规则是”左小右大”,堆的规则是”父大于子”或”父小于子”。BST的中序遍历是有序的,堆不是。
  • 误区2:“树越平衡越好” —— 维持平衡是有代价的。如果你的场景只需要插入不需要查找,用普通BST甚至链表可能更合适。
  • 误区3:“红黑树比AVL树好” —— 不是绝对的。如果你的场景以查找为主(比如数据库索引),AVL树由于更矮,查找次数更少,反而更合适。

七、树在现实世界中的应用

  • 文件系统: 目录树就是一棵多叉树
  • 数据库索引: B树和B+树是多叉搜索树,能在磁盘上高效查找
  • 编译器: 把代码解析成抽象语法树(AST)再进行编译
  • 网页渲染: 浏览器把HTML解析成DOM树
  • 人工智能: 决策树、博弈树
  • 压缩算法: 哈夫曼编码用的是哈夫曼树

📝 掌握度自测

  1. 概念题: 什么是完全二叉树?它和满二叉树有什么区别?为什么堆要求是完全二叉树?
  2. 手算题: 将 [5, 2, 8, 1, 4, 7, 9, 3] 逐个插入一棵空的BST,画出最终的树形结构。然后写出中序遍历结果。
  3. 分析题: 一棵有1000000个节点的平衡BST,最多需要比较多少次才能确定某个值是否存在?
  4. 编码题: 实现一个函数,计算二叉树的最大深度。
  5. 设计题: 如果你要设计一个实时排行榜(支持插入成绩、删除成绩、查询第k名),你会用什么数据结构?为什么?

💡 自我评估

  • 全部答对:树结构已经是你的强项了,向更深处探索吧。
  • 答对3-4题:基础很好,建议手动实现一下AVL树的旋转操作来加深理解。
  • 答对1-2题:树是比较抽象的数据结构,建议拿纸笔画图辅助理解。
  • 全部答错:先确保你理解了递归,因为树的大部分操作都基于递归思想。

数据结构的学习暂告一段落,接下来我们把注意力转向算法本身。第一个登场的是计算机科学中研究最深入的经典问题之一——排序。从最朴素的冒泡到精妙的快速排序,下一章带你领略排序算法的演进之路。

购买课程解锁全部内容

刷题到通关:数据结构与算法面试实战

¥29.90