从算法到人工智能 · 第 12 课:树与二叉树——以及 O(log n) 的亲戚们
前面学的数组、链表、栈、队列、哈希表,数据都是"一条线"排开的。但现实里很多东西天生是分叉的:
- 公司组织架构:一个 CEO 下面一堆 VP,每个 VP 下面又带几个总监……
- 文件夹结构:一个目录里有子目录,子目录里还有子目录……
- 网页的 HTML:
<body>里套<div>,<div>里又套<p>……
这些"一个上级、多个下级"的结构,用一条线表示就很不自然,得用树(tree)。
树是整个算法世界里最重要的数据结构之一,后面要学的堆、二叉搜索树、乃至 AI 里的决策树、语法树,全是从这棵树上长出来的。这一课,我们认识树、遍历树、递归地"玩"树。
一、树是什么:一堆"节点"用边连起来
先记几个术语,用一张家族关系图秒懂:
A ← 根节点(root),最上面的那个
/ \
B C ← B、C 是 A 的孩子(children),A 是它们的父节点(parent)
/ \ \
D E F ← D、E、F 是叶子节点(leaf,没有孩子)| 术语 | 含义 |
|---|---|
| 根节点 root | 树最顶端的节点,只有一个 |
| 父/子节点 | 有边相连的上下两个节点 |
| 叶子 leaf | 没有任何孩子的节点 |
| 深度 depth | 从根到某节点经过的边数 |
| 高度 height | 从某节点到最远叶子的边数 |
| 子树 subtree | 任意节点连同它下面的所有节点,又构成一棵树 |
树的本质:一个节点可以指向多个子节点,但每个节点只有一个父节点(根除外),且不能有环。
二、二叉树:每个节点最多两个孩子
如果限定"每个节点最多两个孩子",就成了二叉树(binary tree)——左孩子 + 右孩子。它是被研究得最透彻的树,也是面试最爱。
在 Python 里,二叉树节点用一个类表示:
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val # 节点值
self.left = left # 左孩子
self.right = right # 右孩子
# 手工搭一棵树
# 1
# / \
# 2 3
# / \
# 4 5
root = TreeNode(1,
TreeNode(2, TreeNode(4), TreeNode(5)),
TreeNode(3)
)三、遍历二叉树:三种经典顺序
树的"遍历",就是按某种顺序访问每个节点。二叉树有三种经典遍历,区别只在"根节点什么时候访问":
- 前序(preorder):根 → 左 → 右
- 中序(inorder):左 → 根 → 右
- 后序(postorder):左 → 右 → 根
对着这棵树看:
A
/ \
B C
/ \
D E- 前序:A B D E C(先到哪个先记哪个)
- 中序:D B E A C(左完记根,再右)
- 后序:D E B C A(左右都完才记根)
三种遍历用递归写,短到不像话——这就是树和递归的"天生一对":
def preorder(root):
if not root:
return
print(root.val, end=" ") # 根
preorder(root.left) # 左
preorder(root.right) # 右
def inorder(root):
if not root:
return
inorder(root.left) # 左
print(root.val, end=" ") # 根
inorder(root.right) # 右
def postorder(root):
if not root:
return
postorder(root.left) # 左
postorder(root.right) # 右
print(root.val, end=" ") # 根记忆口诀:前序"根在前",中序"根在中",后序"根在后"。
三种遍历分别有什么用?
- 前序:复制一棵树、序列化保存(先存根,才能重建)
- 中序:二叉搜索树(BST)的中序遍历,结果是从小到大排好序的!这是它最重要的用途
- 后序:删除一棵树、算树的高度(先处理完孩子,再处理根)
四、层次遍历:一层一层地走
除了递归的三种,还有一种按层的遍历——层次遍历(level order),它要用到第 2 课学的队列:
A 层次遍历结果:A B C D E
/ \ (第0层A,第1层B C,第2层D E)
B C
/ \
D Efrom collections import deque
def level_order(root):
if not root:
return []
result = []
queue = deque([root])
while queue:
level = []
for _ in range(len(queue)): # 每次处理完当前一整层
node = queue.popleft()
level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
result.append(level)
return result
# 对上面的树,返回 [[1], [2, 3], [4, 5]]层次遍历的心法:广度优先(BFS)——用队列,一层一层往外扩。它对应的是第 14 课要讲的"图的广度优先搜索",现在先混个脸熟。
五、递归"玩"树:三行一个经典问题
树的题,几乎全是递归。因为"树的任何子树,本身就是一棵树"——这个自相似性,让递归成了天然解法。
1. 求树的高度
def max_depth(root):
if not root:
return 0
return 1 + max(max_depth(root.left), max_depth(root.right))
# 树高 = 1(自己)+ 左右子树里更高的那个2. 判断两棵树是否相同
def is_same(p, q):
if not p and not q:
return True # 都空,相同
if not p or not q:
return False # 一个空一个不空,不同
if p.val != q.val:
return False # 值不同,不同
return is_same(p.left, q.left) and is_same(p.right, q.right)
# 值相同,还得左右子树都相同3. 反转二叉树(左右镜像互换)
def invert(root):
if not root:
return None
root.left, root.right = root.right, root.left # 左右互换
invert(root.left)
invert(root.right)
return root看这三题的共同套路:递归处理"根 + 左子树 + 右子树",边界是"空节点"。树的问题,先想清楚"空树怎么办",再想"根和左右子树怎么组合",答案就出来了。
六、复杂度小结
对一棵有 n 个节点的二叉树:
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 遍历(前/中/后序) | O(n) | 每个节点访问一次 |
| 层次遍历 | O(n) | 每个节点进出队列一次 |
| 求高度 | O(n) | 递归访问所有节点 |
| 递归栈空间 | O(h) | h 是树高,最坏退化成链时是 O(n) |
注意最坏情况:如果二叉树退化成一条链(每个节点只有左孩子或只有右孩子),树高 h = n,递归会爆栈,所有"O(h)"的空间都变成 O(n)。这也是为什么后面要学平衡树——保持树"矮胖",性能才稳。
七、平衡二叉树与红黑树:让树永远"矮胖"
问题:最怕"退化成链"
上一节说过,二叉查找树最怕数据有序地进来——比如按 1、2、3、4、5 顺序插入,树就长成一条斜线,查找退化成 O(n)。
要治这个病,就一个办法:插入、删除时,顺手把树"扶正",让它保持"矮胖"(平衡)。树一旦平衡,高 h 就是 O(log n),查找/插入/删除就都能锁死在 O(log n)。
平衡二叉树(AVL):严格"左右身高差 ≤ 1"
最早的平衡树叫 AVL 树,它立了一条很严的规矩:
任意节点的左子树高度和右子树高度,最多差 1。一旦差超过 1,就通过旋转把树重新扶正。
「旋转」不神秘,就是改变父子关系、让树重新"站正",本质是几行指针调换:
# 右旋示意(左重右轻时,把左边顶上去)
# y x
# / \ / \
# x C ==> A y
# / \ / \
# A B B C
def right_rotate(y):
x = y.left
y.left = x.right
x.right = y
return x # x 成为新的根AVL 的好处:查找严格 O(log n),因为树高被死死压住。代价:规矩太严,每次插入/删除可能要转好几下,写代码的人很痛苦。
红黑树:放宽一点,用"颜色"偷懒
AVL 太较真,于是有了红黑树(Red-Black Tree)——它不再要求"严格平衡",只要求"大致平衡",换取更少的旋转,更适合频繁增删的场景。
红黑树的五条规矩(记住前四条就能看懂):
- 每个节点要么红,要么黑。
- 根节点永远是黑的。
- 每个叶子(空节点)算黑的。
- 红色节点的两个孩子必须是黑的(红不能连着红)。
- 从任意节点到它所有叶子,经过的黑色节点数量一样多(黑高相等)。
第 5 条是精髓:它保证"最长的路径也不会比最短的路径长到离谱"——最长路径最多是"红黑红黑…",最短是"全黑",两者长度最多差 1 倍。所以树高 O(log n),查找仍是 O(log n),但插入删除时旋转次数比 AVL 少。
AVL vs 红黑树,怎么选
| 维度 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡程度 | 严格(高差 ≤1) | 大致(最长 ≤ 2×最短) |
| 查找 | 更快一点(树更矮) | 略慢一点(树稍高) |
| 插入/删除 | 旋转多,慢 | 旋转少,快 |
| 适合 | 读多写少(如字典查询) | 读写都频繁(如数据库索引、map) |
一句话记住:AVL 是"书呆子",规矩死但查得快;红黑树是"灵活派",稍微松一点、换来增删更省事。
工程里你几乎不用手写红黑树——C++ 的 std::map、Java 的 TreeMap、Linux 内核、数据库的很多索引,底层都是红黑树。Python 标准库没有直接提供,但 sortedcontainers 这类库、以及数据库的 B+ 树思路,都能给你 O(log n) 的有序查找。
八、跳表:不用树,也能 O(log n)
平衡树(AVL、红黑树)能保证查找/插入/删除都是 O(log n),但写起来很麻烦——旋转、变色、一堆边界情况。有没有更简单的结构也能做到 O(log n)?
有,跳表(Skip List)。它的思路特别"接地气":
一个有序链表,查找要 O(n)(只能一步步走)。但如果我在链表上面再架几层"索引",只指向隔几个的节点,就能"跳着走",把查找降到 O(log n)。
具体做法:
- 底层是一个有序链表,存所有元素。
- 往上再叠几层"索引链表",每层只保留下面一层约一半的节点(用抛硬币决定每个节点"升不升到上一层")。
- 查找时从最高层往下走:能跳就跳,跳不动了降一层,最后落到目标附近。
# 跳表概念示意(不实现完整代码,感受"分层索引"即可)
# 底层链表: 1 -> 3 -> 5 -> 7 -> 9 -> 11
# 第 1 层索引:1 -----> 5 -----> 9 ------> 11
# 第 2 层索引:1 -----------> 9 ----------> 11
# 找 7:从第 2 层 1 -> 9(超了,降一层)-> 5 -> 9(超了,降到底层)-> 7 ✅
# 只跳了几步就定位到 7;n 越大,比纯链表省得越多。为什么是 O(log n):每层约减半,层数是 O(log n),每层最多走几步就降层,所以查找是 O(log n)。
跳表 vs 平衡树:
| 维度 | 跳表 | 平衡树(红黑树等) |
|---|---|---|
| 查找/插入/删除 | O(log n) | O(log n) |
| 实现难度 | 简单,就是"多层链表" | 复杂,旋转/变色 |
| 范围查询 | 天然支持(从最小值往后扫) | 中序遍历 |
| 额外空间 | O(n)(索引节点) | O(n) |
Redis 的有序集合(ZSet)底层就是跳表——工程里"要 O(log n) 又不想写红黑树"时的首选。
九、延伸:线段树与树状数组——区间查询的加速器
前面树的操作,都是一次访问一个节点。但有一类高频问题,树解决得不好:
数组 [3, 1, 4, 1, 5, 9, 2, 6],反复问"第 2 到第 6 个数的和是多少?",中间还穿插着"把第 4 个数改成 8"。如果每次求和都从头加一遍,是 O(n);改一个数倒是 O(1)。有没有办法区间求和、单点修改都很快?有两个经典结构:树状数组和线段树。
1. 树状数组(Fenwick Tree):代码极短的前缀和利器
核心思想:维护一个数组 t,其中 t[i] 存的是"以 i 结尾、长度为 lowbit(i) 的一段的和"。lowbit(i) = i & -i,取出 i 二进制里最右边的 1。
class BIT:
def __init__(self, n):
self.t = [0] * (n + 1)
self.n = n
def add(self, i, v): # 单点修改:a[i] += v
while i <= self.n:
self.t[i] += v
i += i & -i # 往上爬
def sum(self, i): # 前缀和:a[1..i]
s = 0
while i > 0:
s += self.t[i]
i -= i & -i # 往下跳
return s
bit = BIT(10)
for i, v in enumerate([1, 3, 5, 7, 9, 11], 1):
bit.add(i, v)
print(bit.sum(5)) # 25(1+3+5+7+9)区间和 = sum(r) - sum(l-1)。单点修改和前缀查询都是 O(log n)。
2. 线段树(Segment Tree):能处理"任意区间聚合"
树状数组只能处理"可逆"的运算(求和可减)。线段树更通用:它是一棵每个节点代表一个区间的二叉树,叶子是单个元素,父节点的值 = 两个子节点值的合并(和、最大值、最小值……)。
# 递归建树 + 区间查询(以区间求和为例)
class SegTree:
def __init__(self, arr):
self.n = len(arr)
self.tree = [0] * (4 * self.n)
self._build(arr, 1, 0, self.n - 1)
def _build(self, arr, node, l, r):
if l == r:
self.tree[node] = arr[l]
return
mid = (l + r) // 2
self._build(arr, node*2, l, mid)
self._build(arr, node*2+1, mid+1, r)
self.tree[node] = self.tree[node*2] + self.tree[node*2+1]
def query(self, ql, qr):
return self._query(1, 0, self.n-1, ql, qr)
def _query(self, node, l, r, ql, qr):
if qr < l or r < ql: # 完全不相交
return 0
if ql <= l and r <= qr: # 完全包含
return self.tree[node]
mid = (l + r) // 2
return self._query(node*2, l, mid, ql, qr) + self._query(node*2+1, mid+1, r, ql, qr)
st = SegTree([1, 3, 5, 7, 9, 11])
print(st.query(1, 4)) # 24(3+5+7+9)区间查询、区间修改都是 O(log n),还能支持"区间加/区间最值"等树状数组做不了的运算。
3. 前缀和与差分:不修改 / 不查询时的 O(1) 神器
树状数组/线段树是"查询、修改都要快"时的全能选手。但如果你的需求更单纯,有两个代码更短、跑得更快的 O(1) 技巧:
前缀和(Prefix Sum):只读不改,反复问区间和。
arr = [3, 1, 4, 1, 5, 9, 2, 6]
pre = [0]
for v in arr:
pre.append(pre[-1] + v) # pre[i] = 前 i 个数的和
# 区间 [l, r] 的和 = pre[r+1] - pre[l]
print(pre[6] - pre[1]) # 第 2~6 个数的和 = 1+4+1+5+9 = 20预处理 O(n),之后每次区间求和 O(1)。
差分(Diff Array):反向操作——"频繁区间修改、最后一次性查询"时用它。
arr = [0] * 8
diff = [0] * (len(arr) + 1)
# 给区间 [l, r] 整体 +v:diff[l] += v; diff[r+1] -= v
def add(l, r, v):
diff[l] += v
diff[r + 1] -= v
add(1, 4, 5) # 第 1~4 个元素 +5
add(3, 6, 2) # 第 3~6 个元素 +2
# 最后做一次前缀和还原:
cur = 0
for i in range(len(arr)):
cur += diff[i]
arr[i] = cur
print(arr) # [0, 5, 5, 7, 7, 2, 2, 0]每次修改 O(1),最后一次性还原 O(n)。"区间加"大量出现时,差分比线段树简洁得多。
记忆口诀:前缀和管"查得快",差分管"改得快";又要查又要改,才上树状数组/线段树。
用在哪儿
| 场景 | 用哪个 |
|---|---|
| 排行榜、前缀和 | 树状数组(短、快) |
| 区间最值、区间求和、带修改 | 线段树(通用) |
| 数据流统计、逆序对 | 树状数组 |
记住:树状数组/线段树 = 把"区间"也变成树上的节点,让"一次算一段"从 O(n) 变成 O(log n)。
十、动手时间 🎯
实验 1:搭一棵树,跑三种遍历
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val; self.left = left; self.right = right
root = TreeNode(1,
TreeNode(2, TreeNode(4), TreeNode(5)),
TreeNode(3, None, TreeNode(6))
)
def preorder(r):
if not r: return
print(r.val, end=" ")
preorder(r.left); preorder(r.right)
def inorder(r):
if not r: return
inorder(r.left)
print(r.val, end=" ")
inorder(r.right)
def postorder(r):
if not r: return
postorder(r.left); postorder(r.right)
print(r.val, end=" ")
print("前序:", end=" "); preorder(root); print()
print("中序:", end=" "); inorder(root); print()
print("后序:", end=" "); postorder(root); print()对照手工结果,看看三种顺序差在哪。
实验 2:从数组递归建树(了解"树是怎么来的")
def build(nums, i):
# 用数组下标 i 表示节点位置,2i+1 左孩子、2i+2 右孩子
if i >= len(nums) or nums[i] is None:
return None
node = TreeNode(nums[i])
node.left = build(nums, 2 * i + 1)
node.right = build(nums, 2 * i + 2)
return node
# [1,2,3,4,5] 表示:
# 1
# / \
# 2 3
# / \
# 4 5
r = build([1, 2, 3, 4, 5], 0)
inorder(r) # 4 2 5 1 3这个"下标 2i+1 / 2i+2"的规则,正是堆(后面主角)的存储方式,先记住它。
实验 3:统计叶子节点个数
def count_leaves(root):
if not root:
return 0
if not root.left and not root.right: # 没有孩子,就是叶子
return 1
return count_leaves(root.left) + count_leaves(root.right)
print(count_leaves(r)) # 3(节点 4、5、3)实验 4(挑战):判断一棵树是否"平衡"
平衡 = 任意节点左右子树高度差不超过 1。
def is_balanced(root):
def height(node):
if not node:
return 0
lh = height(node.left)
rh = height(node.right)
if lh == -1 or rh == -1 or abs(lh - rh) > 1:
return -1 # 用 -1 标记"已经不平衡"
return 1 + max(lh, rh)
return height(root) != -1
# 上面的树 r 是平衡的;自己搭一棵不平衡的试试体会:这个"递归返回时顺便带信息"的技巧(返回 -1 表示状态),在树的题里极常用。
十一、小结
- 树是"一个上级多个下级"的层级结构,二叉树每个节点最多两个孩子,节点用类表示(val + left + right)。
- 三种递归遍历记"根在哪":前序根在前、中序根在中(BST 里中序=有序)、后序根在后;层次遍历用队列一层层走。
- 树的题 = 递归处理"根 + 左右子树",边界是空节点——求高度、判相同、反转,全是这个套路。
先别急着往后翻,把三种遍历和"求高度/数叶子"敲熟,树的手感就长在你脑子里了。