树的存储:父亲表示法与孩子表示法
树的两种基础存储方式:父亲表示法只记每个点的父亲,适合自底向上递推;孩子表示法记每个点的孩子列表,适合自顶向下遍历。
一句话算法
树有
问题模型
给一棵有根树:
- 找到点
的父亲; - 枚举点
的所有孩子; - 遍历整棵树:自顶向下(从根往叶子)和自底向上(从叶子往根)。
目标是用
核心直觉
树上每个点只有一个父亲,却可能有多个孩子,所以信息流动只有两个方向:向上找父亲和向下找孩子。存树只需要在这两个方向里挑一个记下来:
- 父亲表示法:每个点向上看,只记"我的父亲是谁"。一个数组
fa[u]就够了。 - 孩子表示法:每个点向下看,记"我的孩子都有谁"。每个点配一个孩子列表
ch[u]。
为什么这么少的信息就够了?因为树只有
两种表示法各记住一个方向
父亲表示法里"向上"是 fa[u]。竞赛里最常用的是两者合一:孩子列表负责向下,顺手记的 fa[u] 负责向上。
如果每点最多两个孩子,就退化成二叉树,用 lch[u]、rch[u] 两个数组记录左右孩子即可。还有一种"左孩子右兄弟"表示法,用 child[u](第一个孩子)和 sib[u](下一个兄弟)两个数组存任意多叉树,它把孩子列表压成了链表,本文不展开。
算法步骤
一、父亲表示法
- 开数组
fa[1..n],令fa[root] = 0,表示根没有父亲。 - 对每个非根点
,记录 fa[v] = u,其中是 的父亲。 - 查询:找
的父亲直接读 fa[u],;从 向上跳就是不断走 u = fa[u],走到根为止。
二、孩子表示法
- 开列表数组
ch[1..n],初始都为空。 - 对每条父子边(
是父亲, 是孩子),执行 ch[u].push_back(v),同时顺手记fa[v] = u。 - 查询:枚举
的所有孩子就是扫描 ch[u],每个孩子恰好花。
三、从无向边输入建有根树
题目给的往往是
- 从
开始遍历,用 fa[]同时兼任"已访问"标记,令fa[root] = 0。 - 从
走到没访问过的邻居 时,说明 是 的孩子:记 fa[v] = u、ch[u].push_back(v)。 - 遍历结束时,两种表示法就都建好了。
算法证明
需要证明的是:两种表示法都完整记录了树的全部边,不多也不少;无向边建表算法给出的父亲关系是正确的。
父亲表示法的不变量:对每个非根点 fa[v] 恰好是树中
- 建表时每个非根点恰好写一次
fa[v],对应一条边。 - 一共有
个非根点,得到 条边,正好是树的边数。 - 这些边互不相同:每条边的"孩子端"就是下标
,不同的 对应不同的边。
所以父亲表示法记录的边与树的边集一一对应,可以完整恢复整棵树。
孩子表示法的不变量:ch[u] 中保存的恰好是
每条边 ch[u] 中出现一次,同样与树的边集一一对应;加上顺手记录的 fa[v] = u,两个方向的信息互相印证,不多也不少。
DFS 建表的正确性:树中任意两点之间有且仅有一条简单路径。从 fa[u]"的判断挡掉。因此每个点恰好入表一次,记下的父亲关系就是真实的父子关系。
复杂度分析
设点数为
- 建表时间复杂度:
,每个点、每条边恰好处理一次。 - 空间复杂度:
。父亲表示法只有一个数组;孩子表示法有 个列表,但所有列表一共只装 个孩子,加上顺手记的 fa[]共。
两种表示法的操作代价对比:
| 操作 | 父亲表示法 | 孩子表示法(顺手记 fa) |
|---|---|---|
| 找父亲 | ||
| 枚举全部孩子 | ||
| 找第 |
||
| 从 |
||
| 自顶向下遍历 | 不便,需先扫描出所有孩子 | |
| 自底向上递推 | 天然支持 | 需要按逆序遍历 |
| 空间 |
代码模板
父亲表示法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 树的存储:父亲表示法(parent / 双亲表示法)
// 每个点只记录自己的父亲 fa[u],根的父亲记为 0。
// 信息方向:向上走一步 O(1);向下找一个孩子要扫描全部点 O(n)。
// 适用:自底向上递推、按父亲跳祖先、只关心"向上"信息的问题。
// 如何使用:
// 1. ParentTree pt(n, root);
// 2. pt.set_fa(v, u); // u 是 v 的父亲,即 fa[v] = u
// 3. pt.for_each_ancestor(v, f); // 从 v 向上依次访问所有真祖先
// 4. pt.depth(v); // v 的深度,根的深度为 0
#include <vector>
struct ParentTree {
int n, root;
std::vector<int> fa; // fa[u] = u 的父亲;fa[root] = 0,表示根没有父亲
ParentTree(int n = 0, int root = 1) : n(n), root(root), fa(n + 1, 0) {}
// 把 v 的父亲设成 f;注意参数顺序和 fa[v] = f 一致
void set_fa(int v, int f) { fa[v] = f; }
// u 是不是根
bool is_root(int u) { return fa[u] == 0; }
// 从 v 向上依次访问所有真祖先(不含 v 自己),一路走到根
// 例:pt.for_each_ancestor(v, [](int u){ ... });
template<typename U>
void for_each_ancestor(int v, U func) {
for (int u = fa[v]; u != 0; u = fa[u]) func(u);
}
// v 的深度:从 v 走到根要经过多少条边(树中路径唯一,步数确定)
int depth(int v) {
int d = 0;
for (int u = fa[v]; u != 0; u = fa[u]) d++;
return d;
}
};
Python 版接口相同,封装成 ParentTree 类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 树的存储:父亲表示法(parent / 双亲表示法)
# 每个点只记录自己的父亲 fa[u],根的父亲记为 0。
# 信息方向:向上走一步 O(1);向下找一个孩子要扫描全部点 O(n)。
# 适用:自底向上递推、按父亲跳祖先、只关心"向上"信息的问题。
# 如何使用:
# 1. pt = ParentTree(n, root)
# 2. pt.set_fa(v, u) # u 是 v 的父亲,即 fa[v] = u
# 3. pt.for_each_ancestor(v, f) # 从 v 向上依次访问所有真祖先
# 4. pt.depth(v) # v 的深度,根的深度为 0
from collections.abc import Callable
class ParentTree:
"""fa[v] 是 v 的父亲;fa[root] = 0,根没有父亲。"""
n: int
root: int
fa: list[int]
def __init__(self, n: int, root: int = 1) -> None:
self.n = n
self.root = root
self.fa = [0] * (n + 1) # 下标 1..n
def set_fa(self, v: int, f: int) -> None:
"""把 v 的父亲设成 f;注意参数顺序和 fa[v] = f 一致。"""
self.fa[v] = f
def is_root(self, u: int) -> bool:
return self.fa[u] == 0
def for_each_ancestor(self, v: int, func: Callable[[int], None]) -> None:
"""从 v 向上依次访问所有真祖先(不含 v 自己),一路走到根。"""
u = self.fa[v]
while u != 0:
func(u)
u = self.fa[u]
def depth(self, v: int) -> int:
"""v 的深度:从 v 走到根要经过多少条边(树中路径唯一,步数确定)。"""
d = 0
u = self.fa[v]
while u != 0:
d += 1
u = self.fa[u]
return d
孩子表示法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 树的存储:孩子表示法
// 每个点记录自己的孩子列表 ch[u],顺手记下父亲 fa[u],向上向下就都 O(1) 了。
// 枚举 u 的全部孩子只要 O(孩子数);空间 O(n),所有列表一共只装 n-1 个孩子。
// 适用:树形 DP、DFS/BFS 遍历、子树统计等"向下走"的问题。
// 如何使用:
// 1. ChildTree ct(n, root);
// 2. ct.add_child(u, v); // u 是 v 的父亲
// 3. ct.for_each_child(u, f); // 遍历 u 的所有孩子
// 4. ct.fa[u]; // 向上找 u 的父亲
#include <vector>
struct ChildTree {
int n, root;
std::vector<std::vector<int>> ch; // ch[u]:u 的孩子,按加入顺序排列
std::vector<int> fa; // fa[u]:u 的父亲;fa[root] = 0
ChildTree(int n = 0, int root = 1)
: n(n), root(root), ch(n + 1), fa(n + 1, 0) {}
// u 是 v 的父亲:把 v 挂到 u 的孩子列表末尾
void add_child(int u, int v) {
ch[u].push_back(v);
fa[v] = u;
}
// 遍历 u 的所有孩子
// 例:ct.for_each_child(u, [](int v){ ... });
template<typename U>
void for_each_child(int u, U func) {
for (int v : ch[u]) func(v);
}
};
Python 版接口相同,封装成 ChildTree 类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 树的存储:孩子表示法
# 每个点记录自己的孩子列表 ch[u],顺手记下父亲 fa[u],向上向下就都 O(1) 了。
# 枚举 u 的全部孩子只要 O(孩子数);空间 O(n),所有列表一共只装 n-1 个孩子。
# 适用:树形 DP、DFS/BFS 遍历、子树统计等"向下走"的问题。
# 如何使用:
# 1. ct = ChildTree(n, root)
# 2. ct.add_child(u, v) # u 是 v 的父亲
# 3. ct.for_each_child(u, f) # 遍历 u 的所有孩子
# 4. ct.fa[u] # 向上找 u 的父亲
from collections.abc import Callable
class ChildTree:
"""ch[u] 是 u 的孩子列表(按加入顺序);fa[u] 是 u 的父亲,fa[root] = 0。"""
n: int
root: int
ch: list[list[int]]
fa: list[int]
def __init__(self, n: int, root: int = 1) -> None:
self.n = n
self.root = root
self.ch = [[] for _ in range(n + 1)] # 下标 1..n
self.fa = [0] * (n + 1)
def add_child(self, u: int, v: int) -> None:
"""u 是 v 的父亲:把 v 挂到 u 的孩子列表末尾。"""
self.ch[u].append(v)
self.fa[v] = u
def for_each_child(self, u: int, func: Callable[[int], None]) -> None:
"""遍历 u 的所有孩子。"""
for v in self.ch[u]:
func(v)
代码实现
完整的建树程序:读入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 完整实现:从无向边输入建一棵有根树,输出每个点的深度和子树大小
// 输入:
// 第一行 n root
// 接下来 n-1 行 u v(无向边)
// 输出:
// 第一行 depth[1..n],第二行 size[1..n]
//
// 两种表示法配合使用:
// 孩子表示法 ch[]:自顶向下求深度
// 父亲表示法 fa[]:按 DFS 逆序自底向上累加子树大小
#include <cstdio>
#include <vector>
using namespace std;
const int maxn = 1e5 + 5;
int n, root;
vector<int> g[maxn]; // 第一步:树是特殊的图,先用邻接表存 n-1 条无向边
vector<int> ch[maxn]; // 孩子表示法
int fa[maxn]; // 父亲表示法
int depth[maxn]; // 深度:到根的路径上的边数
int sz[maxn]; // 子树大小:以 u 为根的子树里的点数
// 从 root 出发 DFS(用栈模拟),确定每个点的父亲和孩子,同时得到遍历顺序
void build() {
vector<int> order; // DFS 顺序:父亲一定排在孩子前面
vector<int> stk = {root};
fa[root] = 0; // 根没有父亲
depth[root] = 0;
while (!stk.empty()) {
int u = stk.back();
stk.pop_back();
order.push_back(u);
for (int v : g[u]) {
if (v == fa[u]) continue; // 树上 u 只有父亲这一条"回头边"
fa[v] = u; // 第一次走到 v 的点就是 v 的父亲
ch[u].push_back(v);
depth[v] = depth[u] + 1;
stk.push_back(v);
}
}
// 自底向上(父亲表示法):逆序遍历,把子树大小累加到父亲上
for (int i = 1; i <= n; i++) sz[i] = 1; // 先算上自己
for (int i = n - 1; i >= 0; i--) {
int u = order[i];
if (u != root) sz[fa[u]] += sz[u];
}
}
int main() {
scanf("%d %d", &n, &root);
for (int i = 1; i < n; i++) {
int u, v;
scanf("%d %d", &u, &v);
g[u].push_back(v);
g[v].push_back(u);
}
build();
printf("depth:");
for (int i = 1; i <= n; i++) printf(" %d", depth[i]);
printf("\nsize:");
for (int i = 1; i <= n; i++) printf(" %d", sz[i]);
printf("\n");
return 0;
}
Python 版用显式栈模拟 DFS,避免递归深度过大。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 完整实现:从无向边输入建一棵有根树,输出每个点的深度和子树大小
# 输入:
# 第一行 n root
# 接下来 n-1 行 u v(无向边)
# 输出:
# 第一行 depth[1..n],第二行 size[1..n]
#
# 两种表示法配合使用:
# 孩子表示法 ch[]:自顶向下求深度
# 父亲表示法 fa[]:按 DFS 逆序自底向上累加子树大小
import sys
def main() -> None:
it = iter(sys.stdin.read().split())
n = int(next(it))
root = int(next(it))
# 第一步:树是特殊的图,先用邻接表存 n-1 条无向边
g: list[list[int]] = [[] for _ in range(n + 1)]
for _ in range(n - 1):
u = int(next(it))
v = int(next(it))
g[u].append(v)
g[v].append(u)
ch: list[list[int]] = [[] for _ in range(n + 1)] # 孩子表示法
fa: list[int] = [0] * (n + 1) # 父亲表示法
depth: list[int] = [0] * (n + 1) # 深度:到根的路径上的边数
sz: list[int] = [1] * (n + 1) # 子树大小,先算上自己
# 从 root 出发 DFS(用栈模拟),确定每个点的父亲和孩子,同时得到遍历顺序
order: list[int] = [] # DFS 顺序:父亲一定排在孩子前面
stk = [root]
fa[root] = 0 # 根没有父亲
while stk:
u = stk.pop()
order.append(u)
for v in g[u]:
if v == fa[u]: # 树上 u 只有父亲这一条"回头边"
continue
fa[v] = u # 第一次走到 v 的点就是 v 的父亲
ch[u].append(v)
depth[v] = depth[u] + 1
stk.append(v)
# 自底向上(父亲表示法):逆序遍历,把子树大小累加到父亲上
for u in reversed(order):
if u != root:
sz[fa[u]] += sz[u]
print("depth:", *(depth[i] for i in range(1, n + 1)))
print("size:", *(sz[i] for i in range(1, n + 1)))
if __name__ == "__main__":
main()
测试用例
输入(6 个点,根为 1):
6 1
1 2
1 3
2 4
2 5
3 6
树的结构:1 的孩子是 2、3;2 的孩子是 4、5;3 的孩子是 6。
手工走一遍算法:
- 自顶向下求深度:
depth[1] = 0,depth[2] = depth[3] = 1,depth[4] = depth[5] = depth[6] = 2。 - 自底向上求子树大小:叶子
sz[4] = sz[5] = sz[6] = 1,sz[2] = 1 + 1 + 1 = 3,sz[3] = 1 + 1 = 2,sz[1] = 1 + 3 + 2 = 6。
输出:
depth: 0 1 1 2 2 2
size: 6 3 2 1 1 1
边界用例:1 1,输出 depth: 0 与 size: 1,此时 fa[] 全是 0、孩子列表全为空,不需要任何特判。
应用分类详解
父亲表示法和孩子表示法没有优劣之分,只有信息方向之分:向上看的问题选父亲表示法,向下看的问题选孩子表示法。
一、自底向上递推(父亲表示法)
典型模式: 每个点的状态只由它自己和父亲决定,答案从叶子一路合并到根。
识别信号: 题面直接给出"每个点的父亲",或者状态转移沿"叶子 fa[v] 直接做转移,或者按深度从大到小枚举点,保证处理
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 按父亲跳祖先 | luogu-P3379 | fa[] 是倍增表 up[u][0],跳祖先是一切树上查询的起点 |
| 向上贪心覆盖 | 贪心选叶子后跳到祖父 | 只需要 fa[] 链,孩子列表完全用不上 |
| 并查集式合并 | 按父亲链维护集合 | 父亲数组就是最朴素的"向上跳"结构 |
二、自顶向下遍历与树形 DP(孩子表示法)
典型模式: 每个点的答案由它和所有孩子的信息合并而成,需要先递归处理完子树。
识别信号: “子树内”“以 ch[u] 枚举孩子做转移,DFS 先算完子树再合并到父亲。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 树形 DP | luogu-P1352 | 枚举每个点的孩子,合并"选/不选"两种状态 |
| 子树统计 | 子树大小、子树权值和 | sz[u] = 1 + sum sz[v],ch[u] |
| 树上背包 | luogu-P2015 | 在 ch[u] 上做分组背包,孩子就是物品组 |
三、无向边输入建有根树(邻接表 + DFS 定父子)
典型模式: 输入是 fa[] 和 ch[];两种表示法常常配合使用。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 换根 DP | luogu-P1364 | 向下靠 ch[],向上靠 fa[],两个方向都要 |
| 求深度、子树 | 本文的完整实现 | DFS 定父子后自顶向下、自底向上各扫一遍 |
经典例题
1. luogu-P1352 没有上司的舞会
每个点选或不选,且选了 f[u][0/1] 表示 ch[u] 把孩子的贡献合并上来。孩子表示法让"枚举所有孩子"这一步是
2. luogu-P2015 二叉苹果树
在树上留 ch[u] 做分组背包即可,这里每个点最多两个孩子,也顺便展示了"孩子列表退化成左右儿子"的情形。
3. luogu-P1364 医院设置
求选哪个点使所有点的带权距离和最小,标准做法是换根 DP:第一遍自顶向下求出以 1 为根的答案,第二遍把答案从父亲"传"给每个孩子。向下传要枚举 ch[u],向上传要知道 fa[u],两种表示法(或合一的写法)都用得上。
4. luogu-P3379 【模板】最近公共祖先
倍增求 LCA 的第一步就是记录 fa[u] = up[u][0],之后所有操作都是在父亲链上跳。它展示了父亲表示法的核心价值:只要能向上跳,就能在