← Back to Learning Notes

Lecture 18:树、森林

Chinese-language Computer Science 408 note: Lecture 18:树、森林. Original source available where applicable.

On this page

This note is currently available in Chinese. The original wording, formulas and diagrams are preserved to avoid an unverified technical translation.

18.1 树的存储结构

18.1.1 双亲表示法

定义:#双亲表示法

描述: 双亲表示法是一种顺序存储结构。它用数组存储树中各个结点,并在每个结点中保存该结点双亲在数组中的下标。

基本思想

  • 每个结点中保存一个指向双亲的“指针”。
  • 在顺序表中,这个指针实际保存的是双亲结点在数组中的下标。
  • 根结点没有双亲,因此根结点的 parent 通常记为: $$ -1 $$

PDF 示例

下标 data parent
0 A -1
1 B 0
2 C 0
3 D 0
4 E 1
5 F 1
6 G 2
7 H 3
8 I 3
9 J 3
10 K 4

含义

  • B 的 parent=0,说明 B 的双亲是下标为 0 的结点 A。
  • E 的 parent=1,说明 E 的双亲是下标为 1 的结点 B。
  • K 的 parent=4,说明 K 的双亲是下标为 4 的结点 E。

优点

  • 查指定结点的双亲很方便。
  • 可以直接通过下标找到双亲结点。
  • 也可以存储森林:把多棵树的根结点 parent 都设为 -1。

缺点

  • 查指定结点的孩子不方便。
  • 若要找某个结点的所有孩子,需要从头扫描整个数组。
  • 因此它适合经常找双亲、不经常找孩子的场景。

复杂度口径

  • 已知结点在数组中的下标时,读取 parent 并定位双亲是 $O(1)$。
  • 查一个结点的全部孩子通常要扫描整个数组,是 $O(n)$。
  • 如果只给出关键字、还要先在数组中查到该结点,则查找关键字本身也可能需要 $O(n)$;不能脱离“已知下标”的前提笼统地说查双亲必为 $O(1)$。

DS-L18-双亲表示法.png

图示说明

  • 左侧数组的 parent 保存双亲下标,右侧箭头由孩子指向双亲。
  • A 的 parent=-1,所以 A 是根;B、C、D 的 parent 都是 0,所以它们都是 A 的孩子。

双亲表示法代码

#define MAX_TREE_SIZE 100

typedef struct {
    ElemType data;  // 数据元素
    int parent;     // 双亲位置域,指明双亲在数组中的下标
} PTNode;

typedef struct {
    PTNode nodes[MAX_TREE_SIZE]; // 双亲表示
    int n;                       // 结点数
} PTree;

代码理解

  • nodes[i].data 保存第 i 个结点的数据。
  • nodes[i].parent 保存第 i 个结点的双亲下标。
  • 根结点的 parent 为 -1。

18.1.2 孩子表示法

定义:#孩子表示法

描述: 孩子表示法使用数组顺序存储各个结点,同时为每个结点建立一个孩子链表,用来保存该结点所有孩子在数组中的位置。

存储方式

  • 用数组顺序存储所有结点。
  • 数组中的每个结点保存:
    • 数据元素。
    • 孩子链表头指针。
  • 孩子链表中的每个结点保存:
    • 某个孩子结点在数组中的下标。
    • 指向下一个孩子链表结点的指针。

PDF 示例理解

  • A 结点的孩子是 B C D。
  • 在孩子表示法中,A 的 firstChild 指向一个孩子链表。
  • 这个孩子链表依次保存 B、C、D 在数组中的下标。

优点

  • 查某个结点的所有孩子很方便。
  • 沿着该结点的孩子链表即可找到所有孩子。

缺点

  • 查某个结点的双亲不方便。
  • 若没有额外保存双亲信息,仍然需要扫描各个孩子链表。

复杂度口径

  • 已知结点数组下标时,枚举它的孩子只需遍历本结点的孩子链表,时间为 $O(d)$,其中 $d$ 是该结点的度。
  • 若结构中没有双亲域,反查某结点的双亲通常要检查各条孩子链,最坏为 $O(n)$。

存储森林时的注意

  • 孩子表示法可以存储森林。
  • 但需要额外记录多个根结点的位置。
  • PDF 中代码用 r 记录根位置;若是森林,则需要记录多个根位置。

DS-L18-孩子表示法.png

图示说明

  • 数组保存结点数据和孩子链表头指针;橙色链结点保存孩子在数组中的下标。
  • 例如 A 的孩子链依次为 1 -> 2 -> 3,对应孩子 B、C、D。

孩子表示法代码

struct CTNode {
    int child;              // 孩子结点在数组中的位置
    struct CTNode *next;    // 下一个孩子
};

typedef struct {
    ElemType data;
    struct CTNode *firstChild; // 第一个孩子
} CTBox;

typedef struct {
    CTBox nodes[MAX_TREE_SIZE];
    int n, r;                  // 结点数和根位置
} CTree;

代码理解

  • CTBox 是数组中的一个结点框。
  • firstChild 指向该结点的第一个孩子链表结点。
  • CTNode.child 存的是孩子在数组中的位置,不是孩子的数据本身。

18.1.3 孩子兄弟表示法

定义:#孩子兄弟表示法

描述: 孩子兄弟表示法是一种链式存储结构。每个结点保存两个指针:一个指向第一个孩子,另一个指向右边的第一个兄弟。

两个指针

  • firstchild:指向该结点的第一个孩子。
  • nextsibling:指向该结点右边的第一个兄弟。

与二叉链表的关系

  • 形式上和二叉树的二叉链表类似。
  • 都是一个数据域加两个指针域。
  • 但两个指针的含义不同。
结构 左指针含义 右指针含义
二叉树二叉链表 左孩子 右孩子
孩子兄弟表示法 第一个孩子 右兄弟

核心口诀

左指针 = 第一个孩子
右指针 = 下一个兄弟

优点

  • 可以用熟悉的二叉树操作来处理树和森林。
  • 树、森林与二叉树之间的转换,本质上依赖孩子兄弟表示法。

森林中的特殊含义

  • 森林中每棵树的根结点可以看作平级兄弟。
  • 因此多个根结点之间可以用右指针串起来。

操作特点

  • 已知结点时,找第一个孩子和下一个兄弟都是 $O(1)$。
  • 若不额外保存双亲指针,找双亲通常仍需从根遍历,最坏为 $O(n)$。

DS-L18-孩子兄弟表示法.png

图示说明

  • 蓝色边表示“第一个孩子”,黄色边表示“下一个兄弟”。
  • 森林中各棵树的根也按兄弟关系用右指针相连,因此整片森林可以用一棵二叉树形态表示。

孩子兄弟表示法代码

typedef struct CSNode {
    ElemType data;                         // 数据
    struct CSNode *firstchild, *nextsibling; // 第一个孩子和右兄弟指针
} CSNode, *CSTree;

代码理解

  • firstchild 可以看作二叉树中的左指针。
  • nextsibling 可以看作二叉树中的右指针。
  • 但语义一定要按“孩子、兄弟”理解,不能误解成二叉树的左孩子、右孩子。

18.1.4 三种存储结构对比

存储方式 存储结构 查双亲 查孩子 适合场景
双亲表示法 顺序存储 方便 不方便 经常找父结点
孩子表示法 顺序 + 链式 不方便 方便 经常找孩子
孩子兄弟表示法 链式存储 需遍历 较方便 树/森林与二叉树转换

18.2 树和森林的遍历

IMPORTANT · 孩子次序前提 下列遍历序列和转换结果都依赖固定的孩子次序。408 题目中的树通常按从左到右有序;若讨论无序树,必须先约定孩子排列顺序,否则遍历序列和对应二叉树并不唯一。

18.2.1 树的先根遍历

定义:#树的先根遍历

描述: 若树非空,先访问根结点,再依次对每棵子树进行先根遍历。

访问顺序

根 -> 第 1 棵子树 -> 第 2 棵子树 -> ... -> 第 k 棵子树

递归过程

  1. 访问根结点。
  2. 按从左到右的顺序,对每棵子树进行先根遍历。

重要对应关系

  • 树的先根遍历序列,与这棵树转换得到的二叉树的先序遍历序列相同。

代码

void PreOrder(CSTree R) {
    if (R != NULL) {
        visit(R);                            // 访问根结点
        for (CSTree child = R->firstchild; child != NULL;
             child = child->nextsibling) {
            PreOrder(child);                 // 按顺序遍历每棵子树
        }
    }
}

这里沿用上文的 CSNode/CSTree 孩子兄弟结点定义,visit 表示由使用者提供的访问操作;只沿 firstchild 进入子树,由本层循环沿 nextsibling 枚举兄弟。

记忆

  • 树的“先根”对应二叉树的“先序”。
  • 根先访问,所以和二叉树先序遍历一致。

18.2.2 树的后根遍历

定义:#树的后根遍历

描述: 若树非空,先依次对每棵子树进行后根遍历,最后再访问根结点。

访问顺序

第 1 棵子树 -> 第 2 棵子树 -> ... -> 第 k 棵子树 -> 根

递归过程

  1. 按从左到右的顺序,对每棵子树进行后根遍历。
  2. 最后访问根结点。

重要对应关系

  • 树的后根遍历序列,与这棵树转换得到的二叉树的中序遍历序列相同。
  • 原因:在孩子兄弟表示法中,结点的左指针指向第一个孩子,右指针指向下一个兄弟。树根没有右兄弟,因此转换后二叉树的根没有右子树;中序遍历先访问根的左子树(其中编码了原树根的各棵子树及兄弟链),再访问根,正好得到原树的后根遍历。

代码

void PostOrder(CSTree R) {
    if (R != NULL) {
        for (CSTree child = R->firstchild; child != NULL;
             child = child->nextsibling) {
            PostOrder(child);                // 先遍历各棵子树
        }
        visit(R);                            // 最后访问根结点
    }
}

记忆

  • 树的“后根”对应二叉树的“中序”。
  • 这是本节常考对应关系之一。

18.2.3 森林的先序遍历

定义:#森林先序遍历

描述: 若森林非空,先访问森林中第一棵树的根结点,再先序遍历第一棵树根结点的子树森林,最后先序遍历去除第一棵树后剩余树构成的森林。

访问规则

  1. 访问森林中第一棵树的根结点。
  2. 先序遍历第一棵树中根结点的子树森林。
  3. 先序遍历去除第一棵树之后剩余树构成的森林。

重要对应关系

  • 森林的先序遍历序列,与森林转换得到的二叉树的先序遍历序列相同。
  • 做题时可以先转换成二叉树,再求先序遍历。

18.2.4 森林的中序遍历

定义:#森林中序遍历

描述: 若森林非空,先中序遍历森林中第一棵树根结点的子树森林,再访问第一棵树的根结点,最后中序遍历去除第一棵树后剩余树构成的森林。

访问规则

  1. 中序遍历森林中第一棵树根结点的子树森林。
  2. 访问第一棵树的根结点。
  3. 中序遍历去除第一棵树之后剩余树构成的森林。

重要对应关系

  • 森林的中序遍历序列,与森林转换得到的二叉树的中序遍历序列相同。

18.2.5 遍历对应关系总结

原结构 遍历方式 对应二叉树遍历
树 先根遍历 先序遍历
树 后根遍历 中序遍历
森林 先序遍历 先序遍历
森林 中序遍历 中序遍历

易错点

  • 树没有“中根遍历”这一常用说法,树常说先根、后根。
  • 森林常说先序、中序。
  • 树的后根遍历不是对应二叉树后序,而是对应二叉树中序。

18.3 树、森林与二叉树的转换

18.3.1 核心原则

树、森林与二叉树转换的核心是孩子兄弟表示法:

第一个孩子 -> 左指针
右边兄弟   -> 右指针

也可以记为:

左孩子右兄弟

“糖葫芦”理解

  • 同一组兄弟结点用右指针串成一串。
  • 这串兄弟链就是 PDF 中说的“右指针糖葫芦”。
  • 第一个孩子挂在父结点的左指针下。
  • 后续兄弟依次挂在前一个兄弟的右指针上。

18.3.2 树转二叉树

定义:#树转二叉树

描述: 将一棵普通树按孩子兄弟表示法转换成一棵二叉树。

转换方法

  1. 先在二叉树中画出原树的根结点。
  2. 按树的层序,从上到下、从左到右依次处理每个结点。
  3. 如果当前结点在树中有孩子:
    • 把所有孩子结点用右指针串成一串。
    • 把第一个孩子挂到当前结点的左指针下。
  4. 重复直到所有结点处理完。

处理一个结点的口诀

孩子排成右链
第一个孩子挂左边

例子 若树中 A 的孩子依次为:

B C D

则在二叉树中:

  • A->lchild = B
  • B->rchild = C
  • C->rchild = D

画成结构:

    A
   /
  B
   \
    C
     \
      D

注意

  • 原树中的兄弟关系,在二叉树中表现为右孩子关系。
  • 原树中的第一个孩子,在二叉树中表现为左孩子关系。

DS-L18-树转二叉树.png

图示说明

  • 原树中同一父结点的孩子按从左到右的次序排列;转换后,第一个孩子成为左孩子,其余兄弟沿右指针依次相连。
  • 图中的黄色边表示由兄弟关系形成的右指针。

18.3.3 森林转二叉树

定义:#森林转二叉树

描述: 将森林中的多棵树按孩子兄弟表示法合并表示为一棵二叉树。

转换方法

  1. 先把森林中所有树的根结点画出来。
  2. 在二叉树中,用右指针把这些根结点串成一串。
  3. 按森林的层序依次处理每个结点。
  4. 如果当前结点在原树中有孩子:
    • 把所有孩子结点用右指针串成一串。
    • 把第一个孩子挂在当前结点的左指针下。

根结点的处理 若森林有三棵树,根分别为:

A C F

则在二叉树中:

A ->right C ->right F

即:

A
 \
  C
   \
    F

注意

  • 森林中各棵树的根结点视为平级兄弟关系。
  • 在转换后的二叉树中,右指针表示平级关系。
  • 转换后二叉树的左边表示“第一个孩子”关系,右边表示“下一个兄弟”关系;必须结合边的方向解释原森林关系。

DS-L18-森林转二叉树.png

图示说明

  • A、C、F、L 是四棵树的根,在二叉树中沿根结点右链相连。
  • 每棵树内部仍执行“第一个孩子接左边、其余兄弟接右边”。

18.3.4 二叉树转树

定义:#二叉树转树

描述: 将由一棵树转换得到的二叉树,按左孩子右兄弟规则还原为原来的树。

转换方法

  1. 先画出树的根结点。
  2. 从根结点开始,按树的层序顺序,一层一层恢复每个结点的孩子。
  3. 在二叉树中,如果当前处理的结点有左孩子:
    • 把左孩子和从它开始的一整串右指针链拆下来。
    • 按顺序挂在当前结点的下方,作为当前结点的孩子。
  4. 重复直到所有结点处理完。

反向口诀

左孩子是第一个孩子
右链是同一组兄弟

例子 若二叉树中有:

A->lchild = B
B->rchild = C
C->rchild = D

则还原为树时:

  • B C D 都是 A 的孩子。
  • 它们不是二叉树意义上的右孩子关系,而是原树中的兄弟关系。

注意

  • 任意二叉树都可以按“左孩子、右兄弟”解释为一片森林。
  • 只有当二叉树根结点的右指针为空时,解释结果才是一棵树;若根的右指针非空,则根及其右链对应森林中的多个树根。

DS-L18-二叉树转树.png

图示说明

  • 二叉树根 A 没有右兄弟,所以还原结果是一棵树。
  • 每个结点的左孩子及其右链,被还原为该结点从左到右的一组孩子。

18.3.5 二叉树转森林

定义:#二叉树转森林

描述: 将由森林转换得到的二叉树,按左孩子右兄弟规则还原为多棵树组成的森林。

转换方法

  1. 先把二叉树的根结点和从根结点开始的一整串右指针链拆下来。
  2. 这些结点作为森林中多棵树的根结点。
  3. 按森林的层序恢复每个结点的孩子。
  4. 在二叉树中,如果当前处理的结点有左孩子:
    • 把左孩子和从左孩子开始的一整串右指针链拆下来。
    • 按顺序挂在当前结点的下方,作为它的孩子。

核心

右指针糖葫芦 = 平级关系

还原规则

  • 根结点的右指针链,对应森林中多棵树的根。
  • 某个结点左孩子开始的右指针链,对应该结点的一组孩子。

DS-L18-二叉树转森林.png

图示说明

  • 二叉树根 A 的右链还包含 C、F、L,因此还原为以 A、C、F、L 为根的四棵树。
  • 对每个根和内部结点,再按“左孩子是第一个孩子、右链是其余兄弟”恢复各自的孩子。

18.4 题型:树、森林与二叉树转换

题型一:判断存储方式特点

第一部分:解题方法

看到题干描述后,抓关键词:

关键词 存储方式
每个结点保存双亲下标 双亲表示法
数组 + 每个结点一个孩子链表 孩子表示法
第一个孩子 + 右兄弟 孩子兄弟表示法
左孩子右兄弟 孩子兄弟表示法

第二部分:典型例题

例 1 某树的存储结构中,每个结点保存 data 和 parent 两个域,parent 保存双亲结点在数组中的位置。该结构是什么?

答案

  • 双亲表示法。

例 2 某树的存储结构中,每个结点保存 firstchild 和 nextsibling 两个指针。该结构是什么?

答案

  • 孩子兄弟表示法。

第三部分:知识点复盘

  • 双亲表示法查父结点快。
  • 孩子表示法查孩子快。
  • 孩子兄弟表示法最适合和二叉树互相转换。

题型二:树和森林遍历对应二叉树遍历

第一部分:解题方法

记住对应关系:

原结构 原遍历 对应二叉树遍历
树 先根 先序
树 后根 中序
森林 先序 先序
森林 中序 中序

第二部分:典型例题

例 1 一棵树转换为二叉树后,对该二叉树进行先序遍历,所得序列等于原树的什么遍历?

答案

  • 原树的先根遍历。

例 2 一棵树转换为二叉树后,对该二叉树进行中序遍历,所得序列等于原树的什么遍历?

答案

  • 原树的后根遍历。

第三部分:知识点复盘

  • 树后根对应二叉树中序,这是常见易错点。
  • 森林先序对应二叉树先序。
  • 森林中序对应二叉树中序。

题型三:树转二叉树

第一部分:解题方法

  1. 从原树根结点开始。
  2. 每个结点的第一个孩子,变成该结点的左孩子。
  3. 每个结点的其余孩子,用右指针依次串起来。
  4. 对所有结点重复。

第二部分:典型例题

若原树中:

A 的孩子:B C D
B 的孩子:E F
C 的孩子:G

则二叉树中:

A->lchild = B
B->rchild = C
C->rchild = D
B->lchild = E
E->rchild = F
C->lchild = G

第三部分:知识点复盘

  • 原树同一父结点的多个孩子,在二叉树中构成右指针链。
  • 原树的第一个孩子才是二叉树中的左孩子。

题型四:二叉树还原树或森林

第一部分:解题方法

  1. 先看二叉树根结点的右指针是否为空。
  2. 若根的右指针非空,则根及其右链表示多个平级根,还原为森林。
  3. 若根的右指针为空,则还原为一棵树。
  4. 对每个结点:
    • 左孩子是第一个孩子。
    • 左孩子开始的整串右指针链,是该结点的所有孩子。

第二部分:典型例题

若二叉树中:

A->lchild = B
B->rchild = C
C->rchild = D

则在还原后的树中:

B C D

都是 A 的孩子。

第三部分:知识点复盘

  • 二叉树中的右孩子,在还原时通常表示兄弟。
  • 二叉树中的左孩子,在还原时表示第一个孩子。
  • 根结点的右链表示森林中的多个根。

相关知识

18.5 本节复盘

三种存储方式

存储方式 核心字段 优点 缺点
双亲表示法 parent 找双亲方便 找孩子麻烦
孩子表示法 firstChild 找孩子方便 找双亲麻烦
孩子兄弟表示法 firstchild, nextsibling 便于转二叉树 语义易混

转换总口诀

左孩子,右兄弟

具体含义

二叉树指针 在树/森林中的含义
左指针 第一个孩子
右指针 下一个兄弟

遍历对应

原结构 原遍历 对应二叉树遍历
树 先根 先序
树 后根 中序
森林 先序 先序
森林 中序 中序

易错点

  • 双亲表示法的 parent 存的是数组下标,不是指针地址。
  • 双亲表示法只有在已知结点下标时,找双亲才是 $O(1)$;找全部孩子通常是 $O(n)$。
  • 森林可以用双亲表示法存储,只要多个根的 parent 都设为 -1。
  • 孩子表示法存森林时,需要记录多个根的位置。
  • 孩子兄弟表示法的两个指针虽然长得像二叉树指针,但含义不同。
  • 树转二叉树时,兄弟要用右指针串起来。
  • 遍历和转换都依赖固定的孩子次序;无序树必须先约定顺序。
  • 任意二叉树都能解释为森林;根的右指针为空时才对应单棵树,非空时根的右链表示多个树根。