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