二叉树专题:从遍历、重建到应用
二叉树专题:从遍历、重建到应用
覆盖洛谷 8 道经典二叉树题目,从遍历互求到树上DP,一篇文章讲透。
题单:P1827 / P1030 / P1229 / P1305 / P5076 / P1364 / P3884 / P1185
一、基础知识速览
1.1 三种遍历的定义
以下面这棵树为例:
A
/ \
B C
/ \ \
D E F
| 遍历方式 | 访问顺序 | 该树输出 |
|---|---|---|
| 前序(先根) | 根 → 左子树 → 右子树 | A B D E C F |
| 中序(中根) | 左子树 → 根 → 右子树 | D B E A C F |
| 后序(后根) | 左子树 → 右子树 → 根 | D E B F C A |
1.2 递归模板
三种遍历的递归实现,只有 print(root) 的位置不同:
def preorder(u):
if not u: return
print(u.val) # 根
preorder(u.left) # 左
preorder(u.right) # 右
def inorder(u):
if not u: return
inorder(u.left)
print(u.val) # 根在中间
inorder(u.right)
def postorder(u):
if not u: return
postorder(u.left)
postorder(u.right)
print(u.val) # 根在最后
1.3 核心性质
给定中序 + 另一种遍历 → 唯一确定二叉树
给定前序 + 后序 → 不能唯一确定(后文展开)
原因:中序能区分左右子树,而前序/后序只能确定根。
二、遍历互求(三道核心题)
2.1 P1827 前序 + 中序 → 后序
题目:给定前序遍历和中序遍历,输出后序遍历。节点最多 26 个。
样例输入:
ABEDFCHG
CBADEFGH
样例输出:
AEFDBHGC
对应二叉树:
C
/ \
B G
/ \ /
A D H
/ \
E F
思路:
前序首字符 = 根
中序中根的位置 = 左右子树的分界点
前序: C B A D E F G H
中序: A B E D F C H G
root='C', 在中序的 idx=5
左子树: in="ABEDF"(5个), pre="BADEF"(根后5个)
右子树: in="HG"(2个), pre="GH"(剩余2个)
↓ 递归
后序 = 左后序 + 右后序 + 'C'
def build(in_o, pre_o):
if not pre_o:
return ''
root = pre_o[0]
idx = in_o.index(root)
left_in = in_o[:idx]
right_in = in_o[idx + 1:]
left_pre = pre_o[1 : 1 + idx]
right_pre = pre_o[1 + idx:]
return (build(left_in, left_pre) +
build(right_in, right_pre) +
root)
# 输入
in_o = input().strip() # ⚠️ strip() 不能省,样例第二行末尾有空格
pre_o = input().strip()
print(build(in_o, pre_o))
易错点:
input().strip()— 洛谷部分测试数据行末带空格- 切片
pre[1 : 1+idx]取左子树,正好 idx 个节点
2.2 P1030 中序 + 后序 → 先序
题目:给定中序和后序遍历,输出前序遍历。(NOIP 2001 普及组)
样例输入:
BADC
BDCA
样例输出:
ABCD
与 P1827 的区别:后序的最后一个字符是根,其余完全对称。
中序: B A D C
后序: B D C A
root = post[-1] = 'A', 在中序的 idx=1
左子树: in="B", post="B"
右子树: in="DC", post="DC"
↓ 递归
先序 = 'A' + 左先序 + 右先序 ← 注意拼接顺序变了
def build(in_o, post_o):
if not post_o:
return ''
root = post_o[-1] # 后序最后一个 = 根
idx = in_o.index(root)
left_in = in_o[:idx]
right_in = in_o[idx + 1:]
left_post = post_o[:idx] # 后序前 idx 个
right_post = post_o[idx:-1] # 中间部分(不含根)
return root + build(left_in, left_post) + build(right_in, right_post)
# ↑ 先序:根在前
对照表:
| P1827(前+中→后) | P1030(中+后→先) | |
|---|---|---|
| 根的位置 | pre[0] |
post[-1] |
| 拼接顺序 | 左后序 + 右后序 + 根 | 根 + 左先序 + 右先序 |
| 核心思路 | 完全一致 | 完全一致 |
2.3 P1229 前序 + 后序 → 中序计数
题目:给定前序和后序遍历,求可能的中序遍历总数。
样例输入:
abc
cba
样例输出:
4
关键结论:前序+后序无法唯一确定二叉树的原因是——当某个节点只有一个子节点时,无法区分它是左孩子还是右孩子。
A A
/ vs \
B B
前序都是: A B
后序都是: B A
但中序可以是: B A 或 A B
算法:统计有多少个节点只有一个子节点(即该节点的前序中,子节点紧跟在它后面,且在后序中子节点紧贴在它前面),记为 cnt,答案 = $2^{cnt}$。
pre = "abc"
post = "cba"
① 整段 pre="abc", post="cba"
根='a', 左子根=pre[1]='b', 右子根=post[-2]='b'
'b'=='b' → 只有一个孩子,cnt++
② 子段 pre="bc", post="cb"
根='b', 左子根=pre[1]='c', 右子根=post[-2]='c'
'c'=='c' → 只有一个孩子,cnt++
cnt=2, 答案=2²=4
def count(pre, post):
if not pre:
return 0
if len(pre) == 1:
return 0
# pre[1] = 左子树的根(如果有左孩子)
# post[-2] = 右子树的根(如果有右孩子)
cnt = 0
if pre[1] == post[-2]: # 只有一个孩子
cnt = 1
root = pre[0]
# 找到左子树的根在 post 中的位置来切分
left_root = pre[1]
idx = post.index(left_root)
left_pre = pre[1 : 2 + idx]
left_post = post[:idx + 1]
right_pre = pre[2 + idx:]
right_post = post[idx + 1 : -1]
return cnt + count(left_pre, left_post) + count(right_pre, right_post)
pre = input().strip()
post = input().strip()
print(2 ** count(pre, post))
总结:
| 已知条件 | 结果 | 原因 |
|---|---|---|
| 前序 + 中序 | 唯一二叉树 | 中序区分左右 |
| 后序 + 中序 | 唯一二叉树 | 同上 |
| 前序 + 后序 | 不唯一 | 单子节点方向不确定 |
| 层序 + 中序 | 唯一二叉树 | 层序首个=根,中序定左右 |
三、二叉树的构建与遍历(P1305)
题目:给定每个节点的左右孩子(* 表示空),输出前序遍历。第一行读入的为根。
样例输入:
6
abc
bdi
cj*
d**
i**
j**
样例输出:
abdicj
对应二叉树:
a
/ \
b c
/ \ /
d i j
思路:用字典存每个节点的左右孩子,然后从根开始递归前序遍历。
n = int(input())
tree = {}
root = None
for _ in range(n):
line = input().split()
if len(line) == 1:
# 可能是 "abc" 三个字符挤在一起
p, l, r = line[0][0], line[0][1], line[0][2]
else:
p, l, r = line
if root is None:
root = p
tree[p] = (l if l != '*' else None, r if r != '*' else None)
def preorder(u):
if u is None:
return ''
l, r = tree.get(u, (None, None))
return u + preorder(l) + preorder(r)
print(preorder(root))
注意:输入格式不统一,有的数据是空格分隔,有的三个字符连在一起,需要容错处理。
四、二叉搜索树操作(P5076)
题目:维护一个集合,支持 5 种操作(查询排名、按排名查数、前驱、后继、插入)。q ≤ 10⁴。
样例输入:
7
5 1
5 3
5 5
1 3
2 2
3 3
4 3
样例输出:
2 ← 操作1: 3的排名, 集合={1,3,5}, 小于3的有1个, 排名=2
3 ← 操作2: 排名为2的数=3
1 ← 操作3: 3的前驱=1
5 ← 操作4: 3的后继=5
操作过程: 插入1 → 插入3 → 插入5 → 查3的排名 → 查第2小 → 3的前驱 → 3的后继
思路一:手写 BST
class Node:
__slots__ = ('val', 'left', 'right', 'size', 'cnt')
def __init__(self, val):
self.val = val
self.left = None
self.right = None
self.size = 1 # 子树节点数
self.cnt = 1 # 该值出现次数
def insert(root, x):
if root is None:
return Node(x)
if x < root.val:
root.left = insert(root.left, x)
elif x > root.val:
root.right = insert(root.right, x)
else:
root.cnt += 1
root.size = (root.left.size if root.left else 0) + \
root.cnt + \
(root.right.size if root.right else 0)
return root
def rank(root, x):
"""查询 x 的排名(小于 x 的数的个数 + 1)"""
if root is None:
return 1
if x <= root.val:
return rank(root.left, x)
else:
left_size = root.left.size if root.left else 0
return left_size + root.cnt + rank(root.right, x)
def kth(root, k):
"""查询排名为 k 的数"""
left_size = root.left.size if root.left else 0
if k <= left_size:
return kth(root.left, k)
elif k <= left_size + root.cnt:
return root.val
else:
return kth(root.right, k - left_size - root.cnt)
def predecessor(root, x):
"""前驱:小于 x 的最大值"""
if root is None:
return -2147483647
if root.val >= x:
return predecessor(root.left, x)
else:
# 当前根小于 x,答案可能是根,也可能在右子树
return max(root.val, predecessor(root.right, x))
def successor(root, x):
"""后继:大于 x 的最小值"""
if root is None:
return 2147483647
if root.val <= x:
return successor(root.right, x)
else:
return min(root.val, successor(root.left, x))
思路二:Python bisect 硬搞(比赛偷懒版)
由于 q ≤ 10⁴,O(q²) 能过(插入时 O(n) 挪动),直接维护有序列表即可:
import bisect
a = []
for _ in range(q):
op, x = map(int, input().split())
if op == 1: # 查排名
print(bisect.bisect_left(a, x) + 1)
elif op == 2: # 按排名查
print(a[x - 1])
elif op == 3: # 前驱
idx = bisect.bisect_left(a, x) - 1
print(a[idx] if idx >= 0 else -2147483647)
elif op == 4: # 后继
idx = bisect.bisect_right(a, x)
print(a[idx] if idx < len(a) else 2147483647)
elif op == 5: # 插入
bisect.insort(a, x)
推荐:比赛用
bisect,手写 BST 是理解「在递归中利用子树大小做决策」的最佳练习。
BST 决策逻辑总结:
[根5, size=6]
/ \
[左子树] [右子树]
size=3 size=2
值<5 值>5
查排名(7):7>5 → 跳过左子树(3个)+根(1个) → 在右子树查 rank=rank-4
查第k大:k<=左size → 去左;k<=左size+1 → 答案=根;否则去右
五、树的性质与DP
5.1 P1364 医院设置(树的重心)
题目:二叉树每个节点有居民人数 w,选一个节点建医院,使所有人到医院的距离之和最小。相邻节点距离=1。
样例输入:
5
13 2 3
4 0 0
12 4 5
20 0 0
40 0 0
样例输出:
81
树结构: 1(w=13)
/ \
2(w=4) 3(w=12)
/ \
4(w=20) 5(w=40)
医院建在3: 4×2 + 13 + 20 + 40 = 81 (最优)
医院建在1: 4 + 12 + 2×20 + 2×40 = 136
思路一:暴力枚举(n ≤ 100,O(n²) 稳过)
以每个节点为医院候选,BFS/DFS 计算总距离:
from collections import deque
n = int(input())
w = [0] * (n + 1)
g = [[] for _ in range(n + 1)]
for i in range(1, n + 1):
weight, l, r = map(int, input().split())
w[i] = weight
if l:
g[i].append(l)
g[l].append(i)
if r:
g[i].append(r)
g[r].append(i)
ans = float('inf')
for i in range(1, n + 1):
# BFS 计算以 i 为医院的总距离
dist = [-1] * (n + 1)
dist[i] = 0
q = deque([i])
total = 0
while q:
u = q.popleft()
total += w[u] * dist[u]
for v in g[u]:
if dist[v] == -1:
dist[v] = dist[u] + 1
q.append(v)
ans = min(ans, total)
print(ans)
思路二:树形 DP(换根法,O(n))
第一次 DFS:以 1 为根,计算子树总人数 sz[u] 和以 1 为医院的总距离 dp[1]。
第二次 DFS:换根,dp[v] = dp[u] - sz[v] + (total_sum - sz[v])。
# 篇幅原因只给出核心转移方程
# dp[v] = dp[u] + total_w - 2 * sz[v]
直观理解:把医院从 u 移到邻居 v 时,
- v 子树内的居民少走 1 步(减 sz[v])
- v 子树外的居民多走 1 步(加 total_w - sz[v])
5.2 P3884 二叉树问题(深度、宽度、距离)
题目:给定以 1 为根的二叉树,求:
- 深度:最大层数
- 宽度:同一层最多节点数
- 距离:dist(u, v) = 2×(到 LCA 的向上边数) + (LCA 到另一节点的向下边数)
样例输入:
10
1 2
1 3
2 4
2 5
3 6
3 7
5 8
5 9
6 10
8 6
样例输出:
4 ← 深度
4 ← 宽度(第2层和第3层各有4个节点)
8 ← dist(8,6)
对应二叉树:
1
/ \
2 3
/\ /\
4 5 6 7
/\ |
8 9 10
距离定义的直观理解:
1 ← LCA
/ \
2 3
| |
5 6 dist(8, 6) = ?
|
8
路径 8→5→2→1→3→6
←向上→ ←向下→
3条 2条
距离 = 2×3 + 2 = 8
实现:
n = int(input())
g = [[] for _ in range(n + 1)]
parent = [0] * (n + 1)
depth = [0] * (n + 1)
width = [0] * (n + 1) # width[d] = 第 d 层的节点数
for _ in range(n - 1):
u, v = map(int, input().split())
g[u].append(v)
parent[v] = u
x, y = map(int, input().split())
# 如果最后一行是空格分隔的 (题目用例是两行)
# 需要容错处理
# DFS 统计深度和宽度
max_depth = 0
def dfs(u, d):
global max_depth
max_depth = max(max_depth, d)
depth[u] = d
width[d] += 1
for v in g[u]:
dfs(v, d + 1)
dfs(1, 0)
# 求 LCA:向上标记法(n≤100 够用)
def lca(a, b):
visited = set()
while a:
visited.add(a)
a = parent[a]
while b:
if b in visited:
return b
b = parent[b]
return 1
anc = lca(x, y)
up = depth[x] - depth[anc] # 向上的边
down = depth[y] - depth[anc] # 向下的边
dist = 2 * up + down
print(max_depth + 1) # 深度 = 最大层号+1(取决于根算第几层)
print(max(width))
print(dist)
输入最后一行是两个整数 x, y,注意读入方式。
六、模拟与绘制(P1185)
题目:绘制一棵去掉若干节点的满二叉树。m 层,删除 n 个节点。
# 样例1: m=2, n=0(不删除任何节点)
输入:
2 0
输出:
o
/ \
o o
# 样例2: m=4, n=0(完整4层满二叉树)
输入:
4 0
输出:
o
/ \
/ \
/ \
/ \
/ \
o o
/ \ / \
/ \ / \
o o o o
/ \ / \ / \ / \
o o o o o o o o
# 样例3: m=4, 删除(3,2) (4,1) (3,4)
输入:
4 3
3 2
4 1
3 4
输出:
o
/ \
/ \
/ \
/ \
/ \
o o
/ /
/ /
o o
\ / \
o o o
核心难点:坐标计算和字符摆放。
6.1 满二叉树的绘制规律
m=3 的满二叉树:
o ← 第1层, 根在列中心
/ \
o o ← 第2层
/ \ / \
o o o o ← 第3层(叶子),同父间3空格,异父间1空格
坐标推算:
对于 m 层的满二叉树:
- 叶子层(第 m 层)节点间距固定:同父 3 空格 + 节点
o,异父 1 空格 - 每个叶子节点占据宽度 = 1(字符 'o')+ 右侧空格
- 从叶子层逐层向上推父节点的列坐标:
col[parent] = (col[left] + col[right]) // 2
6.2 实现思路
# 第1步:确定叶子层每个节点的列坐标
# 第2步:从下往上推父节点坐标
# 第3步:在字符矩阵中放置 'o' 和 '/' '\'
# 第4步:根据删除列表挖掉节点及子树
m, n = map(int, input().split())
deleted = set()
for _ in range(n):
layer, pos = map(int, input().split())
# 标记该节点及其子树为删除
...
这道题考察的是坐标映射 + 模拟,而非传统数据结构。建议先手算小样例理解坐标关系,再编码。
七、题目能力矩阵
| 题号 | 核心考点 | 难度 | 关键技巧 |
|---|---|---|---|
| P1305 | 树的存储与遍历 | ★☆☆ | 邻接表 + 递归 |
| P1827 | 遍历互求 | ★★☆ | 分治 + 切片 |
| P1030 | 遍历互求(变体) | ★★☆ | 与 P1827 对称 |
| P1229 | 遍历唯一性 | ★★★ | 单子节点判定 + 计数 |
| P5076 | BST 操作 | ★★★ | size 维护 + 递归决策 |
| P1364 | 树的重心 | ★★★ | 换根DP / 暴力BFS |
| P3884 | 树性质 + LCA | ★★★ | 深度/宽度/路径分解 |
| P1185 | 绘制模拟 | ★★★★ | 坐标映射 + 格式化 |
八、总结
遍历:前中后,根的位置决定一切
重建:中序定左右,另一遍历定根
计数:单子节点是歧义的来源(2^cnt)
操作:BST 维护 size,递归做决策
DP: 换根转移 = 总人数 - 2×子树人数
距离:LCA 分解 = 2×向上 + 向下
绘制:叶子定位 → 倒推父坐标 → 排版
整理时间:2026-05-18 · 题源:洛谷
浙公网安备 33010602011771号