12. 树:从定义到存储,一次讲透

树这个结构,说实话,是很多程序员从「线性思维」转向「层次思维」的第一道坎。我记得自己刚学的时候,总觉得链表、数组用得好好的,干嘛要搞这么复杂?直到后来做文件系统解析,才真正体会到树的威力。

今天我们就来聊聊树。我会从定义讲起,再到术语,最后落到三种存储结构上。嗯,都是实战中经常碰到的。

12.1 树的定义

树是一种非线性的数据结构。它由 n(n≥0)个节点组成,节点之间有明确的层次关系。

专业定义是这样的:

  • 有且仅有一个根节点
  • 除根节点外,其余节点被分成 m(m≥0)个互不相交的子树
  • 每个子树本身也是一棵树

说白了,就是「一个根,多个叉,层层往下分」。我在项目中遇到过最典型的例子——解析 XML 文档。XML 的标签嵌套,本质上就是一棵树。根标签是根节点,子标签是子树。

核心要点:树的定义是递归的。子树本身也是树,这意味着很多树的操作天然适合用递归实现。

12.2 树的基本术语

这些术语我建议你记牢。因为后面讲二叉树、平衡树、B树,都会用到。我当年面试时,就被问过「树的度和图的度有什么区别」——嗯,当时答得不太好。

术语 含义 我的理解
根节点 没有父节点的节点 整棵树的起点
叶子节点 没有子节点的节点 树的末端,度为0
父节点/子节点 直接上下层关系 一对一的关系
兄弟节点 同一个父节点的子节点 平级关系
节点的度 节点拥有的子树个数 有几个孩子
树的度 所有节点中最大的度 最「能生」的那个节点
深度/高度 从根到最远叶子的层数 树的层数,根为第1层
森林 m棵互不相交的树 去掉根节点,剩下的就是森林

小技巧:判断一个节点是不是叶子,就看它的度是否为0。我在写树遍历时,经常用这个条件作为递归终止。

12.3 树的存储结构

树怎么存到内存里?这是个好问题。数组?链表?还是两者结合?

实际上,三种主流方案各有优劣。我分别讲一下。

12.3.1 双亲表示法

思路很简单:每个节点只存自己的数据和父节点的位置(下标)。

你想想看,这就像每个人都知道自己爸爸是谁,但不知道孩子是谁。适合什么场景?找父节点非常快,O(1) 就能找到。但找子节点?得遍历整个数组。

#define MAX_TREE_SIZE 100

typedef struct {
    char data;       // 节点数据
    int parent;      // 父节点下标,-1表示根
} PTNode;

typedef struct {
    PTNode nodes[MAX_TREE_SIZE];
    int r, n;        // 根的位置和节点数
} PTree;

注意:双亲表示法下,删除一个节点时,要小心处理它的子节点。我曾经在写一个树形菜单时,忘了更新子节点的 parent 字段,结果整个树结构乱了——排查了半天。

12.3.2 孩子表示法

每个节点存一个链表,链表里是所有子节点的下标。说白了,就是「我知道我的孩子们是谁」。

这种结构找子节点很快,但找父节点就麻烦了——得遍历所有节点。

typedef struct ChildNode {
    int child;                 // 子节点下标
    struct ChildNode *next;    // 下一个孩子
} ChildNode;

typedef struct {
    char data;                 // 节点数据
    ChildNode *firstChild;     // 第一个孩子
} CTNode;

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

我个人习惯在需要频繁遍历子树的场景用孩子表示法。比如渲染一个树形组件,从根往下递归,孩子表示法就非常自然。

12.3.3 孩子兄弟表示法

这个方案我觉得最巧妙。它用二叉链表来表示任意树。每个节点只有两个指针:

  • firstChild:指向第一个孩子
  • nextSibling:指向下一个兄弟

这样一来,任何一棵树都能转成二叉树。你想想看,是不是很神奇?

typedef struct CSNode {
    char data;                 // 节点数据
    struct CSNode *firstChild; // 第一个孩子
    struct CSNode *nextSibling;// 下一个兄弟
} CSNode, *CSTree;

为什么说它重要?因为这种结构把「多叉」转成了「二叉」。而二叉树的操作,我们有很多成熟的算法。比如遍历、查找、插入,都能复用二叉树的套路。

我在做文件系统遍历时就用过这个结构。每个目录节点,firstChild 指向第一个子文件/目录,nextSibling 指向同级的下一个。写递归遍历时,逻辑非常清晰。

12.4 三种存储结构对比

存储方式 找父节点 找子节点 空间开销 适用场景
双亲表示法 O(1) O(n) 频繁找父节点
孩子表示法 O(n) O(度) 频繁遍历子树
孩子兄弟表示法 较慢 O(度) 通用,可转二叉树

12.5 知识体系总览

下面这张图,我把本章的核心知识点串了起来。你可以用它来快速回顾。

树的知识体系 树的定义 基本术语(度、深度、叶子等) 三种存储结构 双亲表示法 存父节点下标 孩子表示法 链表存子节点 孩子兄弟表示法 转二叉树 核心:递归定义 + 层次关系 + 灵活存储

嗯,树的内容就讲到这里。三种存储结构各有千秋,没有绝对的好坏。关键看你需要频繁做什么操作——找父节点?遍历子树?还是希望结构通用?选对了,代码写起来会顺手很多。

我的建议:初学者先从孩子兄弟表示法入手。因为它和二叉树打通了,后续学二叉树的遍历、查找、平衡,都能直接迁移。我自己做项目时,90% 的树结构都用这种表示法。


公众号:蓝海资料掘金营,微信 deep3321