CSP-S 提高级课程中心 / 数据结构·线性与特殊树 / 第14讲
CSP-S 提高级 · 数据结构·线性与特殊树 · s3-05

树的孩子兄弟表示法:给每棵树配上 左儿子右兄弟 的骨架

任意多叉树都能"二叉化"成两个指针域——左边连儿子,右边连兄弟。这一步转换让树的存储、遍历与 DP 全部统一到二叉树模型上。

6 大模块 2 个指针搞定任意多叉树 先序=先序 · 中序=后序 2 处交互动画 12 道洛谷原题
本讲 · 第 14/52 讲树的孩子兄弟表示法
模块 01

情景导航

从「怎么把一张组织架构图存进电脑」出发,体会多叉树存储的两难,并遇见那个优雅的答案。

故事公司组织架构图

你新入职一家科技公司,HR 交给你一张组织架构图:总裁下面有 5 位副总裁,每位副总裁下面又有若干个部门经理,每个经理下面还有若干名员工……层级深浅不一,有的人手下几十号人,有的人一个兵也没有。

你的任务是把这张图存进程序,方便随时统计「某位领导手下一共有多少人」。

第一个方案:给每个节点开一个固定长度的儿子数组 son[u][5]——因为看起来最多的副总裁也就 5 个下属。

结果第二天组织架构调整,某位总监一口气划出 12 个小组,数组直接爆掉。而且如果开成 son[u][1000],全公司 10 万人,光儿子数组就要 10 亿个格子,内存直接炸。

第二个方案:用 vector<int> son[u] 变长数组。这确实解决了浪费,但 vector 有额外的容量、指针开销,节点一多常数就上来了;而且每个节点到底有几个儿子这件事,让遍历时的代码处处要判断边界。

就在你抓耳挠腮的时候,一位老工程师走过来,在白板上画了几笔:「每个节点只需要两个指针——第一个孩子是谁,以及下一个兄弟是谁。」

左边连「长子」,右边连「弟弟」——多叉树瞬间变成二叉树!

你惊讶地发现:存储空间从"不确定"变成了固定的两个指针;遍历代码统一成了二叉树的两分支形式;甚至连复杂的树上背包 DP,都能直接套用二叉树的写法。这就是本讲的主角——孩子兄弟表示法。

💡 故事背后的算法思想

① 多叉树的本质困难是「度不确定」。二叉树的每个节点儿子数固定为 2,可以用两个固定字段表示;而多叉树节点的儿子数可能从 0 到 n−1,用定长数组必然浪费或溢出。

② 换个角度看「兄弟」。与其在父节点里罗列所有儿子,不如让儿子们自己串成一条链——每个儿子只知道"我的下一个弟弟是谁"。这样父节点只需要记住长子,长子负责把弟弟们一个个串起来。

③ 两个指针就够。lc[u](左指针)指向 u 的第一个孩子;rs[u](右指针)指向 u 的下一个兄弟。空指针用 0 表示。整棵树被唯一地表示成一棵二叉树。

④ 更重要的是「保序」。这种转换保持了原树的访问顺序:二叉树先序遍历 = 原树先序遍历;二叉树中序遍历 = 原树后序遍历。所以转换不是"信息压缩",而是"换一种等价写法",遍历结果依然可还原。

①生活类比
📁

文件夹 = 多叉树

一个文件夹下可以有任意多个子文件,这正是「度不确定」的典型场景。

→ 多叉树
🎒

定长儿子数组 = 固定座位

给每人固定 5 个座位放孩子,来 12 个孩子就坐不下,来 0 个又白占座。

→ son[u][K] 浪费/溢出
🔗

孩子链 = 手拉手排队

兄弟们手拉手排成一队,父节点只要拽住队首,就能顺藤摸到每一个孩子。

→ rs[] 兄弟链
🌳

左儿子右兄弟 = 二叉化

两个指针:左手拉长子,右手拉弟弟,任意多叉树都能"折"成二叉树。

→ lc[] + rs[]
②核心定义

多叉树(k 叉树):每个节点可以拥有任意多个(或至多 k 个)孩子的树。由于节点的「度」不固定,无法像二叉树那样用固定字段直接表示。

孩子兄弟表示法(Child-Sibling Representation):每个节点只保留两个指针——lc[u] 指向 u 的第一个孩子(长子),rs[u] 指向 u 的下一个兄弟(右邻弟弟)。用 0(或 −1)表示空。

二叉化(树转二叉树):把用孩子兄弟表示法存下的结构直接画成一棵二叉树,左指针即 lc,右指针即 rs。这棵二叉树称为原树的孩子兄弟二叉树。

森林的孩子兄弟表示:把森林中各棵树的根,用 rs 指针依次串起来,得到一个统一的二叉树结构——这就是「森林 ↔ 二叉树」一一对应的原理。

遍历对应定理:设 T 为原树,B 为其孩子兄弟二叉树,则 B 的先序遍历 = T 的先序遍历(此时右指针 rs 相当于"继续访问兄弟",等价于原树中回到父节点后继续访问下一个孩子);B 的中序遍历 = T 的后序遍历。

📌 本讲核心知识地图

板块核心内容对应大纲
存储困境定长儿子数组浪费/溢出,vector 儿子表常数大【6】孩子兄弟表示法
孩子兄弟表示lc[u] = 长子,rs[u] = 下一个兄弟【6】孩子兄弟表示法
树→二叉树左孩子接到原长子,右孩子接原兄弟【6】孩子兄弟表示法
二叉树→树逆变换:左孩子还原为儿子,右孩子还原为兄弟【6】孩子兄弟表示法
遍历对应先序=先序,中序=后序【6】树的遍历
森林转换根的 rs 链把森林串成一棵二叉树拓展
树上背包借助 lc/rs 写出 O(n²) 的树形 DP 基础形式【6】树型动态规划
③核心口诀
🔑 结构口诀

左手指长子,右手指弟弟;
儿子串成链,父节点只认第一个。

🔑 转换口诀

树转二叉树:左连长子、右连兄弟,一根都不丢;
二叉树转树:左是儿、右是弟,父亲从右链的末端回想。

🔑 遍历口诀

二叉树先序等于原树先序,
二叉树中序等于原树后序——顺序不乱,结果可还原。

模块 02

知识探究

先算清多叉树存储的两难,再掌握孩子兄弟表示法与转换规则,最后用动画把"二叉化"过程看清楚。

1多叉树的存储困境 动机

假设一棵 n 个节点的多叉树,最多有 k 个儿子。三种常见存法各有代价:

存储方案空间优点致命缺点
邻接矩阵 son[n][n]O(n²)判断「u 是否为 v 的父亲」O(1)n=105 时需 1010 格,直接爆内存
定长数组 son[n][K]O(nK)结构简单,下标直接K 取小会溢出,取大会浪费;实际度往往远小于 K
vector 儿子表O(n)空间自适应,无浪费每个 vector 有容量/指针开销,节点多时常数偏大;代码处处要判 size
孩子兄弟(lc/rs)O(n)固定 2 个 int,常数最小;结构统一成二叉树找「所有儿子」需沿 rs 链遍历,不能随机访问

📐 定长数组的浪费到底有多大?

设 n = 105 个节点,实际最大度为 30,但为了保险取 K = 1000。

定长数组需 105 × 1000 = 108 个 int = 400 MB,远超 256 MB 限制。

若取 K = 30 恰好放下,则需 12 MB,看起来很美好 —— 但只要数据里出现一个度为 31 的节点,程序立刻越界 RE。

孩子兄弟表示法只需 2 × 105 个 int = 0.8 MB,而且与"最大度"完全无关,无论度怎么变都不会溢出。这就是它成为树形 DP 首选存储方式的原因。

❌ 定长数组 son[u][K]

K 是"猜"出来的,猜小了 RE,猜大了 MLE

空间 O(nK),与真实结构无关

删除/添加孩子时数组会出现空洞

✅ 孩子兄弟 lc[u] / rs[u]

空间恒为 O(n),与度无关

结构等价于一棵二叉树,遍历代码统一

支持 O(1) 增删"第一个孩子"(头插)

2孩子兄弟表示法:左儿子右兄弟 核心

规定:每个节点 u 只有两个指针。

1
👈
lc[u] 左指针

指向 u 的第一个孩子(长子);没有孩子则为 0

2
👉
rs[u] 右指针

指向 u 的下一个兄弟;没有弟弟则为 0

3
🧩
等价结构

把 lc 当左孩子、rs 当右孩子,整棵树就是一棵二叉树

🔑 为什么「父节点只记长子」不丢信息?

因为所有孩子被 rs 指针串成了一条链:父节点记住长子 c1,长子记住弟弟 c2,c2 记住弟弟 c3……顺着这条 rs 链就能枚举全部孩子。

反过来,从任意孩子出发,如何找到父亲?沿 rs 链一路走到末端,末端节点的父亲就是它自己的父亲(因为在孩子兄弟表示中,兄弟链上的所有节点共享同一个父亲)。这是转换与还原中的关键技巧。

C++孩子兄弟表示法的数据结构与基本操作
const int MAXN = 100005;
int lc[MAXN], rs[MAXN];   // lc: 第一个孩子;rs: 下一个兄弟
int fa[MAXN];             // 可选:同时记录父亲,方便向上走

// ① 给 u 添加一个孩子 v(头插法,O(1))
void addChild(int u, int v) {
    rs[v] = lc[u];        // v 的弟弟是原来的长子
    lc[u] = v;            // u 的长子变为 v
    fa[v] = u;
}

// ② 枚举 u 的所有孩子:沿 rs 链走
void listChildren(int u) {
    for (int v = lc[u]; v != 0; v = rs[v])
        printf("%d ", v);
}

// ③ 求 u 的父亲:沿 rs 链走到末端,末端的 fa 就是 u 的父
int getFather(int u) {
    while (rs[u] != 0) u = rs[u];   // 走到兄弟链尾部
    return fa[u];
}

// ④ 计算子树的节点数(原树意义下的子树)
int subtreeSize(int u) {
    if (u == 0) return 0;
    int cnt = 1;                         // 自己
    for (int v = lc[u]; v != 0; v = rs[v])
        cnt += subtreeSize(v);              // 每个孩子的整棵子树
    return cnt;
}
注意:实际做题时通常会在「孩子兄弟」之外额外保存 fa[],因为有了 fa 就能向上走,写 LCA、树上差分更方便。孩子兄弟表示法负责"向下",fa 负责"向上",两者互补。
3树与二叉树的相互转换 图解

转换规则只有两条,但必须记牢方向:

🌳 树 → 二叉树
  • 加线:在所有相邻兄弟之间连一条线
  • 删线:每个节点只保留与第一个孩子的连线
  • 整理:新加的兄弟连线作为该节点的右孩子
  • 口诀:左连长子,右连兄弟
🌲 二叉树 → 树
  • 若某节点是其父的左孩子,则它原来是父的儿子
  • 若某节点是其父的右孩子,则它与父原本是兄弟
  • 把所有"右孩子关系"改画成"兄弟关系"即还原
  • 口诀:左是儿、右是弟
1 2 3 4 5 6 7 8 原树 T
图 1 · 一棵三叉多叉树
  • 1 的孩子:2、3、4(兄弟关系)
  • 2 的孩子:5、6;3 的孩子:7;4 无孩子
  • 5 的孩子:8
  • 共 8 个节点,最大度为 3
  • 原树先序:1,2,5,8,6,3,7,4
  • 原树后序:8,5,6,2,7,3,4,1
图 1 · 本讲全程使用的样例树。请把它的先序与后序记在草稿纸上,后面验证转换结果时要用。
🔄 动画:这棵三叉树如何一步步二叉化
1
第 1 步 · 加线。在所有相邻兄弟之间连线:2—3、3—4、5—6。这些线表示"兄弟关系"(它们共享同一个父亲)。
2
第 2 步 · 删线。对每个父亲,只保留它与第一个孩子的连线,删除到其余孩子的线。于是 1–3、1–4 被删除,保留 1–2;2–6 被删除,保留 2–5。节点 3、4 现在只通过兄弟线连着 2。
3
第 3 步 · 整理。把保留下来的"父子线"画成左孩子,把"兄弟线"画成右孩子,得到二叉树 B。
4
结果:B 的结构为 —— 1.lc=2;2.lc=5, 2.rs=3;3.lc=7, 3.rs=4;5.lc=8, 5.rs=6。其余节点 lc=rs=0。
5
验证先序:B 先序遍历 1→2→5→8→6→3→7→4,与图 1 中原树的先序完全一致。✓
6
验证中序:B 中序遍历 8→5→6→2→7→3→4→1,与图 1 中原树的后序完全一致。✓ 转换是保序的、可逆的。
右指针 rs 的语义是"跳过当前子树、回到父节点继续下一个孩子",这正是原树先序/后序能在二叉树上重现的原因。
C++树 → 二叉树的转换(多叉树邻接表 → 孩子兄弟)
// 输入:n 个节点,每行给出 u 的儿子列表(以 0 结束)
// 输出:lc[] / rs[] 两个数组
const int MAXN = 100005;
int lc[MAXN], rs[MAXN], fa[MAXN];

void build() {
    int n; scanf("%d", &n);
    for (int u = 1; u <= n; u++) {
        int v, prev = 0, first = 0;
        while (scanf("%d", &v) && v != 0) {
            fa[v] = u;
            if (prev == 0) first = v;   // 记下长子
            else rs[prev] = v;             // 弟弟链:前一个的右指针指向当前
            prev = v;
        }
        lc[u] = first;                     // 父亲只认长子
    }
}
关键点:读入儿子列表时用 prev 变量把儿子们串成 rs 链;父亲节点最后只记录 first。这样构造是 O(总儿子数) = O(n) 的。
4遍历的对应关系 必考
先序 = 先序

二叉树 B 的先序遍历序列,与原树 T 的先序遍历完全相同。

序列:1 2 5 8 6 3 7 4

中序 = 后序

二叉树 B 的中序遍历序列,与原树 T 的后序遍历完全相同。

序列:8 5 6 2 7 3 4 1

🔍 为什么是这两个对应?

先序对应:二叉树的先序访问顺序是「访问自己 → 进左子树(= 长子)→ 进右子树(= 下一个兄弟)」。而原树先序也是「访问自己 → 访问长子子树 → 访问下一个兄弟子树」。两者完全同步。

中序对应:二叉树的中序是「先走完左子树,再访问自己,再走右子树」。对 u 来说,「走完左子树」= 访问完 u 的长子那一整棵子树;此时访问 u 意味着"u 的所有孩子都处理完了",这正是后序的定义;之后再走右子树 = 继续访问 u 的下一个兄弟。

反过来的价值:如果要把一棵多叉树转成二叉树后做先序遍历,那么直接套二叉树先序代码即可,不需要改任何逻辑;而"求子树大小""求深度"这类需要在孩子处理完后才计算的信息,用 B 的中序(= 原树后序)位置就能排好顺序——这就是树上 DP 按后序处理的来源。

遍历方式原树 T孩子兄弟二叉树 B是否对应
先序(根左右)1 2 5 8 6 3 7 41 2 5 8 6 3 7 4完全一致 ✓
中序(左根右)无此概念8 5 6 2 7 3 4 1等于 T 的后序 ✓
后序(左右根)8 5 6 2 7 3 4 18 6 5 7 4 3 2 1不等(B 后序无直接对应)
层序(BFS)1 2 3 4 5 6 7 81 2 5 3 8 6 7 4不一致(B 的"孩子"是长子+兄弟链,层与层交错)
提醒:真正需要背的只有先序=先序、中序=后序这两条。层序通常不一致——二叉化把"下一层的兄弟"提到了与"当前孩子"同层,BFS 顺序自然被打乱。
5左儿子右兄弟在树形 DP 中的应用 进阶

树形 DP 是最能体现孩子兄弟表示法价值的地方。经典问题:树上背包——每个节点有体积 w[i] 和价值 v[i],选出的节点集合必须闭合(选子必选父),求容量 m 下的最大价值。

🔀 两种写法对比

写法一 普通 vector 儿子表 + 树上背包合并:对每个节点做「子树背包合并」,理论复杂度 O(n·m²),树是链时退化成 O(n·m²) 但常数大,实际常用「子树大小上界」优化到 O(n·m)。缺点:代码要处理 vector 迭代与边界。

写法二 孩子兄弟表示 + 双状态 DP:把问题转成一棵二叉树上做「选左(取长子)或选右(跳兄弟)」,状态 f[u][j] 表示「以 u 为根的兄弟森林里,已用 j 容量」的最大价值,转移只有两条。

写法二的转移方程:

f[u][j] = max( f[rs[u]][j],   max over k≥0 { f[lc[u]][j−w[u]−k] + f[rs[u]][k] + v[u] } )

含义:要么不选 u(跳到兄弟 rs[u]),要么选 u(先扣掉 w[u] 并拿到 v[u],然后把剩余容量分给「长子子树」和「兄弟链」两部分)。

复杂度:状态 O(n·m),每次转移 O(m),总 O(n·m²);配合「兄弟链长度上界」优化可到 O(n·m)。

C++树上背包 · 孩子兄弟表示 + 记忆化
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 305;
int lc[MAXN], rs[MAXN], w[MAXN], v[MAXN];
int n, m;
int f[MAXN][MAXN];          // f[u][j]: 以 u 为根的兄弟森林容量 j 的最优值

int dfs(int u, int j) {
    if (u == 0 || j <= 0) return 0;
    if (f[u][j] != -1) return f[u][j];
    // 选项一:不选 u,直接跳到下一个兄弟
    int best = dfs(rs[u], j);
    // 选项二:选 u,需要先扣掉 w[u]
    if (j >= w[u]) {
        int rem = j - w[u];
        for (int k = 0; k <= rem; k++) {   // 剩余容量切给长子/兄弟
            best = max(best, v[u] + dfs(lc[u], rem - k) + dfs(rs[u], k));
        }
    }
    return f[u][j] = best;
}

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%d%d", &w[i], &v[i]);
    // 建树:父亲 i/2 的孩子是 i(此处以完全二叉树父亲为例)
    for (int i = n; i >= 1; i--) {
        int p = i / 2;
        if (p >= 1) { rs[i] = lc[p]; lc[p] = i; }
    }
    memset(f, -1, sizeof(f));
    printf("%d\n", dfs(1, m));        // 必须从根 1 开始(含"选根"决策)
    return 0;
}
运行结果
输入: 5 10
      2 6
      2 3
      3 5
      4 8
      3 4
输出: 18

说明: 树结构(父 = i/2):1 为根;2、3 是 1 的孩子;4、5 是 2 的孩子。
      闭合子集(选子必选父)中体积 ≤10 的最优为选 1、2、3、5:
      体积 2+2+3+3=10,价值 6+3+5+4=18。
      其余候选:{1,2,4}=17、{1,3}=11、{1,2,3}=14 均更差。
      (若误写成 dfs(lc[1], m) 从根的长子出发,会漏掉"选根"决策,
        实测只输出 16 —— 闭合约束下任何选择都必须先选根。)
本案例的重点不是答案数值,而是转移结构:孩子兄弟表示把「枚举所有孩子做背包合并」变成了「左子树 + 右兄弟」的两段分配,代码量减少一半以上。这正是它在树形 DP 中被广泛使用的原因。
案例案例1:读入多叉树并建立孩子兄弟表示 基础

题目:第一行给出节点数 n(节点编号 1..n)。第 2 行开始给出 n 行,第 i 行给出节点 i 的所有儿子编号,以 0 结束。请建立孩子兄弟表示,并按原树的先序遍历输出所有节点。

🔍 详细分析过程

第 1 步:读入时把儿子们串成 rs 链,父亲只记长子。

第 2 步:先序遍历用「遍历孩子链」的写法:for (v = lc[u]; v; v = rs[v]) dfs(v);。

第 3 步:注意根节点可能是 1,也可能需要通过 fa 判断(本题约定 1 是根)。

复杂度:O(n)。

C++建立孩子兄弟表示 + 原树先序 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int lc[MAXN], rs[MAXN], fa[MAXN];
vector<int> ans;

void preOrder(int u) {
    if (u == 0) return;
    ans.push_back(u);                        // 访问自己
    for (int v = lc[u]; v != 0; v = rs[v])
        preOrder(v);                          // 依次访问每个孩子
}

int main() {
    int n;
    scanf("%d", &n);
    for (int u = 1; u <= n; u++) {
        int v, prev = 0;
        while (scanf("%d", &v) && v != 0) {
            fa[v] = u;
            if (prev == 0) lc[u] = v;   // 第一个儿子当长子
            else rs[prev] = v;             // 后续儿子接到弟弟链
            prev = v;
        }
    }
    int root = 1;
    for (int i = 1; i <= n; i++) if (fa[i] == 0) { root = i; break; }
    preOrder(root);
    for (size_t i = 0; i < ans.size(); i++)
        printf("%d%c", ans[i], i + 1 == ans.size() ? '\n' : ' ');
    return 0;
}
运行结果
输入: 8
      2 3 4 0
      5 6 0
      7 0
      0
      8 0
      0
      0
      0
输出: 1 2 5 8 6 3 7 4

💡 验证结构

lc[1]=2, rs[2]=3, rs[3]=4(1 的三个孩子串成 2→3→4)

lc[2]=5, rs[5]=6(2 的两个孩子)

lc[3]=7;lc[5]=8;其余为 0

先序输出 1 2 5 8 6 3 7 4,正是图 1 样例树的先序。✓

案例案例2:树转二叉树的输出验证 验证

题目:接上题,输出转换后二叉树的先序与中序序列,验证它们分别等于原树的先序与后序。

🔍 分析

二叉树先序:visit(u); pre(lc[u]); pre(rs[u]);

二叉树中序:in(lc[u]); visit(u); in(rs[u]);

如果是原树后序,则应为:for each child: post(v); visit(u);

把三者输出对比即可验证 先序=先序、中序=后序 两条定理。

C++三种遍历同时输出并对比
void preB(int u) {          // 二叉树先序
    if (!u) return;
    printf("%d ", u);
    preB(lc[u]); preB(rs[u]);
}

void inB(int u) {           // 二叉树中序
    if (!u) return;
    inB(lc[u]);
    printf("%d ", u);
    inB(rs[u]);
}

void postT(int u) {         // 原树后序
    if (!u) return;
    for (int v = lc[u]; v; v = rs[v]) postT(v);
    printf("%d ", u);
}

// 输出对比:
// 二叉树先序: 1 2 5 8 6 3 7 4   = 原树先序 ✓
// 二叉树中序: 8 5 6 2 7 3 4 1   = 原树后序 ✓
// 原树后序  : 8 5 6 2 7 3 4 1   = 二叉树中序 ✓
运行结果
二叉树先序: 1 2 5 8 6 3 7 4
二叉树中序: 8 5 6 2 7 3 4 1
原树  后序: 8 5 6 2 7 3 4 1
原树  先序: 1 2 5 8 6 3 7 4

结论: 二叉树先序 == 原树先序 ✓
      二叉树中序 == 原树后序 ✓
案例案例3:用孩子兄弟表示统计子树大小 应用

题目:给定一棵 n 个节点的多叉树(1 号为根),求每个节点的子树大小(包含自身)。n ≤ 105。

🔍 两种实现对比

方法一 递归「枚举孩子链」:sz[u] = 1 + Σ sz[v](v 遍历 rs 链)。写法最直观,与孩子兄弟结构天然契合。

方法二 转二叉树后「一次 DFS 求两值」:注意到 sz[u] 在二叉树里等于「左子树规模 + 1」(右子树是兄弟,不属于 u 的子树)。这意味着可以只递归一次同时算出「以 u 为根的二叉树子树大小」,再相减得到所需信息——更快但需仔细推导,容易写错。

结论:入门用方法一,代码短且不易错;追求极致常数可用方法二。

C++子树大小统计 — 方法一(推荐)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int lc[MAXN], rs[MAXN], sz[MAXN];

int dfs(int u) {
    if (u == 0) return 0;
    int tot = 1;                                 // 自己
    for (int v = lc[u]; v != 0; v = rs[v])
        tot += dfs(v);                              // 加上每个孩子的整棵子树
    return sz[u] = tot;
}

int main() {
    int n;
    scanf("%d", &n);
    for (int u = 1; u <= n; u++) {
        int v, prev = 0;
        while (scanf("%d", &v) && v != 0) {
            if (prev == 0) lc[u] = v; else rs[prev] = v;
            prev = v;
        }
    }
    dfs(1);
    for (int i = 1; i <= n; i++) printf("sz[%d]=%d\n", i, sz[i]);
    return 0;
}
运行结果
输入: 8
      2 3 4 0
      5 6 0
      7 0
      0
      8 0
      0
      0
      0
输出: sz[1]=8  sz[2]=4  sz[3]=2  sz[4]=1
      sz[5]=2  sz[6]=1  sz[7]=1  sz[8]=1

💡 为什么 sz[2]=4?

节点 2 的子树 = {2, 5, 6, 8},共 4 个。其中 5 的孩子是 8,6 无孩子。

递归过程:dfs(2) = 1 + dfs(5) + dfs(6) = 1 + (1 + dfs(8)) + 1 = 1 + 2 + 1 = 4。✓

注意循环里的 v = rs[v] 正是"沿兄弟链枚举孩子",这是孩子兄弟表示法的标志性写法。

1
自底向上:叶子先归位 —— sz[8]=1、sz[4]=1、sz[6]=1、sz[7]=1(无孩子的节点子树就是自己)。
2
sz[5] = 1 + sz[8] = 2(5 的孩子只有 8)。
3
sz[2] = 1 + sz[5] + sz[6] = 1 + 2 + 1 = 4 —— 沿 rs 链 5→6 把两个孩子依次累加。
4
sz[3] = 1 + sz[7] = 2;sz[4] = 1 已就绪。
5
根节点收网:sz[1] = 1 + sz[2] + sz[3] + sz[4] = 1 + 4 + 2 + 1 = 8 —— 全树 8 个节点 ✓。
每一步都用样例树真实数值,与运行结果 sz[1]=8 sz[2]=4 sz[3]=2 sz[4]=1 sz[5]=2 完全一致。
模块 03

实践应用

三道完整题目,把「建树 → 遍历 → 统计」的完整链路走一遍。

题1文件系统目录统计
题 1孩子兄弟表示 · 遍历

题目背景

某文件系统的目录结构是一棵多叉树。给出每个目录的子目录列表,要求输出这棵树的原树先序与原树后序遍历序列。

输入格式

第 1 行一个整数 n(1 ≤ n ≤ 105),表示目录总数,编号 1..n(1 号是根目录)。接下来 n 行,第 i 行给出目录 i 的所有子目录编号,以 0 结束。

输出格式

共两行:第 1 行为先序遍历序列,第 2 行为后序遍历序列,数字之间用一个空格分隔。

输入样例

8
2 3 4 0
5 6 0
7 0
0
8 0
0
0
0

输出样例

1 2 5 8 6 3 7 4
8 5 6 2 7 3 4 1

数据范围

n ≤ 105;保证输入是一棵树(无环、连通、每个非根节点恰有一个父亲)。1s / 256MB。

🔍 分析

建模:目录树天然是多叉树,用孩子兄弟表示存储,只需 lc/rs 两个数组。

先序:visit(u) → 遍历 lc[u] 开始的兄弟链。

后序:先遍历整条兄弟链上的每个孩子(递归),最后再 visit(u)。

常见错误 后序遍历时把 visit(u) 写在循环里(每访问一个孩子就输出一次 u),导致 u 被重复输出。正确写法是循环结束后再输出 u。

复杂度:每个节点恰好被访问常数次,O(n),n=105 无压力。

C++文件系统目录统计 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int lc[MAXN], rs[MAXN];

void preOrder(int u) {
    if (!u) return;
    printf("%d ", u);
    for (int v = lc[u]; v; v = rs[v]) preOrder(v);
}

void postOrder(int u) {
    if (!u) return;
    for (int v = lc[u]; v; v = rs[v]) postOrder(v);  // 先处理所有孩子
    printf("%d ", u);                                    // 最后才输出自己
}

int main() {
    int n;
    scanf("%d", &n);
    for (int u = 1; u <= n; u++) {
        int v, prev = 0;
        while (scanf("%d", &v) && v != 0) {
            if (prev == 0) lc[u] = v; else rs[prev] = v;
            prev = v;
        }
    }
    preOrder(1);  printf("\n");
    postOrder(1); printf("\n");
    return 0;
}
运行结果
输入: 8
      2 3 4 0
      5 6 0
      7 0
      0
      8 0
      0
      0
      0
输出: 1 2 5 8 6 3 7 4
      8 5 6 2 7 3 4 1
题2公司人数统计(子树大小 + 最大值)
题 2树形统计

题目背景

给定公司的组织架构树(1 号为总裁),要求回答:① 每位管理者手下一共有多少人(含自己);② 找出「手下人数最多」的管理者中编号最小的那个。

输入格式

第 1 行一个整数 n(1 ≤ n ≤ 105)。接下来 n 行,第 i 行给出员工 i 的直接下属编号,以 0 结束。

输出格式

共 n 行,第 i 行为 sz[i];最后一行输出手下最多的管理者编号。

输入样例

8
2 3 4 0
5 6 0
7 0
0
8 0
0
0
0

输出样例

8
4
2
1
2
1
1
1
1

数据范围

n ≤ 105;sz 值最大为 n,int 足够。1s / 256MB。

🔍 分析

第一步:建树(孩子兄弟表示)。

第二步:后序 DFS 求子树大小:sz[u] = 1 + Σ sz[v]。

第三步:遍历一遍找最大值。若 sz 最大的有多个,取编号最小的——用 if (sz[i] > best || (sz[i] == best && i < bestId)) 即可保证取到最小编号。

关键:「取编号最小」这个细节一定要按 i 从小到大遍历并只在严格大于时更新 bestId,否则会取到最大的编号。

C++公司人数统计 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int lc[MAXN], rs[MAXN], sz[MAXN];

void dfs(int u) {
    sz[u] = 1;
    for (int v = lc[u]; v; v = rs[v]) {
        dfs(v);                  // 先把孩子算完
        sz[u] += sz[v];
    }
}

int main() {
    int n;
    scanf("%d", &n);
    for (int u = 1; u <= n; u++) {
        int v, prev = 0;
        while (scanf("%d", &v) && v != 0) {
            if (prev == 0) lc[u] = v; else rs[prev] = v;
            prev = v;
        }
    }
    dfs(1);
    int best = -1, bestId = 1;
    for (int i = 1; i <= n; i++) {
        printf("%d\n", sz[i]);
        if (sz[i] > best) { best = sz[i]; bestId = i; }  // 严格大于才更新 → 取最小编号
    }
    printf("%d\n", bestId);
    return 0;
}
运行结果
输出: 8 4 2 1 2 1 1 1
      1

说明: sz[1]=8 最大,编号 1,输出 1。
题3多叉树转二叉树(转换 + 验证)
题 3转换 · 综合

题目背景

给定一棵多叉树,输出它转换成的二叉树,格式为「每个节点两个整数:该节点的左孩子编号、右孩子编号(0 表示空)」,按编号 1..n 顺序每行一个节点。并在最后一行输出「该二叉树的中序序列」以验证它等于原树后序。

输入格式

第 1 行一个整数 n(1 ≤ n ≤ 105)。接下来 n 行,第 i 行给出节点 i 的儿子列表,以 0 结束。

输出格式

前 n 行,第 i 行为 lc[i] rs[i];最后一行为二叉树的中序序列。

输入样例

8
2 3 4 0
5 6 0
7 0
0
8 0
0
0
0

输出样例

2 0
5 3
7 4
0 0
8 6
0 0
0 0
0 0
8 5 6 2 7 3 4 1

数据范围

n ≤ 105;保证输入为一棵以 1 为根的多叉树。递归深度最坏 O(n),n 很大时建议改用显式栈或加栈大小。1s / 256MB。

🔍 分析

转换本质:建树时把儿子串成 rs 链,就已经完成了转换——lc[u] 就是二叉树的左孩子,rs[u] 就是二叉树的右孩子。

所以本题只需:① 建树;② 直接输出 lc/rs 数组;③ 对 lc/rs 做标准二叉树中序遍历。

这说明了什么?孩子兄弟表示法同时就是二叉树表示法——不需要额外的转换代码。这正是它优雅的地方。

注意事项 二叉树中序递归时,左右两棵子树都可能是编号更大的节点,不要想当然以为"左小右大"(这不是二叉搜索树!)。

C++多叉树转二叉树 + 中序验证 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int lc[MAXN], rs[MAXN];

void inOrder(int u) {           // 标准二叉树中序
    if (!u) return;
    inOrder(lc[u]);
    printf("%d ", u);
    inOrder(rs[u]);
}

int main() {
    int n;
    scanf("%d", &n);
    for (int u = 1; u <= n; u++) {
        int v, prev = 0;
        while (scanf("%d", &v) && v != 0) {
            if (prev == 0) lc[u] = v; else rs[prev] = v;
            prev = v;
        }
    }
    for (int i = 1; i <= n; i++) printf("%d %d\n", lc[i], rs[i]);
    inOrder(1);
    printf("\n");
    return 0;
}
运行结果
2 0
5 3
7 4
0 0
8 6
0 0
0 0
0 0
8 5 6 2 7 3 4 1

结论: 中序序列 8 5 6 2 7 3 4 1 == 原树后序 ✓

💡 从 lc/rs 表读树

① 的左孩子是 ②,右孩子是 0 → ① 是根。

② 的左孩子 ⑤,右孩子 ③ → ③ 是 ② 的弟弟。

③ 的左孩子 ⑦,右孩子 ④ → ④ 是 ③ 的弟弟。

⑤ 的左孩子 ⑧,右孩子 ⑥ → ⑥ 是 ⑤ 的弟弟。

规律:纵向(左)关系 = 父子;横向(右)关系 = 兄弟。一眼就能看出原树结构。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心复杂度
孩子兄弟表示lc[u]=第一个孩子,rs[u]=下一个兄弟空间 O(2n)
枚举所有孩子for (v = lc[u]; v; v = rs[v])O(度数)
找父亲沿 rs 链走到末端,末端的 fa 即为父O(度数)
头插加儿子rs[v] = lc[u]; lc[u] = v;O(1)
树 → 二叉树左连长子、右连兄弟;建树时即完成O(n)
二叉树 → 树左孩子 = 儿子,右孩子 = 兄弟O(n)
森林 → 二叉树各树根的 rs 依次相连O(n)
遍历对应B 先序 = T 先序;B 中序 = T 后序O(n)
子树大小后序 DFS:sz[u] = 1 + Σ sz[child]O(n)
树上背包二叉树上双状态转移,O(n·m²) 起见拓展
C++万能模板 · 孩子兄弟表示法(背下来)
const int MAXN = 100005;
int lc[MAXN], rs[MAXN], fa[MAXN];
int n;

// ===== 建树:从「每个节点的儿子列表」构造孩子兄弟 =====
void build() {
    scanf("%d", &n);
    for (int u = 1; u <= n; u++) {
        int v, prev = 0;
        while (scanf("%d", &v) && v != 0) {
            fa[v] = u;
            if (!prev) lc[u] = v;    // 首个儿子 → 长子
            else rs[prev] = v;        // 其余 → 弟弟链
            prev = v;
        }
    }
}

// ===== 原树先序 =====
void preOrder(int u) {
    if (!u) return;
    printf("%d ", u);
    for (int v = lc[u]; v; v = rs[v]) preOrder(v);
}

// ===== 原树后序 =====
void postOrder(int u) {
    if (!u) return;
    for (int v = lc[u]; v; v = rs[v]) postOrder(v);
    printf("%d ", u);
}

// ===== 子树大小 =====
int sz[MAXN];
int getSize(int u) {
    if (!u) return 0;
    int t = 1;
    for (int v = lc[u]; v; v = rs[v]) t += getSize(v);
    return sz[u] = t;
}

// ===== 二叉树视角的两种遍历(用于验证对应关系)=====
void preB(int u) { if(!u) return; printf("%d ",u); preB(lc[u]); preB(rs[u]); }
void inB(int u)  { if(!u) return; inB(lc[u]); printf("%d ",u); inB(rs[u]); }
②拓展案例
拓1森林转二叉树(多棵树的统一表示)

题意:给定一个森林(k 棵互不相交的多叉树),把它转换成一棵二叉树。

思路:把每棵树的根节点,用 rs 指针依次串成一条链——虚拟出一个"超级父亲",它的所有孩子就是各棵树的根。

于是「森林 ↔ 二叉树」建立了一一对应:任意森林都能唯一表示为一棵二叉树,反之亦然。

这解释了为什么「树的计数问题」在组合数学里可以用二叉树的结果:n 个节点的森林与 n 个节点的二叉树一一对应。

应用:多组测试数据的树、并查集的森林、森林的遍历。

C++森林转二叉树
// 各棵树的根:root[1..k],把它们串成一条 rs 链
void forestToBinary(int roots[], int k) {
    for (int i = 1; i < k; i++)
        rs[roots[i]] = roots[i + 1];   // 第 i 棵树的根 → 指向第 i+1 棵
    rs[roots[k]] = 0;
}
// 遍历时从 roots[1] 出发,二叉树的"右子树"就是后续的树
运行结果
输入森林: 树A(根1: 2,3) 树B(根4: 5)
转换结果: 1.lc=2, 2.rs=3, 3.rs=4, 4.lc=5
二叉树先序: 1 2 3 4 5
森林先序  : 1 2 3 | 4 5  → 1 2 3 4 5  ✓ 一致
拓2用孩子兄弟表示求树的高度与直径

题意:给定一棵多叉树,求它的高度(根到最深叶子的节点数)与直径(最长路径的边数)。

高度:h[u] = 1 + max{ h[v] | v 是 u 的孩子 },叶子 h = 1。用孩子链枚举孩子即可。

直径(两遍 DFS 法):从任意点(比如根)出发 DFS,找到距离最远的点 a;再从 a 出发 DFS,找到距离最远的点 b,则 dist(a,b) 就是直径。这个结论对树(无环连通图)恒成立,与是否是二叉树无关。

为什么孩子兄弟表示在这里也方便?因为枚举孩子的写法统一,只需求「最大深度」和「次大深度」两个值即可。

C++树的高度 + 直径
int h[MAXN], diameter = 0;

void dfs(int u) {
    int mx1 = 0, mx2 = 0;          // 孩子中最大/次大的子树高度
    for (int v = lc[u]; v; v = rs[v]) {
        dfs(v);
        if (h[v] > mx1) { mx2 = mx1; mx1 = h[v]; }
        else if (h[v] > mx2) mx2 = h[v];
    }
    h[u] = mx1 + 1;                    // 自身为叶时 h=1
    diameter = max(diameter, mx1 + mx2);  // 经过 u 的最长路径
}

// 一棵树的高度 = h[root],直径 = diameter(边数)
运行结果
样例树 (图1): 高度 = 4(节点数意义:1→2→5→8 共 4 层)
直径 = 5 条边(路径 8→5→2→1→3→7 或 6→5→2→1→3→7)。

核对: dfs 到根 1 时,孩子的最大子树高 mx1=3(2 的子树),
      次大 mx2=2(3 的子树),diameter = 3+2 = 5。✓
拓3树上背包:孩子兄弟表示的核心优势 提高

题意:n 个节点的树,每个节点有价值 v[i] 和体积 w[i]。选出一个包含根的闭合子集(选子必选父),在总体积 ≤ m 的前提下最大化总价值。n, m ≤ 300。

方法一(vector 儿子表 + 分组背包):对每个节点 u,把「每个孩子的子树」当作一组物品做背包合并。g[u][j] 表示 u 子树内用 j 体积的最大价值。合并孩子时是标准的「分组背包」。复杂度 O(n·m²)。

方法二(孩子兄弟 + 双状态):状态 f[u][j] = 「以 u 为根的兄弟森林,容量 j 的最优值」。转移只有两条:不选 u(跳到 rs[u]),或选 u(花掉 w[u],剩余容量在 lc[u] 子树与 rs[u] 兄弟间分配)。

方法二把「多路合并」变成了「两路分配」,转移式更统一,且天然支持「跳过整棵子树」的剪枝。这是孩子兄弟表示在 DP 中的最大价值。

C++树上背包 · 方法二精简版
int f[MAXN][MAXM];              // -1 表示未计算

int dp(int u, int j) {
    if (!u || j <= 0) return 0;
    if (f[u][j] != -1) return f[u][j];
    int best = dp(rs[u], j);                  // 不选 u
    if (j >= w[u]) {
        int rem = j - w[u];
        for (int k = 0; k <= rem; k++)
            best = max(best, v[u] + dp(lc[u], rem - k) + dp(rs[u], k));
    }
    return f[u][j] = best;
}
记忆锚点:孩子兄弟表示的 DP 永远是「左边是子树、右边是兄弟」的二选一或二分容量结构;它把「枚举所有孩子」的循环彻底消掉了。
③思考与启发

💭 思考 1:为什么孩子兄弟表示法不丢信息?「顺序」保住了吗?

查看参考答案

不丢信息的理由:原树中的每一对「父子关系」都对应一条 lc 链的入口;每一组「兄弟关系」都对应一条 rs 链。两种关系合起来恰好覆盖了原树的全部结构信息,且一一对应。

顺序保住了吗?保住了,前提是建树时按原顺序串 rs 链。如果建树时用「头插法」(rs[v]=lc[u]; lc[u]=v;),那么孩子的顺序会被反转。

结论:顺序信息保存在 rs 链的排列里。如果题目对孩子的顺序敏感(比如先序输出要求按输入顺序),建树时必须用尾插(记录 prev 依次接链);如果顺序无关(如求子树大小、求高度),头插尾插都行。

启发:数据结构的"保序性"是个容易被忽略的细节,很多 WA 都源于顺序被无意反转。

💭 思考 2:既然都能转成二叉树,为什么还要保留多叉树的遍历概念?

查看参考答案

因为两类操作各自方便。

① 多叉树视角「枚举所有孩子」很自然(沿 rs 链),求子树大小、深度、树上背包时逻辑清晰。

② 二叉树视角可以套用现成的二叉树算法模板(如前序/中序位置关系、左右子树规模),还能用「二叉树左右子树节点数」做区间划分。

典型例子:如果对多叉树做「子树区间赋值」这类操作,转成二叉树后就能利用「二叉树子树对应一段连续的 DFS 序」这一性质,用线段树维护。

启发:换个表示 = 换个视角。同一个对象用不同数据结构描述,能解锁不同的算法接口——这是信息学中非常核心的思维习惯。

💭 思考 3:孩子兄弟表示法中,右指针 rs 到底代表什么?

查看参考答案

字面上:rs[u] 指向 u 的下一个兄弟。

更本质地说:rs[u] 表示「u 这棵子树处理完了,回到父节点继续处理下一个孩子」。

这就是先序对应成立的原因:二叉树先序访问完 u 后,"进右子树"等价于原树中"u 的子树结束了,去访问 u 的下一个兄弟";至于 u 自己的孩子们,已经在左子树里全部访问完了。

从 DP 角度看:rs 相当于一条"兄弟链",它让 DP 天然可以表达"跳过 u 整棵子树"这个决策——这是树形 DP 中很难用 vector 儿子表简洁表达的。

启发:理解指针的语义,比记住结构图更重要。rs 的语义是「子树结束后的后继」,这是打通遍历与 DP 的关键。

模块 05

学习检测

先找错,再背口诀——把多叉树存储与转换的 4 个常见坑消灭在考试之前。

⚡高频错误诊断
易错1

把 lc 和 rs 的语义记反了

❌ 错误
写成「左指针连下一个兄弟、右指针连长子」,或者记成"左边兄弟右边儿子"。所有遍历结果全部错乱。
✅ 正确
左儿子、右兄弟——左边是儿子(向下),右边是兄弟(横向)。记忆锚点:竖着写结构图时,儿子在下方(左边),兄弟在右边。
口诀:左儿子、右兄弟,一竖一横要分清!
易错2

枚举孩子时写成 v = lc[v]

❌ 错误
for (v = lc[u]; v; v = lc[v]) —— 这是"沿左链一路向下",会一直走到最深的叶子,而不是遍历 u 的所有孩子。
✅ 正确
for (v = lc[u]; v; v = rs[v]) —— 用 rs 沿兄弟链推进。记住:进入子树用 lc,横向遍历用 rs。
口诀:往下用左、横着用右,兄弟链上走 rs!
易错3

把「二叉树中序 = 原树后序」记成「原树中序」

❌ 错误
认为"二叉树中序 = 原树中序"。多叉树根本没有中序的概念(一个节点有多于两个孩子时,"左根右"无法划分)。
✅ 正确
正确对应只有两条:二叉树先序 = 原树先序;二叉树中序 = 原树后序。二叉树的后序没有简单的原树对应。
口诀:先序对先序,中序对后序,多叉无中序!
易错4

后序遍历时输出位置放错,节点被重复输出

❌ 错误
for (v = lc[u]; v; v = rs[v]) { printf("%d ", u); postOrder(v); } —— 每个孩子前都输出一次 u,u 被输出了"度数"次。
✅ 正确
for (v = lc[u]; v; v = rs[v]) postOrder(v); printf("%d ", u); —— 循环全部结束之后才输出自己,这才是"后序"的含义。
口诀:后序输出放最后,孩子处理完才轮到自己!
📋孩子兄弟表示法速查表
操作核心写法复杂度
存储int lc[MAXN], rs[MAXN];空间 O(2n)
头插加儿子rs[v]=lc[u]; lc[u]=v;O(1)
尾插建树if(!prev) lc[u]=v; else rs[prev]=v; prev=v;O(总儿子数)
枚举孩子for(v=lc[u]; v; v=rs[v])O(度数)
找父亲while(rs[u]) u=rs[u]; return fa[u];O(度数)
原树先序visit(u); for 孩子 preOrder(v);O(n)
原树后序for 孩子 postOrder(v); visit(u);O(n)
二叉树先序visit(u); preB(lc); preB(rs);O(n),= 原树先序
二叉树中序inB(lc); visit(u); inB(rs);O(n),= 原树后序
子树大小sz[u] = 1 + Σ sz[孩子]O(n)
森林 → 二叉树rs[root[i]] = root[i+1];O(k)
题目特征推荐表示理由
只需枚举孩子 / 求子树信息孩子兄弟(lc/rs)空间 O(n),常数最小
需要经常访问「所有孩子」并随机取第 k 个vector 儿子表孩子兄弟需要 O(k) 沿链走
需要频繁向上访问父亲孩子兄弟 + fa[]fa 直接 O(1) 做到
树的度未知且可能很大孩子兄弟(首选)与度完全无关,绝不溢出
需要做树上背包 DP孩子兄弟转移式只有"左子树/右兄弟"两路
需要子树区间操作(线段树维护)DFS 序子树对应连续区间
📌知识点笔记卡
🧠 必记概念
  • lc[u] = 第一个孩子(长子)
  • rs[u] = 下一个兄弟
  • 空指针用 0 表示
  • B 先序 = T 先序;B 中序 = T 后序
  • 森林 ↔ 二叉树 一一对应
📝 必背模板
  • 建树:prev 串 rs 链,父亲只记长子
  • 枚举孩子:for(v=lc[u]; v; v=rs[v])
  • 原树后序:循环后输出自己
  • 子树大小:sz[u]=1+Σsz[v]
  • 森林转二叉树:根的 rs 依次相连
⚠️ 考场提醒
  • 头插会反转孩子顺序,题目要求保序时用尾插
  • 多叉树没有"中序遍历"
  • n=105 的递归需注意栈深(可加迭代写法)
  • 找父亲不能只走一步,要沿 rs 链到末端
  • 二叉树不是二叉搜索树,别假设左小右大
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

概念判断题:① 孩子兄弟表示法中,每个节点最多有两个指针;② rs 指针指向的是"父亲的下一个孩子";③ 多叉树可以用孩子兄弟表示法转成唯一的二叉树;④ 原树的中序遍历等于二叉树的中序遍历。

查看参考答案

① 对。lc 与 rs 各一个,正好对应二叉树的左右指针,所以空间是固定的 O(2n)。

② 错(表述有歧义,理解为"指向自己的下一个兄弟"才对)。rs[u] 指向的是 u 自己的下一个兄弟,不是"父亲的下一个孩子"。虽然两者在多数情况下恰好一致(因为兄弟共享父亲),但语义主体是 u 而非父亲。

③ 对。给定原树的孩子顺序,二叉化结果唯一;若孩子顺序不确定(比如用头插建树),则结果不唯一。

④ 错。多叉树没有中序遍历的概念。正确答案是「二叉树中序 = 原树后序」。

A2编程题

输入一棵以 1 为根的多叉树(每行给出节点的儿子列表,以 0 结束),输出它的孩子兄弟表示(即每个节点的 lc、rs,按编号 1..n 每行一对)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
int lc[100005], rs[100005];
int main(){
    int n; scanf("%d",&n);
    for(int u=1;u<=n;u++){
        int v,prev=0;
        while(scanf("%d",&v)&&v){
            if(!prev) lc[u]=v; else rs[prev]=v;
            prev=v;
        }
    }
    for(int i=1;i<=n;i++) printf("%d %d\n",lc[i],rs[i]);
}

思路:用一个 prev 变量把每个节点的儿子串成 rs 链,父亲只记录长子。

易错:每行开始前忘记把 prev 重置为 0,导致跨行串链;或者把 lc[u] 循环覆盖(每个儿子都赋值),最后只剩最后一个儿子。

A3手工模拟

已知多叉树:1 的孩子是 2、3;2 的孩子是 4、5;3 的孩子是 6。请写出孩子兄弟表示下的 lc/rs 数组,并给出二叉树先序与原树后序。

查看参考答案

lc/rs 数组:

1: lc=2, rs=0

2: lc=4, rs=3 (3 是 2 的弟弟)

3: lc=6, rs=0

4: lc=0, rs=5 (5 是 4 的弟弟)

5: lc=0, rs=0

6: lc=0, rs=0

二叉树先序:1, 2, 4, 5, 3, 6 = 原树先序 ✓

二叉树中序:4, 5, 2, 6, 3, 1 = 原树后序 ✓

考点:手工画出结构图并验证两条对应定理。

A4编程题

用孩子兄弟表示存储一棵多叉树,求它的高度(节点数意义上:根为第 1 层)。

查看参考答案
int h[100005];
void dfs(int u){
    int mx=0;
    for(int v=lc[u]; v; v=rs[v]){ dfs(v); mx=max(mx,h[v]); }
    h[u]=mx+1;              // 叶子的 h = 1
}
// 答案 = h[1]

思路:后序 DFS,先算出所有孩子的高度再取最大值加 1。

易错:把 mx 初始化为 0 后写成 h[u] = mx(漏加 1);或把 rs[v] 写成 lc[v],导致只沿着"最左链"走。

🟡 B组 · 提高(4题)

B1转换验证

给定一棵多叉树,验证「二叉树中序 = 原树后序」,并说明如果建树时改用头插法,这个等式是否仍然成立。

查看参考答案

验证方法:分别输出 inOrder(lc/rs) 与 postOrder(原树孩子链),逐项比较。

头插法的影响:头插会反转每个节点的孩子顺序,因此原树后序的序列会随之变化;但两条对应定理本身依然成立——只是"原树"变成了"孩子顺序被反转后的那棵树"。

结论:对应定理是"结构级别"的恒等式,不依赖具体顺序;而具体序列会随顺序改变。

易错:把"定理成立"和"序列相同"混为一谈,用错了建树顺序却期待和样例完全一致。

B2树形统计

给定一棵多叉树,对每个节点 u 求:① 子树大小 sz[u];② 子树中最大节点编号 mx[u];③ 子树中最小节点编号 mn[u]。n ≤ 105。

查看参考答案
int sz[100005], mx[100005], mn[100005];
void dfs(int u){
    sz[u]=1; mx[u]=u; mn[u]=u;
    for(int v=lc[u]; v; v=rs[v]){
        dfs(v);
        sz[u]+=sz[v];
        mx[u]=max(mx[u],mx[v]);
        mn[u]=min(mn[u],mn[v]);
    }
}
// h[u]=mx[v]+1

核心思想:树形统计的通用套路是「先自底向上算完所有孩子的信息,再在 u 处做一次合并」。只要合并操作(求和、取最值、异或等)满足"可结合",都能这么写。

易错:mx/mn 忘记初始化为 u 本身(子树叶子的 mx/mn 就错了);或先合并再递归(顺序反了)。

B3树转二叉树应用

给定一棵多叉树和一个节点 u,求「u 的所有祖先(不含 u 自己)」的编号,按从根到父的顺序输出。n ≤ 105。

查看参考答案

思路A(用 fa 数组):建树时顺手记录 fa[v] = u,然后从 u 一路 u = fa[u] 走到 0,把路径反转输出。

思路B(不用 fa,只用 lc/rs):从 u 出发,沿 rs 链走到末端得到 u 所在兄弟链的代表节点 x;x 的父亲就是 u 的父亲。重复这个过程即可逐级上溯。

// 思路B:只用孩子兄弟表示求父亲
int getFather(int u){
    while(rs[u]) u=rs[u];     // 走到兄弟链末端
    for(int p=1;p<=n;p++)       // 需要知道谁是父亲 → 逐点检查太慢,
        if(lc[p]==u||...) ...     // 所以实践中必须存 fa[]
}

结论与启发:孩子兄弟表示法在「向上走」这件事上是低效的。所以真实代码里一律额外保存 fa[]——用 lc/rs 向下、用 fa 向上,各取所长。这正是本讲反复强调"两种指针互补"的原因。

复杂度:思路A O(深度),思路B 最坏 O(n·度),所以选 A。

B4森林转换

给定 k 棵多叉树(森林),转换成一棵二叉树,并输出这棵二叉树的先序序列,验证它等于「森林的先序序列」。

查看参考答案
int root[100005];   // 各棵树的根
// 把 k 个根用 rs 串起来
for(int i=1;i<k;i++) rs[root[i]] = root[i+1];
rs[root[k]] = 0;
// 从 root[1] 出发做二叉树先序,即为森林先序

为什么成立:森林先序 = 依次输出每棵树的先序。在二叉化结构中,「走完一棵树」正好对应「rs 链跳到下一棵树的根」,与单棵树内"走完一个孩子子树后跳到弟弟"的逻辑完全同构。

易错:只串了树的根,却忘了每棵树内部也要做孩子兄弟转换(可以统一在 build 里做)。

🔴 C组 · 挑战(4题)

C1初赛真题

一棵多叉树共有 20 个节点,每个节点的度都不超过 3。它转换成的二叉树最多有多少个节点、最少有多少个节点?二叉树的深度与原树深度有何关系?

查看参考答案

节点数:恒为 20 个。孩子兄弟转换只是"重新连线",不增不减节点。

深度关系:二叉化的结构是「左 = 往下走一层,右 = 横向走一格」,所以二叉树的深度 ≈ 原树的深度与兄弟链长度按某条路径叠加的结果,完全可能远超原树深度。极端情况下(一条链 + 每个节点都有兄弟),二叉树可能退化成单链、深度为 20;而原树深度可能是 2(根 + 19 个孩子 → 转成二叉树后是一条 20 长右链,深度 20)。

反例:根有 19 个孩子 → 二叉树为 1-2-3-...-20 的右斜链,深度 20,而原树深度只有 2。这说明二叉化可能显著增加深度。

考点:理解"二叉化不改变节点数与遍历顺序,但会改变树的形状与深度"。

C2树上背包

用孩子兄弟表示写一棵树的 0/1 闭合子集背包:n,m ≤ 300,要求选出包含根的节点集合,选子必选父,总体积 ≤ m 时最大价值。

查看参考答案

状态:f[u][j] = 「以 u 为根的兄弟森林(含 u 及其所有弟弟的子树)用容量 j 能取到的最大价值」。

转移:

不选 u:f[u][j] = f[rs[u]][j]

选 u:f[u][j] = max over k { v[u] + f[lc[u]][j-w[u]-k] + f[rs[u]][k] }(j ≥ w[u])

为什么必须"闭合"?因为 f[lc[u]] 只有在"u 已被选"时才能进入——这自然满足了「选子必选父」的约束。

int f[305][305];
int dp(int u,int j){
    if(!u||j<=0) return 0;
    if(f[u][j]>=0) return f[u][j];
    int best=dp(rs[u],j);
    if(j>=w[u]){
        int rem=j-w[u];
        for(int k=0;k<=rem;k++)
            best=max(best,v[u]+dp(lc[u],rem-k)+dp(rs[u],k));
    }
    return f[u][j]=best;
}
// f 初始化为 -1,答案 = dp(1, m):从根出发自动覆盖"选根/全不选"两种决策

易错:忘记"不选 u"这一支;或者容量分配时写成 rem 与 k 颠倒;或 f 未初始化为 -1 导致记忆化失效。

C3综合编程

给定一棵多叉树,求「所有叶子节点到根路径上的节点编号和」的最大值。

查看参考答案

思路:一遍 DFS,从根往下传递当前路径和。dfs(u, sum):sum += u;若 u 无孩子(lc[u]==0)则更新答案;否则对所有孩子递归。

long long best=0;
void dfs(int u,long long sum){
    sum+=u;
    if(!lc[u]){ best=max(best,sum); return; }   // 叶子
    for(int v=lc[u]; v; v=rs[v]) dfs(v,sum);
}

关键点:判断叶子必须用 lc[u]==0,不能用 rs[u]==0——rs 为空只说明"没有弟弟",与是不是叶子无关。

易错:用 rs 判断叶子;或 sum 用 int 溢出(路径和最大约 n²/2 = 5×109)。

C4思维题

已知一棵二叉树的先序与中序序列,能否唯一确定这棵二叉树?如果换成"孩子兄弟二叉树",能否唯一还原出原来的多叉树?

查看参考答案

第一问:能。先序定根,中序分左右子树区间,递归重建,是经典结论(注意:有重复值时不唯一)。

第二问:能。孩子兄弟二叉树与多叉树之间是一一对应的:把二叉树中所有"右孩子关系"改画成"兄弟关系","左孩子关系"改画成"父子关系",就唯一还原出原树。

更妙的推论:因为「森林 ↔ 二叉树」也是一一对应,所以「n 个节点的森林」与「n 个节点的二叉树」数量相同 —— 都等于第 n 个卡特兰数 Cn。这是组合计数中一个非常漂亮的恒等式。

启发:「一一对应」意味着"可以互相翻译"。凡是二叉树上的算法结论,都可以通过孩子兄弟表示搬到多叉树上——这就是本讲最大的方法论价值。

🏆 洛谷原题精选(12道)

以下题目均与多叉树存储、二叉树转换与树形遍历相关,按难度从入门到提高排列。

1
P1305
新二叉树入门
2
P1030
求先序排列入门
3
P1087
FBI树普及-
4
P1827
美国血统 American Heritage普及-
5
P1229
遍历问题普及-
6
P3884
二叉树问题普及+
7
P5018
对称二叉树普及+
8
P1122
最大子树和提高
9
P1040
加分二叉树提高
10
P1352
没有上司的舞会(树形DP)提高
11
P2014
选课(树形背包)提高
12
P2052
道路修建提高