NOI C++ 课程体系 / 数据结构篇 / 第4讲
CSP-J 入门级 · 树形结构 · m5-04

树的定义、表示与存储
从家谱到程序里的层级结构

从一本家谱出发,理解"为什么文件夹、公司架构、语法树都是树";掌握根、叶子、度、深度等全部术语,熟记树的四条性质,并能够用双亲表示法、孩子表示法、孩子兄弟表示法把一棵普通树存进程序。

6 大模块 3 种存储结构 4 条重要性质 5 大易错点 12+20 道练习
本讲 · 第 44/56 讲树的定义、表示与存储
模块 01

情景导航

从一本家谱开始,理解为什么"一层套一层"的结构在程序里无处不在。

故事一本家谱

老王家里有一本厚重的族谱。翻开第一页,是这一支的始迁祖;他有三个儿子,三个儿子又各自开枝散叶……

这本族谱有几个很特别的地方:

① 只有一个起点 —— 往下追,所有人都能追到那位始迁祖;

② 每个人只有一个父亲 —— 不会有两个父亲指向同一个人;

③ 不会绕回来 —— 你不可能既是自己爷爷的爷爷,又是他的后代。

一个起点、人人一个父亲、绝不成环 —— 这就是"树"!🌳

把"人"换成"结点",把"父子关系"换成"边",族谱就变成了程序里的树(Tree)。最上面那个唯一的起点叫根,最下面那些没有后代的人叫叶子,中间的每一层叫层次。

💡 和前面学的结构对比

数组、链表、栈、队列都是一对一的线性结构 —— 每个结点最多只有一个前驱、一个后继。

树是一对多的非线性结构:一个双亲可以有多个孩子。这让它天然适合表达层级、分类、嵌套这类关系。

①生活类比:树无处不在
🏢

公司组织架构

总经理下面是若干部门经理,每个经理再带几个组员,职责层层分解。

→ 一对多的层级
📁

电脑里的文件夹

C 盘里装文件夹,文件夹里还能装文件夹与文件,最外层只有一个根目录。

→ 根 + 嵌套
📖

一本书的目录

书 → 章 → 节 → 小节,每往下一层就分得更细,绝不会跨章交叉。

→ 层次与深度
🧬

生物分类

界、门、纲、目、科、属、种,一层层往下细分,构成一棵巨大的分类树。

→ 树形分类
②编程定义

树(Tree)是 n(n ≥ 0)个结点的有限集合。n = 0 时称为空树;n > 0 时:

① 有且仅有一个特定的结点称为根(Root);
② 其余结点可分为 m(m ≥ 0)个互不相交的有限集合,每个集合本身又是一棵树,称为根的子树(Subtree)。

注意这个定义是递归的 —— "树由子树构成,子树还是树"。理解了这一点,后面所有递归遍历的写法都会水到渠成。子树之间必须互不相交,这就保证了树里没有环。

📌 本讲核心知识地图

板块核心内容对应大纲
树的定义递归定义、根与子树、空树、森林树的基本概念
基本术语结点、度、叶子、双亲孩子兄弟、祖先子孙、层次与深度树的基本概念
树的性质边数 = 结点数 - 1、结点数 = 度数之和 + 1 等四条树的基本性质
树的存储双亲表示法、孩子表示法、孩子兄弟表示法树的存储结构
③核心口诀
🔑 树长什么样

一个根,往下分叉;
子树之间不相交,谁也不能成环;
除了根,人人只有一个爹;
n 个结点,n-1 条边!

🔑 三种存法

存爹 → 双亲表示法,找爹快、找娃慢;
存娃 → 孩子表示法,找娃快、找爹慢;
存大娃 + 兄弟 → 孩子兄弟法,
两个指针搞定一棵树,还能变成二叉树!

模块 02

知识探究

先认全术语、记住四条性质,再逐一攻克三种存储结构 —— 这是一切树形算法的前提。

1树的定义与基本术语 核心

下面这棵树将贯穿全讲,所有术语、性质和存储都以它为例,请先看熟它的长相:

🌳 示例树(8 个结点):黄色是根,绿色是叶子,蓝色是分支结点
第1层 第2层 第3层 第4层 1 2 3 4 5 6 7 8
根结点 分支结点 叶子结点 共 8 个结点、7 条边、4 层

对着这张图,把下面这些术语一个个对上号。它们是初赛选择题的常客,必须做到脱口而出。

结点相关的术语
  • 结点:树中的每一个元素
  • 根结点:唯一的、没有双亲的结点(1 号)
  • 叶子结点:度为 0,即没有孩子的结点(3、5、7、8)
  • 分支结点:度不为 0 的结点(1、2、4、6)
"度"的两个层次
  • 结点的度:该结点孩子的个数
  • 树的度:所有结点度数中的最大值
  • 本例:d(1)=3,d(2)=2,d(4)=1,d(6)=1
  • 本例树的度 = 3
关系类术语
  • 双亲(父):结点的直接上级
  • 孩子:结点的直接下级
  • 兄弟:同一双亲的孩子(2、3、4 互为兄弟)
  • 祖先 / 子孙:沿路径向上 / 向下的所有结点
术语含义以本例树为准
结点的层次(深度)根在第 1 层,往下逐层 +11 号在第 1 层,8 号在第 4 层
树的深度 / 高度树中结点的最大层次本例深度 = 4
路径与路径长度两个结点之间经过的结点序列;长度是边的条数1→8 的路径为 1-2-6-8,长度 3
子树某个结点及其全部后代构成的树以 2 为根的子树含 2、5、6、8
森林m(m ≥ 0)棵互不相交的树的集合删掉根 1,就得到 3 棵树构成的森林
有序树 / 无序树孩子的顺序有无左右之分本讲默认按从左到右有序
🔍 术语定位:以 6 号结点为中心的亲属关系
1 根·祖先 2 双亲·祖先 3 4 5 兄弟 6 当前结点 7 8 孩子·子孙
当前结点 6 双亲 2 与祖先 1 孩子 / 子孙 8 兄弟 5

💡 一张图记住所有术语

双亲只有一个,孩子可以有好几个;兄弟必须同一个爹(堂兄弟不算);祖先是从根到我这里这条路上的所有人(不含我自己),子孙是以我为根的子树里除我以外的所有人。

树的深度与高度在数值上总是相等(都等于最大层次),只是数方向不同:深度从根往下数,高度从叶子往上数。本讲统一把根记为第 1 层(也有教材记为第 0 层,遇到时看清题目约定即可)。

2树的四条重要性质 必考

树的性质是初赛选择题的高频考点,也是判断一棵结构"是不是树"的依据。四条性质如下:

#性质怎么理解
①n 个结点的树,边数 = n - 1除根以外,每个结点都恰好"带来"一条边通向它的双亲
②结点数 = 所有结点的度数之和 + 1与 ① 等价:每条边都被某个结点"贡献"了一次,只有根没有贡献
③度为 m 的树,第 i 层最多有 mi-1 个结点第 1 层 1 个;往后每个结点最多生 m 个,逐层乘 m
④深度为 h、度为 m 的树,最多有 (mh - 1) / (m - 1) 个结点把 ③ 中各层上界等比求和(m=2 时即 2h-1)

🔍 反过来也要会用

由 ④ 可以推出:n 个结点、度为 m 的树,其最小深度为满足 (mh-1)/(m-1) ≥ n 的最小 h,即 h = ⌈logm(n(m-1)+1)⌉。

通俗地说:树越"胖",就越"矮";每个结点都长满 m 个孩子时最矮,退化成一条链时最深(深度 = n)。

C++tree_props.cpp — 树的四条性质验证
#include <iostream>
#include <vector>
using namespace std;

const int MAXN = 105;
vector<int> child[MAXN];
int cnt[MAXN];                 // 每一层有多少个结点
int n, depth = 0;

void dfs(int u, int d) {
    if (d > depth) depth = d;
    cnt[d]++;
    for (int i = 0; i < (int)child[u].size(); i++) dfs(child[u][i], d + 1);
}

int main() {
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v; cin >> u >> v;
        child[u].push_back(v);
    }

int sum = 0, treeDeg = 0, leaf = 0;
    cout << "各结点度数:";
    for (int u = 1; u <= n; u++) {
        int d = child[u].size();
        cout << d << " ";
        sum += d;
        if (d > treeDeg) treeDeg = d;
        if (d == 0) leaf++;
    }
    cout << endl;

cout << "① 结点数 n = " << n << ",边数 = " << n - 1
         << " → 树的边数恒等于结点数减一" << endl;
    cout << "② 度数之和 = " << sum << ",加 1 = " << sum + 1
         << " → 结点数 = 度数之和 + 1" << endl;
    cout << "③ 树的度 = " << treeDeg << ",叶子个数 = " << leaf << endl;

dfs(1, 1);
    cout << "④ 树的深度 = " << depth << endl;

cout << "各层结点数与该层上界(m 为树的度):" << endl;
    int up = 1;
    for (int d = 1; d <= depth; d++) {
        cout << "  第 " << d << " 层:实际 " << cnt[d]
             << " 个,上界 " << up << " = " << treeDeg << "^" << d - 1 << endl;
        up *= treeDeg;
    }
    return 0;
}
输入(8 个结点,7 条有向边 u→v)
8
1 2
1 3
1 4
2 5
2 6
4 7
6 8
运行结果
各结点度数:3 2 0 1 0 1 0 0
① 结点数 n = 8,边数 = 7 → 树的边数恒等于结点数减一
② 度数之和 = 7,加 1 = 8 → 结点数 = 度数之和 + 1
③ 树的度 = 3,叶子个数 = 4
④ 树的深度 = 4
各层结点数与该层上界(m 为树的度):
  第 1 层:实际 1 个,上界 1 = 3^0
  第 2 层:实际 3 个,上界 3 = 3^1
  第 3 层:实际 3 个,上界 9 = 3^2
  第 4 层:实际 1 个,上界 27 = 3^3
3存储一:双亲表示法 必会

核心思想:既然除根以外每个结点都只有一个双亲,那就用一组连续的存储单元(数组)存放所有结点,每个结点里只存一个整数 —— 双亲的下标。

约定:根的双亲记为 -1(表示"没有双亲")。这样找双亲、找祖先都极快,顺着 parent 一路往上跳即可。

📦 示例树的双亲表示法:一个 data 数组 + 一个 parent 数组
结点编号
1
2
3
4
5
6
7
8
data:存的结点值
双亲下标
-1
0
0
0
1
1
3
5
parent:-1 表示没有双亲(根)
下标 i
0
1
2
3
4
5
6
7
数组下标 0~7
C++parent_repr.cpp — 双亲表示法
#include <iostream>
using namespace std;

// 双亲表示法:每个结点只记一件事 —— 我的双亲在哪
const int MAXN = 105;
struct PNode {
    int data;      // 结点编号
    int parent;    // 双亲的下标,-1 表示没有双亲(根)
} t[MAXN];

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) {
        t[i].data = i + 1;      // 下标 i 存编号 i+1
        cin >> t[i].parent;     // 依次输入每个结点的双亲下标,根输入 -1
    }

cout << "下标\t编号\t双亲下标\t双亲编号" << endl;
    for (int i = 0; i < n; i++) {
        cout << i << "\t" << t[i].data << "\t" << t[i].parent << "\t";
        if (t[i].parent == -1) cout << "无(根)" << endl;
        else cout << t[t[i].parent].data << endl;
    }

// ① 找根:parent == -1 的结点有且只有一个
    int root = -1, cnt = 0;
    for (int i = 0; i < n; i++)
        if (t[i].parent == -1) { root = i; cnt++; }
    cout << "根:下标 " << root << ",编号 " << t[root].data
         << "(双亲为 -1 的结点共 " << cnt << " 个)" << endl;

// ② 顺着 parent 一路往上爬,就得到祖先链
    int x = 7;                  // 想查编号 8 的祖先
    cout << t[x].data << " 的祖先链(含自己):";
    while (x != -1) {
        cout << t[x].data << " ";
        x = t[x].parent;
    }
    cout << endl;
    return 0;
}
输入(8 个结点各自的双亲下标)
8
-1 0 0 0 1 1 3 5
运行结果
下标	编号	双亲下标	双亲编号
0	1	-1	无(根)
1	2	0	1
2	3	0	1
3	4	0	1
4	5	1	2
5	6	1	2
6	7	3	4
7	8	5	6
根:下标 0,编号 1(双亲为 -1 的结点共 1 个)
8 的祖先链(含自己):8 6 2 1

🔍 关键细节分析

优点:找双亲 O(1),找根只需扫描一遍找 parent == -1,求某结点的祖先链也是一路向上 O(深度)。

缺点:想找某个结点的所有孩子,只能从头到尾扫一遍整个数组,看谁的 parent 等于我 —— O(n)。这就是"存爹容易找娃难"。

判断两结点是否同根 / 是否连通:各自沿 parent 走到顶,看终点是否相同 —— 这正是并查集的雏形(见模块四拓展)。

4存储二:孩子表示法(邻接表) 必会

核心思想:把每个结点的所有孩子串成一条链表(竞赛中直接用 vector),再用一个数组把这些链表的头指针存起来。这就是图论里说的邻接表。

它和双亲表示法正好互补:找孩子一目了然,找双亲要遍历全部链表。竞赛中最常用,因为它能直接支持 DFS 与 BFS。

🔗 示例树的孩子表示法:每个结点一条孩子链,∧ 表示链尾
下标 0
1
→
1
→
2
→
3
→
∧
编号 1 的 3 个孩子
下标 1
2
→
4
→
5
→
∧
编号 2 的 2 个孩子
下标 2
3
→
∧
编号 3:没有孩子,是叶子
下标 3
4
→
6
→
∧
编号 4 的 1 个孩子
下标 4
5
→
∧
叶子
下标 5
6
→
7
→
∧
编号 6 的 1 个孩子
下标 6
7
→
∧
叶子
下标 7
8
→
∧
叶子
C++child_repr.cpp — 孩子表示法(邻接表)
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

// 孩子表示法:每个结点拉一条链表,串起它所有的孩子
const int MAXN = 105;
vector<int> child[MAXN];      // child[u] 里存 u 的所有孩子
int n, maxDep = 0;

void dfs(int u, int dep) {                 // 先根遍历,顺便求深度
    if (dep > maxDep) maxDep = dep;
    cout << u << " ";
    for (int i = 0; i < (int)child[u].size(); i++)
        dfs(child[u][i], dep + 1);
}

int subtree(int u) {                       // 以 u 为根的子树有多大
    int s = 1;
    for (int i = 0; i < (int)child[u].size(); i++)
        s += subtree(child[u][i]);
    return s;
}

int main() {
    cin >> n;
    for (int i = 1; i < n; i++) {          // n 个结点恰好 n-1 条边
        int u, v;                          // u 是 v 的双亲
        cin >> u >> v;
        child[u].push_back(v);
    }

cout << "孩子表示法(邻接表):" << endl;
    for (int u = 1; u <= n; u++) {
        cout << u << " -> ";
        for (int i = 0; i < (int)child[u].size(); i++)
            cout << child[u][i] << " ";
        cout << endl;
    }

cout << "先根遍历:";
    dfs(1, 1);
    cout << endl << "树的深度:" << maxDep << endl;

cout << "层序遍历:";                   // 队列实现层序
    queue<int> q;
    q.push(1);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        cout << u << " ";
        for (int i = 0; i < (int)child[u].size(); i++) q.push(child[u][i]);
    }
    cout << endl;

cout << "叶子:";
    int leaf = 0;
    for (int u = 1; u <= n; u++)
        if (child[u].size() == 0) { cout << u << " "; leaf++; }
    cout << endl << "叶子个数:" << leaf << endl;

cout << "各结点子树大小:";
    for (int u = 1; u <= n; u++) cout << u << ":" << subtree(u) << " ";
    cout << endl;
    return 0;
}
输入(8 个结点,7 条边,每行 "双亲 孩子")
8
1 2
1 3
1 4
2 5
2 6
4 7
6 8
运行结果
孩子表示法(邻接表):
1 -> 2 3 4
2 -> 5 6
3 ->
4 -> 7
5 ->
6 -> 8
7 ->
8 ->
先根遍历:1 2 5 6 8 3 4 7
树的深度:4
层序遍历:1 2 3 4 5 6 7 8
叶子:3 5 7 8
叶子个数:4
各结点子树大小:1:8 2:4 3:1 4:2 5:1 6:2 7:1 8:1
注意

孩子表示法里,别把"叶子"和"链尾"搞混

叶子是"没有孩子"的结点(链为空);链尾是某条孩子链的最后一个元素。示例树中下标 5(编号 6)的链上有一个元素 7(编号 8),它不是叶子。判断叶子请一律写 child[u].empty(),不要靠"是不是最后一个"来猜。

5存储三:孩子兄弟表示法 必考

核心思想:每个结点设两个指针 —— firstChild 指向第一个孩子,nextSibling 指向紧挨着的右兄弟。于是"找到第一个孩子后顺着兄弟链走",就能访问全部孩子。

它最大的意义在于:任意一棵普通树都能用它表示成一棵二叉树(左孩子、右兄弟),从而复用二叉树的所有算法。这是"树 → 二叉树"转换的桥梁。

👦 示例树的孩子兄弟表示法:每个结点框内依次为 编号 / 第一个孩子 / 下一个兄弟
1
孩子 2
兄弟 -1
2
孩子 5
兄弟 3
3
孩子 -1
兄弟 4
4
孩子 7
兄弟 -1
5
孩子 -1
兄弟 6
6
孩子 8
兄弟 -1
7
孩子 -1
兄弟 -1
8
孩子 -1
兄弟 -1
蓝色=结点本身绿色=第一个孩子粉色=下一个兄弟-1 表示空
C++child_sibling.cpp — 孩子兄弟表示法
#include <iostream>
using namespace std;

// 孩子兄弟表示法:两个指针就够了 —— 第一个孩子 + 下一个兄弟
const int MAXN = 105;
int fc[MAXN], ns[MAXN];        // firstChild / nextSibling,-1 表示空
int n;

void preorder(int u) {         // 按"根 → 孩子 → 兄弟"遍历整棵转换后的二叉树
    if (u == -1) return;
    cout << u << " ";
    preorder(fc[u]);           // 先一头扎进孩子
    preorder(ns[u]);           // 再回头处理兄弟
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) { fc[i] = -1; ns[i] = -1; }

for (int i = 1; i <= n; i++) {
        int u, k;
        cin >> u >> k;                     // u 有 k 个孩子,后面依次列出
        if (k > 0) {
            cin >> fc[u];
            int last = fc[u];
            for (int j = 1; j < k; j++) {
                int v; cin >> v;
                ns[last] = v; last = v;    // 把兄弟串成一条链
            }
        }
    }

cout << "编号\tfirstChild\tnextSibling" << endl;
    for (int i = 1; i <= n; i++)
        cout << i << "\t" << fc[i] << "\t\t" << ns[i] << endl;

cout << "各结点的孩子(顺着兄弟链走):" << endl;
    for (int u = 1; u <= n; u++) {
        cout << u << " -> ";
        int v = fc[u];
        while (v != -1) { cout << v << " "; v = ns[v]; }
        cout << endl;
    }

cout << "转换后二叉树的先序遍历:";
    preorder(1);
    cout << endl;
    return 0;
}
输入(每个结点一行:"编号 孩子个数 孩子1 孩子2 …")
8
1 3 2 3 4
2 2 5 6
3 0
4 1 7
5 0
6 1 8
7 0
8 0
运行结果
编号	firstChild	nextSibling
1	2		-1
2	5		3
3	-1		4
4	7		-1
5	-1		6
6	8		-1
7	-1		-1
8	-1		-1
各结点的孩子(顺着兄弟链走):
1 -> 2 3 4
2 -> 5 6
3 ->
4 -> 7
5 ->
6 -> 8
7 ->
8 ->
转换后二叉树的先序遍历:1 2 5 6 8 3 4 7

💡 为什么"先孩子、后兄弟"的遍历结果和原树的先根遍历一样?

把 firstChild 看成二叉树的左孩子,nextSibling 看成右孩子,那么"根 → 左子树 → 右子树"的先序遍历,落到原树上正是"访问自己 → 依次处理每个孩子(孩子的兄弟链上就是下一个孩子)"。

结论:转换后二叉树的先序遍历 = 原树的先根遍历。上面程序输出的 1 2 5 6 8 3 4 7 与孩子表示法的 DFS 结果完全一致,就是这个道理。

6三种存储结构对比:怎么选?
对比项双亲表示法孩子表示法孩子兄弟表示法
每个结点存什么双亲下标 parent孩子链表(vector)firstChild + nextSibling
找双亲O(1) 直接读O(n) 全表扫描O(n) 扫描
找所有孩子O(n) 全表扫描O(度数) 直接读O(度数) 走兄弟链
找兄弟找出双亲后再找其孩子找双亲后再读其链表O(1) 一步到位
空间开销最省(每结点 1 个 int)较大(链指针 + 动态分配)中等(每结点 2 个指针)
适合场景只关心"谁是谁的上级"、并查集竞赛首选:DFS / BFS / 树形 DP树转二叉树、节省指针
共同点都必须知道根是谁才能遍历整棵树;都可以加一个 parent 域互相补足
口诀:只找爹用双亲表,要遍历用孩子表,想变二叉树用兄弟表;不确定时,孩子表示法(邻接表)永远不会错!
技巧

实战中最常用的组合:孩子表示法 + parent 数组

竞赛写法往往是边读入边记录双亲:读一条边 u v 时,既 child[u].push_back(v),又记 fa[v] = u。这样向上向下都畅通,找祖先、判连通、求路径全都方便(见 C4)。代价只是多一个数组,非常划算。

模块 03

实践应用

用手推题把术语刻进脑子,用编程题把三种存储真正写一遍。

题1术语辨认与手推

题目:针对本讲的示例树(1 为根;2、3、4 是 1 的孩子;5、6 是 2 的孩子;7 是 4 的孩子;8 是 6 的孩子),回答:

① 哪些是叶子结点?② 各结点的度分别是多少?树的度是多少?③ 树的深度是多少?④ 结点 8 的祖先有哪些?⑤ 结点 6 的兄弟是谁?⑥ 从 1 到 8 的路径与路径长度?⑦ 以 2 为根的子树包含哪些结点?⑧ 若删去根结点 1,会得到几棵树(森林中树的棵数)?

提示:祖先不含自己,子孙也不含自己;路径长度数的是边不是结点。
查看参考答案
小问答案依据
① 叶子3、5、7、8度为 0,即没有孩子的结点
② 各结点的度d(1)=3,d(2)=2,d(3)=0,d(4)=1,d(5)=0,d(6)=1,d(7)=0,d(8)=0孩子个数
树的度 = 3(最大值)
③ 树的深度4最长的一条链是 1-2-6-8,共 4 层
④ 8 的祖先6、2、1沿路径向上,不含自己
⑤ 6 的兄弟5与 6 同双亲(2)的只有 5,堂兄弟不算
⑥ 1 到 8 的路径1 → 2 → 6 → 8,长度 3长度 = 边的条数 = 结点数 - 1
⑦ 2 的子树2、5、6、8,共 4 个结点自己 + 全部后代
⑧ 删去根 1得到 3 棵树1 有 3 个孩子,每个孩子各成一棵树

顺带验证两条性质:度数之和 = 3+2+0+1+0+1+0+0 = 7,加 1 = 8 = 结点数 ✔;边数 = 8 - 1 = 7 ✔。

易错提醒:⑧ 的答案是 3 而不是 4 —— 森林的棵数等于根的孩子个数,不是结点的孩子个数之和。

题2三种存储结构填表

题目:仍以示例树为例,分别写出双亲表示法的 parent 数组、孩子表示法的各条孩子链、孩子兄弟表示法的 firstChild 与 nextSibling 数组(空一律用 -1 表示)。

提示:孩子兄弟表示法中,nextSibling 指向"同一个双亲的下一个孩子",链尾结点的 nextSibling 为 -1。
查看参考答案

① 双亲表示法(下标 0~7 对应编号 1~8)

下标01234567
data12345678
parent-10001135

② 孩子表示法(写成编号形式)

结点12345678
孩子链2 3 45 6空7空8空空

③ 孩子兄弟表示法

结点12345678
firstChild25-17-18-1-1
nextSibling-134-16-1-1-1

怎么验证自己填对了:

· 双亲表示法里,-1 必须恰好出现一次(否则要么没有根,要么有多个根,那就不是树而是森林了)。

· 孩子表示法里,所有链上的元素总数 = n - 1 = 7(每条边被一个结点记录一次)。本例 3+2+0+1+0+1+0+0 = 7 ✔。

· 孩子兄弟表示法里,"从 firstChild 出发顺着 nextSibling 走"得到的序列,应与孩子表示法里的链完全一致:如 1 → 2 → 3 → 4,2 → 5 → 6。

题3综合编程:建树、层序遍历、求深度与叶子

题目:输入 n 个结点,再输入每个结点的双亲编号(根的双亲记为 0)。要求:找出根、输出层序遍历序列、输出树的深度与叶子个数。

提示:输入给出的是双亲表示法,但层序遍历需要"从孩子往下走" —— 先把它转成孩子表示法,再用队列 BFS。层序遍历按一层一层处理,队列里的元素个数就是当前层的结点数。
查看参考答案
C++tree_basic.cpp — 综合:建树 + 层序 + 深度 + 叶子
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

// 输入每个结点的双亲,转成孩子表示法,再求根、层序、深度与叶子
const int MAXN = 105;
int fa[MAXN];
vector<int> child[MAXN];
int n;

int main() {
    cin >> n;
    int root = 0;
    for (int i = 1; i <= n; i++) {
        cin >> fa[i];
        if (fa[i] == 0) root = i;              // 没有双亲的就是根
        else child[fa[i]].push_back(i);        // 双亲表示法 → 孩子表示法
    }
    cout << "根:" << root << endl;

queue<int> q;
    q.push(root);
    int depth = 0;
    while (!q.empty()) {
        int sz = q.size();                     // 当前这一层的结点个数
        depth++;
        while (sz--) {
            int u = q.front(); q.pop();
            cout << u << " ";
            for (int i = 0; i < (int)child[u].size(); i++) q.push(child[u][i]);
        }
    }
    cout << endl << "深度:" << depth << endl;

int leaf = 0;
    cout << "叶子:";
    for (int i = 1; i <= n; i++)
        if (child[i].size() == 0) { cout << i << " "; leaf++; }
    cout << endl << "叶子个数:" << leaf << endl;
    return 0;
}

样例

输入
8
0 1 1 1 2 2 4 6
运行结果
根:1
1 2 3 4 5 6 7 8
深度:4
叶子:3 5 7 8
叶子个数:4

解析:读入时,fa[i]==0 的 i 就是根(本例为 1);其余每个 i 都被挂到 child[fa[i]] 的末尾。转换完成后,从根出发做 BFS,每处理完一层深度加一,同时统计没有孩子的结点。

复杂度:建树 O(n),BFS 每个结点入队出队各一次,时间 O(n),空间 O(n)。

拓展思考:如果题目给的是"每个结点有多少个孩子 + 孩子编号",就不能直接建孩子表示法了吗?当然可以 —— 把每个孩子 push 进对应 vector 即可,因此两种输入形式最终都能转成孩子表示法。

模块 04

总结提升

1核心要点总结
知识板块必须记住的核心
树的定义n 个结点的有限集;有且仅有一个根;子树互不相交;递归定义
树 vs 线性表线性表一对一,树一对多,树是非线性结构
结点分类根(无双亲)、叶子(度为 0)、分支结点(度不为 0)
度结点的度 = 孩子个数;树的度 = 所有结点度的最大值
层次与深度根为第 1 层;树的深度 = 最大层次(数值上等于高度)
路径路径长度数的是边数,不是结点数
性质①②边数 = n - 1;结点数 = 度数之和 + 1(两式等价)
性质③④第 i 层最多 mi-1 个;深度 h 的 m 叉树最多 (mh-1)/(m-1) 个
双亲表示法找双亲 O(1),找孩子 O(n);根记 -1
孩子表示法邻接表 / vector 数组,竞赛首选,直接支持 DFS 与 BFS
孩子兄弟法firstChild + nextSibling,可把普通树转成二叉树
森林m 棵互不相交的树;删去根即得森林
2拓展:并查集与二叉树存储 CSP-J 不要求
说明

以下内容部分属于 CSP-S(提高级)范围

本讲主体内容严格限定在 CSP-J 入门级大纲内。下面三项仅作了解:知道"有这个东西、能解决什么问题"即可,初赛复赛均不作要求(二叉树将是下一讲的主角)。

并查集
  • 双亲表示法的升华
  • 只关心"是否同一集合"
  • find + merge 两个操作
  • 路径压缩后近乎 O(1)
  • 典型题:P3367、P1551
二叉树链式存储
  • 每个结点 两个孩子指针
  • lch / rch,空记 -1
  • 孩子兄弟法的"特例"
  • 支持先序 / 中序 / 后序遍历
  • 下一讲 m5-05 的主角
树的重心与直径
  • 重心:删去后最大子树最小
  • 直径:树上最长的路径
  • 两次 BFS 即可求直径
  • 属于树形 DP 入门
  • 典型题:P1395、C3 已演示
C++extend.cpp — 并查集与二叉树链式存储
#include <iostream>
using namespace std;

// ① 并查集:双亲表示法的"高级玩法",只关心"谁和谁是一伙的"
const int MAXN = 105;
int fa[MAXN];
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
void merge(int a, int b) { fa[find(a)] = find(b); }

// ② 二叉树的链式存储(下一讲的主角,先认识一下)
struct Node {
    int val;
    int lch, rch;               // 左右孩子的下标,-1 表示空
} bt[MAXN];

int main() {
    for (int i = 1; i <= 5; i++) fa[i] = i;
    merge(1, 2); merge(3, 4); merge(2, 3);
    cout << "并查集:1 与 4 是否同集合?" << (find(1) == find(4) ? "Yes" : "No") << endl;
    cout << "代表元 find(1) = " << find(1) << endl;

bt[1].val = 1; bt[1].lch = 2; bt[1].rch = 3;
    bt[2].val = 2; bt[2].lch = -1; bt[2].rch = -1;
    bt[3].val = 3; bt[3].lch = -1; bt[3].rch = -1;
    cout << "二叉树:根 " << bt[1].val
         << ",左孩子 " << bt[bt[1].lch].val
         << ",右孩子 " << bt[bt[1].rch].val << endl;
    return 0;
}
运行结果(本例无需输入)
并查集:1 与 4 是否同集合?Yes
代表元 find(1) = 4
二叉树:根 1,左孩子 2,右孩子 3
3思考与启发

💡 学完本讲,你应该掌握的6项能力

1. 能说出树的递归定义,并解释"为什么树里不会有环"。

2. 能对着一棵树,准确指出根、叶子、双亲、孩子、兄弟、祖先、子孙、层次、深度、路径。

3. 能区分结点的度与树的度,并熟记"边数 = n - 1""结点数 = 度数之和 + 1"。

4. 能独立写出双亲表示法与孩子表示法的数组 / 链表内容。

5. 能说出孩子兄弟表示法的两个指针含义,并理解它如何把普通树变成二叉树。

6. 能用孩子表示法完成建树、DFS 先根遍历、BFS 层序遍历、求深度、数叶子、求子树大小。

模块 05

学习检测

5 个初学者最常犯的错误。

易错1

混淆"结点的度"与"树的度"

❌ 错误
问"树的度是多少",却把所有结点的度加起来回答 7。
✅ 正确
树的度 = 所有结点度的最大值。本例是 3(1 号结点有 3 个孩子)。
度数之和另有用途:加 1 等于结点总数。
口诀:树的度看最大,度数之和加一才是结点数!
易错2

把边数记成 n,或把层次从 0 开始数

❌ 错误
8 个结点就说有 8 条边;或者把根算作第 0 层,于是深度答成 3。
✅ 正确
边数 = n - 1 = 7。除根以外每个结点恰好贡献一条指向双亲的边。
本讲约定根在第 1 层,深度 = 最大层次 = 4。
口诀:边比点少一,根在第 1 层!
易错3

把"兄弟"和"堂兄弟"混为一谈

❌ 错误
认为 7 号(4 的孩子)和 5 号(2 的孩子)也是兄弟,因为它们在同一层。
✅ 正确
兄弟 = 同一个双亲的孩子。7 的父亲是 4,5 的父亲是 2,它们是堂兄弟,不是兄弟。孩子兄弟表示法里的 nextSibling 串的只是亲兄弟。
口诀:同父才是亲兄弟,同层不算!
易错4

双亲表示法里用 0 表示"没有双亲"

❌ 错误
根的双亲写成 0 —— 这与"下标 0 号结点"冲突,程序会把根认成 0 号的孩子,遍历时死循环或漏掉根。
✅ 正确
空指针一律用 -1(下标体系下)。若题目改用"编号 1~n"作为下标(0 号不用),才可以用 0 表示空 —— 但前提是全代码统一,本讲统一用 -1。
口诀:下标从 0 起,空就用 -1!
易错5

建树时忘了"n 个结点只有 n-1 条边",或漏掉根的判断

❌ 错误
循环写成 for (i = 1; i <= n; i++) 去读 n 条边,多读一条导致后面全部错位;或者建完树直接 dfs(1),而根并不一定是 1 号。
✅ 正确
读入边时写 for (i = 1; i < n; i++);建完先找出根(parent 为 -1 / 入度为 0 的结点),再从根开始遍历。
口诀:边数是 n-1,先找根再遍历!
速查树速查表
知识点关键结论
树的本质一对多的非线性结构,一个根、无环、子树互不相交
边数n - 1(n 为结点数)
度数关系结点数 = 所有结点度数之和 + 1
树的度所有结点度的最大值
叶子度为 0(没有孩子)的结点
层次与深度根第 1 层,树的深度 = 最大层次
路径长度数的边数,不是结点数
第 i 层上界mi-1(m 为树的度)
双亲表示法parent[i],根为 -1;找双亲 O(1),找孩子 O(n)
孩子表示法vector<int> child[N];找孩子快,竞赛首选
孩子兄弟法firstChild + nextSibling;可转二叉树
遍历方式DFS 求深度与子树大小,BFS(队列)求层序
📌知识点笔记卡

🌳 树的定义

唯一根 + 互不相交的子树

递归结构、无环

一对多

📏 度

结点的度 = 孩子数

树的度 = 最大值

度 0 即叶子

📐 两条硬性质

边数 = n - 1

结点数 = 度数和 + 1

初赛必考

👨 双亲表示法

只存 parent

找爹 O(1),找娃 O(n)

根记 -1

🧒 孩子表示法

邻接表 / vector

找娃快,可直接 DFS/BFS

竞赛首选

👦 孩子兄弟法

firstChild + nextSibling

兄弟只串亲兄弟

可转二叉树

课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增,点击展开答案与解析。

🟢 A组 · 基础(4题)

A1选择题

下列关于树的叙述,正确的是()

A. 树中每个结点可以有多个双亲
B. n 个结点的树恰好有 n-1 条边
C. 树中至少存在一个环
D. 树的度等于所有结点度数之和

查看参考答案

答案:B

A 错:除根以外,每个结点有且仅有一个双亲;C 错:树中一定没有环(有环就成了图);D 错:树的度是所有结点度的最大值,而"度数之和 = 结点数 - 1"。

B 对:这是树最基本的性质,从"每个非根结点恰好贡献一条指向双亲的边"可以直接推出。

考点:树中除根以外每个结点有且仅有一个双亲,因此每个非根结点恰好贡献一条连向双亲的边,边数 = n - 1。

易错:把“树的度”记成所有结点度数之和;它其实是所有结点度数中的最大值。

A2选择题

一棵树中,度为 3 的结点有 2 个,度为 2 的结点有 1 个,度为 1 的结点有 2 个,其余全是叶子。则叶子个数为()

A. 5   B. 6   C. 7   D. 8

查看参考答案

答案:B(6 个)

度数之和 = 3×2 + 2×1 + 1×2 = 10,即边数为 10,于是结点数 n = 10 + 1 = 11。

非叶子结点共 2 + 1 + 2 = 5 个,所以叶子数 = 11 - 5 = 6。

通用公式:设度为 i 的结点有 ni 个,则 n = 1 + Σ(i × ni),叶子数 n0 = 1 + Σ(i × ni ) - Σni(i≥1)。进一步化简得 n0 = 1 + Σ((i-1) × ni)(i ≥ 1)。

考点:度数之和 = 边数 = n - 1:先由 Σ(i×ni) = 10 得边数 10,故 n = 11,再减去 5 个非叶子结点,叶子数为 6。

易错:把“度数之和 10”直接当成结点数,或求出边数后忘记加 1 得结点数。

A3选择题

下列结构中,一定不是树的是()

A. 8 个结点、7 条边的连通结构
B. 8 个结点、8 条边的连通结构
C. 除一个结点外,其余结点都恰有一个双亲
D. 从任意结点到某个特定结点都有唯一路径

查看参考答案

答案:B

n 个结点、n-1 条边的连通结构恰好是树(A 可以、C 可以、D 也描述的是树的特征)。

而 n 个结点、n 条边的连通结构一定含有环,违反了树的定义。记住这条判据:连通 + 边数 = 点数 - 1 + 无环,三者中任何一条不满足都不是树。

考点:n 个结点、n-1 条边且连通,恰好就是树;若边数达到 n,则必然存在环,一定不是树。

易错:只检查“连通”就判成树,漏掉了对环的检查。

A4编程题

用双亲表示法读入 n 个结点及其双亲编号(根的双亲为 0),输出根,以及每个结点的深度。

查看参考答案
C++a4.cpp — 双亲表示法求深度
#include <iostream>
using namespace std;

// 双亲表示法求每个结点的深度:从自己出发,一路往上数到根
const int MAXN = 105;
int fa[MAXN];

int main() {
    int n, root = 0;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> fa[i];                 // 输入编号 i 的双亲编号,根的双亲写 0
        if (fa[i] == 0) root = i;
    }
    cout << "根:" << root << endl;

int maxDep = 0;
    for (int i = 1; i <= n; i++) {
        int x = i, d = 1;
        while (fa[x] != 0) { d++; x = fa[x]; }
        if (d > maxDep) maxDep = d;
        cout << "编号 " << i << " 的深度 = " << d << endl;
    }
    cout << "树的深度:" << maxDep << endl;
    return 0;
}
输入
8
0 1 1 1 2 2 4 6
运行结果
根:1
编号 1 的深度 = 1
编号 2 的深度 = 2
编号 3 的深度 = 2
编号 4 的深度 = 2
编号 5 的深度 = 3
编号 6 的深度 = 3
编号 7 的深度 = 3
编号 8 的深度 = 4
树的深度:4

求深度的办法很朴素:从自己出发,while (fa[x] != 0) { d++; x = fa[x]; } 一路数到根。因为每个结点到根的路径唯一,这个数就是层次。

复杂度:逐个结点往上爬,最坏 O(n²)(退化成链时)。若想优化到 O(n),可以顺着双亲关系记忆化或先从根做一次 DFS —— 这正是"双亲表示法找孩子慢"带来的代价。

思路:双亲表示法用 fa[i] 存结点 i 的双亲编号,求深度就从 i 出发沿 fa 一路向上数到根(fa 为 0 表示根),走过的边数即深度。

易错:求深度是自下而上数到根,不是自根向下走;另外根的双亲编号 0 表示“无双亲”,不能当成有效结点处理。

🟡 B组 · 提高(4题)

B1分析题

已知一棵树的双亲表示法数组 parent = [-1, 0, 0, 1, 1, 1, 2](下标 0~6 对应编号 1~7)。请画出这棵树,并写出它的深度、叶子结点与树的度。

查看参考答案

还原过程:parent[i] 是下标 i 的双亲下标,于是 ——

下标 0(编号 1)双亲为 -1 → 根;下标 1、2(编号 2、3)的双亲是下标 0 → 1 的孩子;

下标 3、4、5(编号 4、5、6)的双亲是下标 1 → 2 的孩子;下标 6(编号 7)的双亲是下标 2 → 3 的孩子。

结构:1 → {2, 3};2 → {4, 5, 6};3 → {7}

答案:深度 3,叶子是 4、5、6、7(共 4 个),树的度 = 3。

检验:7 个结点 6 条边 ✔;度数 2+3+1+0+0+0+0 = 6,加 1 = 7 = 结点数 ✔。

思路:parent[i] 记录的是双亲的下标而不是编号:值为 -1 的是根,同一双亲的下标互为兄弟。按此展开父子关系,层数最大者给出深度,无孩子的即叶子。

易错:把 parent 数组里存的值当成“结点编号”而非“下标”,会使整棵树错位一位。

B2编程题

用孩子表示法读入 n 个结点与 n-1 条边(每行"双亲 孩子"),输出先根遍历序列与每个结点的子树大小。

查看参考答案
C++b2.cpp — 先根遍历与子树大小
#include <iostream>
#include <vector>
using namespace std;

const int MAXN = 105;
vector<int> child[MAXN];
int sz[MAXN];                  // 子树大小
int n;

void dfs(int u) {
    cout << u << " ";
    sz[u] = 1;
    for (int i = 0; i < (int)child[u].size(); i++) {
        int v = child[u][i];
        dfs(v);
        sz[u] += sz[v];        // 孩子的子树都算进自己头上
    }
}

int main() {
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v; cin >> u >> v;
        child[u].push_back(v);
    }
    cout << "先根遍历:";
    dfs(1);
    cout << endl << "各结点子树大小:";
    for (int i = 1; i <= n; i++) cout << i << ":" << sz[i] << " ";
    cout << endl;
    return 0;
}
输入
8
1 2
1 3
1 4
2 5
2 6
4 7
6 8
运行结果
先根遍历:1 2 5 6 8 3 4 7
各结点子树大小:1:8 2:4 3:1 4:2 5:1 6:2 7:1 8:1

子树大小的求法:递归返回时把孩子的 size 累加到自己头上,即 sz[u] = 1 + Σ sz[v]。这是树形 DP 最基础的一步。

注意:必须先递归完所有孩子再累加,不能在递归前就写 sz[u] = 1 之后忘了加 —— 那样所有结点都会得到 1。

思路:用 vector 数组把每个结点的孩子存起来,从根开始 DFS:进入结点时先输出它本身即得先根遍历,回溯时把各孩子的子树大小累加起来再加 1 得到 sz[u]。

B3编程题

用孩子兄弟表示法存储一棵普通树,输出每个结点的所有孩子,并输出转换后二叉树的先序遍历。

查看参考答案
C++b3.cpp — 孩子兄弟表示法
#include <iostream>
using namespace std;

const int MAXN = 105;
int fc[MAXN], ns[MAXN];
int n;

void preorder(int u) {
    if (u == -1) return;
    cout << u << " ";
    preorder(fc[u]);
    preorder(ns[u]);
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) { fc[i] = -1; ns[i] = -1; }
    for (int i = 1; i <= n; i++) {
        int u, k; cin >> u >> k;
        if (k > 0) {
            cin >> fc[u];
            int last = fc[u];
            for (int j = 1; j < k; j++) { int v; cin >> v; ns[last] = v; last = v; }
        }
    }
    cout << "编号\tfirstChild\tnextSibling" << endl;
    for (int i = 1; i <= n; i++) cout << i << "\t" << fc[i] << "\t\t" << ns[i] << endl;

cout << "各结点的孩子:" << endl;
    for (int u = 1; u <= n; u++) {
        cout << u << " -> ";
        int v = fc[u];
        while (v != -1) { cout << v << " "; v = ns[v]; }
        if (fc[u] == -1) cout << "(无)";
        cout << endl;
    }
    cout << "转换后二叉树的先序遍历:";
    preorder(1);
    cout << endl;
    return 0;
}
输入
8
1 3 2 3 4
2 2 5 6
3 0
4 1 7
5 0
6 1 8
7 0
8 0
运行结果
编号	firstChild	nextSibling
1	2		-1
2	5		3
3	-1		4
4	7		-1
5	-1		6
6	8		-1
7	-1		-1
8	-1		-1
各结点的孩子:
1 -> 2 3 4
2 -> 5 6
3 -> (无)
4 -> 7
5 -> (无)
6 -> 8
7 -> (无)
8 -> (无)
转换后二叉树的先序遍历:1 2 5 6 8 3 4 7

关键观察:转换后二叉树的先序遍历 1 2 5 6 8 3 4 7 与孩子表示法的 DFS 结果完全相同 —— 因为"先孩子后兄弟"正好等价于"依次处理每个孩子"。

易错点:构造兄弟链时必须依次链接(ns[last] = v; last = v;),若把每个孩子的 nextSibling 都指向第一个孩子,链就断了。

思路:孩子兄弟表示法用 fc[u] 存第一个孩子、ns[u] 存下一个兄弟。先序遍历按“根 → 孩子链 → 兄弟链”递归即可;左边挂孩子、右边挂兄弟,就等价于一棵二叉树。

B4分析题

为什么说"n 个结点的树恰好有 n-1 条边"?如果少一条 / 多一条,分别会发生什么?

查看参考答案

理由:除根以外,每个结点都有且仅有一个双亲,于是每个非根结点都唯一对应一条"连向双亲"的边。共有 n-1 个非根结点,所以边数 = n-1。反过来说,每条边都被"下方那个结点"认领一次,所以度数之和 = 边数 = n-1。

少一条(n-2 条):结构不再连通,会分裂成至少两棵互不相交的树 —— 这就是森林。

多一条(n 条):由于边数 ≥ 结点数,结构中必然出现环;一旦有环,"从任意结点到根的路径唯一"就不再成立,它已经不是树,而是一张图。

一句话总结:树 = 连通 + 无环,两个条件合起来恰好锁定边数为 n-1。

思路:每个非根结点都唯一认领一条连向双亲的边,n 个结点共有 n-1 个非根结点,所以边数恒为 n-1;少一条会断成森林,多一条必然出现环,都不再是树。

易错:以为边数少了只是“树变小”;其实结构已不再连通,分裂成了森林。

🔴 C组 · 挑战(4题)

C1综合编程

求树的直径:树上最长的那条路径有多少条边(两次 BFS 法)。

查看参考答案
C++c3.cpp — 树的直径(两次 BFS)
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

// 树的直径:两次 BFS。第一次找最远点,第二次从最远点出发,距离即直径
const int MAXN = 105;
vector<int> g[MAXN];           // 树是无向连通图,双向存边
int dist[MAXN];
int n;

int bfs(int s, int &far) {
    for (int i = 1; i <= n; i++) dist[i] = -1;
    queue<int> q;
    q.push(s); dist[s] = 0; far = s;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int i = 0; i < (int)g[u].size(); i++) {
            int v = g[u][i];
            if (dist[v] == -1) {
                dist[v] = dist[u] + 1;
                if (dist[v] > dist[far]) far = v;
                q.push(v);
            }
        }
    }
    return dist[far];
}

int main() {
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v; cin >> u >> v;
        g[u].push_back(v); g[v].push_back(u);
    }
    int a, b;
    int d1 = bfs(1, a);
    cout << "第一次 BFS:从 1 出发,最远点是 " << a << ",距离 " << d1 << endl;
    int d2 = bfs(a, b);
    cout << "第二次 BFS:从 " << a << " 出发,最远点是 " << b << ",距离 " << d2 << endl;
    cout << "树的直径 = " << d2 << " 条边" << endl;
    return 0;
}
输入(8 个结点,7 条无向边)
8
1 2
1 3
1 4
2 5
2 6
4 7
6 8
运行结果
第一次 BFS:从 1 出发,最远点是 8,距离 3
第二次 BFS:从 8 出发,最远点是 7,距离 5
树的直径 = 5 条边

为什么两次 BFS 就够:从任意点 s 出发,最远点 a 必定是直径的一个端点;再从 a 出发做一次 BFS,到最远点 b 的距离就是整棵树的直径。注意树是无向的,所以要双向存边。

复杂度:两次 BFS,时间 O(n),空间 O(n)。

提醒:树的直径属于提高级内容,CSP-J 阶段只要求理解思路,不要求背代码。

C2分析题

证明:"树中任意两个结点之间的路径唯一",并说明它如何推出"边数 = 结点数 - 1"。

查看参考答案

证明路径唯一(反证法):设 u 到 v 有两条不同的路径。把这两条路径首尾相接,就得到一条从 u 回到 u 的闭路径 —— 也就是说树中出现了环。这与树的定义矛盾,故路径唯一。

推出边数公式(归纳法):

① 当 n = 1 时,边数 = 0 = n - 1,成立。

② 假设结点数 < n 时成立。任取一棵 n 个结点的树,一定存在叶子(否则从根一直往下走会无限延伸,与有限性矛盾)。

③ 摘掉这个叶子及其唯一相连的边,剩余部分仍是连通且无环的,即仍是一棵树,有 n-1 个结点。由归纳假设它有 (n-1)-1 = n-2 条边。

④ 加上刚摘掉的那条边,原树共有 (n-2) + 1 = n - 1 条边。证毕。

延伸:这个"摘叶子"的思路也是很多树形算法的雏形 —— 反复剥掉叶子,最后剩下的 1 个或 2 个结点就是树的重心 / 直径的中点。

易错:归纳时只断定“摘掉叶子后剩下仍是一棵树”,却没说清连通性和无环性为什么保持不变;另外要先说明有限树中必存在叶子。

C3综合编程

用"孩子表示法 + 双亲数组"的组合:判断 u 是否为 v 的祖先,并输出 u 到 v 的路径(含 LCA)。

查看参考答案
C++c4.cpp — 判祖先与求路径
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 双亲数组 + 深度,判断祖先关系并输出两点间的路径
const int MAXN = 105;
int fa[MAXN], dep[MAXN];
int n;

bool isAnc(int u, int v) {          // u 是否为 v 的祖先(或就是 v 本身)
    while (v != 0) {                // 根的双亲是 0,一直往上跳
        if (v == u) return true;
        v = fa[v];
    }
    return false;
}

int main() {
    int root = 0;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> fa[i];
        if (fa[i] == 0) root = i;
    }
    for (int i = 1; i <= n; i++) {  // 逐个往上数,得到每个结点的深度
        int x = i, d = 1;
        while (fa[x] != 0) { d++; x = fa[x]; }
        dep[i] = d;
    }
    cout << "根:" << root << endl;

int q; cin >> q;
    while (q--) {
        int u, v; cin >> u >> v;
        cout << u << " 是 " << v << " 的祖先?" << (isAnc(u, v) ? "Yes" : "No") << endl;
    }

int u, v; cin >> u >> v;        // 最后再给一对,输出它们之间的路径
    int a = u, b = v;
    while (dep[a] > dep[b]) a = fa[a];     // 深的先往上跳,拉到同一层
    while (dep[b] > dep[a]) b = fa[b];
    while (a != b) { a = fa[a]; b = fa[b]; }  // 再同步上跳,相遇处即 LCA
    int lca = a;

vector<int> path;
    int x = v;
    while (x != lca) { path.push_back(x); x = fa[x]; }
    path.push_back(lca);
    reverse(path.begin(), path.end());

cout << "LCA(" << u << "," << v << ") = " << lca << endl;
    cout << u << " 到 " << v << " 的路径:";
    for (int i = 0; i < (int)path.size(); i++) cout << path[i] << " ";
    cout << endl;
    return 0;
}
输入
8
0 1 1 1 2 2 4 6
3
2 8
4 8
1 6
2 8
运行结果
根:1
2 是 8 的祖先?Yes
4 是 8 的祖先?No
1 是 6 的祖先?Yes
LCA(2,8) = 2
2 到 8 的路径:2 6 8

核心技巧:求 LCA 时,先让深度大的那个往上跳,直到两者深度相同;再同步上跳,相遇处即为最近公共祖先。这是最朴素的 LCA 写法,时间 O(深度)。

为什么必须两个数组:dep[] 用于比较深度,fa[] 用于往上跳 —— 这正是模块二"实战组合"那一栏说的:建树时顺手记下 fa,向上向下都畅通。

说明:LCA 的高效解法(倍增、Tarjan)属于提高级内容,此处只要求掌握朴素写法。

思路:用双亲数组配合深度数组:先把 u、v 各自向上爬到同一深度,再同步向上走,第一次相遇的结点就是 LCA;沿途把经过的结点记录下来即为两点间的路径。

易错:忘了先把较深的那个结点“对齐”到相同深度,导致两个指针永远错位、相遇点判断失败。

C4分析题

一棵度为 m、有 n 个结点的树,其最小深度是多少?以 n = 10、m = 3 为例计算,并说明什么时候取到最小值。

查看参考答案

公式:深度为 h 的 m 叉树最多有 (mh - 1) / (m - 1) 个结点。要让深度最小,就要让每层都尽可能装满,于是找满足 (mh - 1)/(m - 1) ≥ n 的最小 h。

代入 n = 10、m = 3:

h = 2:(3² - 1)/2 = 4 < 10,不够;

h = 3:(3³ - 1)/2 = 13 ≥ 10,够了。

答案:最小深度 = 3。

什么时候取到:当树"长得最胖"时 —— 除最后一层外,每个结点都长满 m 个孩子。反之,若每个结点只有 1 个孩子,树退化成一条链,深度达到最大值 n。

结论:n 个结点的树,深度范围大致是 ⌈logm(n(m-1)+1)⌉ ≤ h ≤ n。树越胖越矮,越瘦越高。

思路:深度最小就是让每一层尽量长满孩子:深度 h 的 m 叉树最多有 (m^h - 1)/(m - 1) 个结点,取使该值 ≥ n 的最小 h 即可。

易错:把最大结点数公式 (m^h-1)/(m-1) 记成 m^h;另外要算清 n=10、m=3 时 h=2 只有 4 个结点,不够用。

🏆洛谷原题精选(20道)

以下题目均以树的存储与遍历为核心考点,按难度从易到难排列。建议在洛谷官网提交代码评测。

1B3642二叉树的遍历普及-
2P1305新二叉树普及-
3P4913【深基16.例3】二叉树深度普及-
4P1030求先序排列普及-
5P1827[USACO3.4] 美国血统 American Heritage普及-
6P3884[JLOI2009] 二叉树问题普及-
7P1229遍历问题普及-
8P1364医院设置普及/提高-
9P2420让我们异或吧普及/提高-
10P1122最大子树和普及/提高-
11P5076【深基16.例7】普通二叉树(简化版)普及/提高-
12P2014[CTSC1997] 选课普及+/提高-
13P1395会议普及+/提高
14P2015二叉苹果树普及/提高-
15P5836[USACO19DEC] Milk Visits S提高-
16P1352没有上司的舞会提高-
17P5018对称二叉树提高-
18P2052[NOI2011] 道路修建提高-
19P3379【模板】最近公共祖先(LCA)提高+/省选-
20P2664树上游戏提高+/省选-
🔑 刷题建议

① 先做入门题(B3642、P1305、P4913),把"用数组存下一棵树"这件事练到不假思索

② 再做普及- 题(P1030、P1827、P1229),熟悉"由两种遍历序列还原树"这类经典推导

③ P1364 医院设置、P1395 会议、P2014 选课是普通树(多叉树)的绝佳练习,必须用邻接表存树再做 BFS/DFS

④ 第 15~20 题属于树形 DP 与 LCA(提高级)内容,CSP-J 阶段可以先跳过