树的存储:父亲表示法与孩子表示法

树的两种基础存储方式:父亲表示法只记每个点的父亲,适合自底向上递推;孩子表示法记每个点的孩子列表,适合自顶向下遍历。

一句话算法

树有 nn 个点、n−1n-1 条边,每个点只要记下"父亲是谁"或"孩子是谁"两件事之一,这 n−1n-1 条边就一条不漏地全存住了。

问题模型

给一棵有根树:nn 个点(编号 1∼n1 \sim n)、一个根 rootroot、n−1n-1 条边。存储结构需要支持:

  • 找到点 uu 的父亲;
  • 枚举点 uu 的所有孩子;
  • 遍历整棵树:自顶向下(从根往叶子)和自底向上(从叶子往根)。

目标是用 O(n)O(n) 空间存下整棵树,并且让上面这些操作足够快。

核心直觉

树上每个点只有一个父亲,却可能有多个孩子,所以信息流动只有两个方向:向上找父亲和向下找孩子。存树只需要在这两个方向里挑一个记下来:

  • 父亲表示法:每个点向上看,只记"我的父亲是谁"。一个数组 fa[u] 就够了。
  • 孩子表示法:每个点向下看,记"我的孩子都有谁"。每个点配一个孩子列表 ch[u]。

为什么这么少的信息就够了?因为树只有 n−1n-1 条边,而且每条边连接的恰好是一个父亲和一个孩子。把每个非根点的父亲记一遍(或者把每个点的孩子记一遍),n−1n-1 条边就全在了,而树中任意两点间的路径唯一,边存住了整棵树的结构就存住了。

两种表示法各记住一个方向

父亲表示法里"向上"是 O(1)O(1) 的,"向下"却要扫描全部点才能找到孩子;孩子表示法反过来,"向下"是 O(1)O(1) 的,"向上"要额外记一个 fa[u]。竞赛里最常用的是两者合一:孩子列表负责向下,顺手记的 fa[u] 负责向上。

如果每点最多两个孩子,就退化成二叉树,用 lch[u]、rch[u] 两个数组记录左右孩子即可。还有一种"左孩子右兄弟"表示法,用 child[u](第一个孩子)和 sib[u](下一个兄弟)两个数组存任意多叉树,它把孩子列表压成了链表,本文不展开。

算法步骤

一、父亲表示法

  1. 开数组 fa[1..n],令 fa[root] = 0,表示根没有父亲。
  2. 对每个非根点 vv,记录 fa[v] = u,其中 uu 是 vv 的父亲。
  3. 查询:找 uu 的父亲直接读 fa[u],O(1)O(1);从 vv 向上跳就是不断走 u = fa[u],走到根为止。

二、孩子表示法

  1. 开列表数组 ch[1..n],初始都为空。
  2. 对每条父子边(uu 是父亲,vv 是孩子),执行 ch[u].push_back(v),同时顺手记 fa[v] = u。
  3. 查询:枚举 uu 的所有孩子就是扫描 ch[u],每个孩子恰好花 O(1)O(1)。

三、从无向边输入建有根树

题目给的往往是 n−1n-1 条无向边(树是特殊的图,可以直接用图的存储里的邻接表先存下)。再从 rootroot 出发做一次 DFS 或 BFS 把方向定下来:

  1. 从 rootroot 开始遍历,用 fa[] 同时兼任"已访问"标记,令 fa[root] = 0。
  2. 从 uu 走到没访问过的邻居 vv 时,说明 vv 是 uu 的孩子:记 fa[v] = u、ch[u].push_back(v)。
  3. 遍历结束时,两种表示法就都建好了。

算法证明

需要证明的是:两种表示法都完整记录了树的全部边,不多也不少;无向边建表算法给出的父亲关系是正确的。

父亲表示法的不变量:对每个非根点 vv,fa[v] 恰好是树中 vv 到 rootroot 路径上的第二个点(即 vv 的父亲)。

  1. 建表时每个非根点恰好写一次 fa[v],对应一条边 (v,fa[v])(v, fa[v])。
  2. 一共有 n−1n-1 个非根点,得到 n−1n-1 条边,正好是树的边数。
  3. 这些边互不相同:每条边的"孩子端"就是下标 vv,不同的 vv 对应不同的边。

所以父亲表示法记录的边与树的边集一一对应,可以完整恢复整棵树。

孩子表示法的不变量:ch[u] 中保存的恰好是 uu 的所有孩子,每个孩子出现一次。

每条边 (u,v)(u, v)(uu 是父亲)恰好在 ch[u] 中出现一次,同样与树的边集一一对应;加上顺手记录的 fa[v] = u,两个方向的信息互相印证,不多也不少。

DFS 建表的正确性:树中任意两点之间有且仅有一条简单路径。从 rootroot 出发遍历时,点 vv 第一次被走到的那条边,一定来自它到 rootroot 路径上的相邻点,也就是它的父亲;而之后从 vv 的孩子绕回来的"回头边"会被"是否等于 fa[u]"的判断挡掉。因此每个点恰好入表一次,记下的父亲关系就是真实的父子关系。

复杂度分析

设点数为 nn,边数为 n−1n-1。

  • 建表时间复杂度:O(n)O(n),每个点、每条边恰好处理一次。
  • 空间复杂度:O(n)O(n)。父亲表示法只有一个数组;孩子表示法有 nn 个列表,但所有列表一共只装 n−1n-1 个孩子,加上顺手记的 fa[] 共 O(n)O(n)。

两种表示法的操作代价对比:

操作 父亲表示法 孩子表示法(顺手记 fa)
找父亲 O(1)O(1) O(1)O(1)
枚举全部孩子 O(n)O(n) O(孩子数)O(\text{孩子数})
找第 kk 个孩子 O(n)O(n) O(k)O(k)
从 vv 向上跳到根 O(深度)O(\text{深度}) O(深度)O(\text{深度})
自顶向下遍历 不便,需先扫描出所有孩子 O(n)O(n)
自底向上递推 天然支持 需要按逆序遍历
空间 O(n)O(n) O(n)O(n)

代码模板

父亲表示法

C++
cpp
        
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

Python 版接口相同,封装成 ParentTree 类。

python
        
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

孩子表示法

C++
cpp
        
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

Python 版接口相同,封装成 ChildTree 类。

python
        
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)

代码实现

完整的建树程序:读入 n−1n-1 条无向边,从 root 出发定父子,然后自顶向下用孩子表示法求每个点的深度,自底向上用父亲表示法累加子树大小。

C++
cpp
        
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

Python 版用显式栈模拟 DFS,避免递归深度过大。

python
        
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。

手工走一遍算法:

  1. 自顶向下求深度:depth[1] = 0,depth[2] = depth[3] = 1,depth[4] = depth[5] = depth[6] = 2。
  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

边界用例:n=1n = 1 时没有边,输入 1 1,输出 depth: 0 与 size: 1,此时 fa[] 全是 0、孩子列表全为空,不需要任何特判。

应用分类详解

父亲表示法和孩子表示法没有优劣之分,只有信息方向之分:向上看的问题选父亲表示法,向下看的问题选孩子表示法。

一、自底向上递推(父亲表示法)

典型模式: 每个点的状态只由它自己和父亲决定,答案从叶子一路合并到根。 识别信号: 题面直接给出"每个点的父亲",或者状态转移沿"叶子 →\to 根"方向进行。 核心建模: 用 fa[v] 直接做转移,或者按深度从大到小枚举点,保证处理 vv 时它的子树已经算完。

应用场景 经典题目 核心思路
按父亲跳祖先 luogu-P3379 fa[] 是倍增表 up[u][0],跳祖先是一切树上查询的起点
向上贪心覆盖 贪心选叶子后跳到祖父 只需要 fa[] 链,孩子列表完全用不上
并查集式合并 按父亲链维护集合 父亲数组就是最朴素的"向上跳"结构

二、自顶向下遍历与树形 DP(孩子表示法)

典型模式: 每个点的答案由它和所有孩子的信息合并而成,需要先递归处理完子树。 识别信号: “子树内”“以 uu 为根的”"每个点选或不选"这类按子树划分的问题。 核心建模: ch[u] 枚举孩子做转移,DFS 先算完子树再合并到父亲。

应用场景 经典题目 核心思路
树形 DP luogu-P1352 枚举每个点的孩子,合并"选/不选"两种状态
子树统计 子树大小、子树权值和 sz[u] = 1 + sum sz[v],vv 遍历 ch[u]
树上背包 luogu-P2015 在 ch[u] 上做分组背包,孩子就是物品组

三、无向边输入建有根树(邻接表 + DFS 定父子)

典型模式: 输入是 n−1n-1 条无向边,但题目里的树是有根树(或要自己选根)。 识别信号: "给定一棵树"然后列 n−1n-1 行两个整数,却不告诉你谁是谁的父亲。 核心建模: 先用邻接表存无向边,再从根出发一次 DFS 同时得到 fa[] 和 ch[];两种表示法常常配合使用。

应用场景 经典题目 核心思路
换根 DP luogu-P1364 向下靠 ch[],向上靠 fa[],两个方向都要
求深度、子树 本文的完整实现 DFS 定父子后自顶向下、自底向上各扫一遍

经典例题

1. luogu-P1352 没有上司的舞会

每个点选或不选,且选了 vv 就不能选它的父亲。这是最标准的树形 DP:f[u][0/1] 表示 uu 不选/选时子树内的最大快乐指数,转移时枚举 ch[u] 把孩子的贡献合并上来。孩子表示法让"枚举所有孩子"这一步是 O(孩子数)O(\text{孩子数}) 的,整题复杂度 O(n)O(n)。

2. luogu-P2015 二叉苹果树

在树上留 qq 条边使根到叶子的苹果数最大,是树上背包:每个点的孩子就是一组物品,容量是留下的边数。用孩子表示法枚举 ch[u] 做分组背包即可,这里每个点最多两个孩子,也顺便展示了"孩子列表退化成左右儿子"的情形。

3. luogu-P1364 医院设置

求选哪个点使所有点的带权距离和最小,标准做法是换根 DP:第一遍自顶向下求出以 1 为根的答案,第二遍把答案从父亲"传"给每个孩子。向下传要枚举 ch[u],向上传要知道 fa[u],两种表示法(或合一的写法)都用得上。

4. luogu-P3379 【模板】最近公共祖先

倍增求 LCA 的第一步就是记录 fa[u] = up[u][0],之后所有操作都是在父亲链上跳。它展示了父亲表示法的核心价值:只要能向上跳,就能在 O(log⁡n)O(\log n) 时间内回答祖先查询。

参考