第十二章:树与森林——从存储到遍历的完整攻略

树结构,说实话,是很多C语言学习者的一道坎。我记得刚入行那会儿,第一次看到树的递归遍历代码,整个人都是懵的。但后来在实际项目中,无论是文件系统解析、语法树构建,还是路由表查找,树结构几乎无处不在。今天我们就来彻底搞定树与森林的存储、转换和遍历。

12.1 树的三种存储结构

树在内存里怎么存?这个问题我当年也纠结过。其实核心就三个思路:存爹、存儿子、存兄弟。我们一个个来看。

12.1.1 双亲表示法

说白了,就是每个节点只记住自己的父节点是谁。这种结构特别适合“找爹”频繁的场景。

#define MAX_TREE_SIZE 100

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

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

我的经验:双亲表示法在并查集算法里特别好用。我曾经写一个社交网络的好友圈检测,就是用这个结构实现的。找两个节点的公共祖先,时间复杂度O(n),代码还特别简洁。

优点很明显:找父节点O(1)。缺点?找孩子得遍历整个数组,O(n)。所以它适合“向上查”多的场景。

12.1.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 root, n;
} CTree;

核心要点:孩子表示法找孩子很快,但找爹就麻烦了。实际项目中,我经常把双亲和孩子两种表示法结合起来用——每个节点既存parent下标,又挂孩子链表。空间换时间,值不值看你需求。

12.1.3 孩子兄弟表示法(重点)

这个厉害了。每个节点只存两个指针:firstChild(第一个孩子)和nextSibling(下一个兄弟)。你看,任意一棵树都能用这种“二叉树”的形式存下来。

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

为什么说它是重点?因为这种结构天然支持树与二叉树的互相转换。我当年做编译器前端时,语法分析树就是用孩子兄弟表示法存的,后续遍历、转换都特别顺手。

避坑指南:我曾经在项目里把firstChild和nextSibling搞反了,结果遍历出来的树结构完全错乱。调试了整整一个下午。记住:firstChild指向的是“垂直方向”的孩子,nextSibling指向的是“水平方向”的兄弟。

12.2 森林与二叉树的转换

森林就是多棵树的集合。为什么要转成二叉树?因为二叉树的遍历、查找算法太成熟了,我们想把森林的问题转化成二叉树的问题来解决。

12.2.1 森林→二叉树

转换规则其实就三步:

  1. 每棵树按孩子兄弟表示法转成二叉树
  2. 每棵树的根节点视为兄弟
  3. 把后一棵树的根作为前一棵树根的右孩子

说白了,就是把森林里所有树的根节点,用右指针串成一个链表。

// 森林转二叉树(递归实现)
CSTree ForestToBinary(CSTree forest[], int n) {
    if (n == 0) return NULL;
    
    CSTree root = forest[0];
    CSTree p = root;
    
    for (int i = 1; i < n; i++) {
        p->nextSibling = forest[i];
        p = p->nextSibling;
    }
    
    return root;
}

12.2.2 二叉树→森林

反过来也一样简单:

  1. 根节点没有右孩子?那它就是一棵树
  2. 不断把根节点的右链断开,每断开一次就得到一棵新树的根
  3. 每棵树内部再按孩子兄弟规则还原

我个人的习惯:做转换时,先在纸上画图。画清楚指针怎么指,再写代码。你想想看,指针指错了,debug的时间比写代码的时间长多了。

12.3 树与森林的遍历

遍历是树操作的基础。树的遍历和二叉树的遍历有对应关系,理解了这一点,很多问题就迎刃而解。

12.3.1 树的遍历

树有两种基本遍历方式:

遍历方式顺序对应二叉树遍历
先根遍历先访问根,再依次遍历每个子树先序遍历
后根遍历先依次遍历每个子树,再访问根中序遍历

注意这个对应关系:树的后根遍历 ≠ 二叉树的后序遍历。这是很多初学者容易搞混的地方。

// 树的先根遍历(孩子兄弟表示法)
void PreOrderTree(CSTree T) {
    if (T == NULL) return;
    
    printf("%c ", T->data);           // 访问根
    PreOrderTree(T->firstChild);      // 遍历第一个孩子
    PreOrderTree(T->nextSibling);     // 遍历兄弟
}

12.3.2 森林的遍历

森林的遍历分两种:

  • 先序遍历森林:先访问第一棵树的根,再遍历第一棵树的子树森林,最后遍历剩下的树
  • 中序遍历森林:先遍历第一棵树的子树森林,再访问第一棵树的根,最后遍历剩下的树

你发现没有?森林的先序遍历,其实就是对应二叉树的先序遍历。森林的中序遍历,对应二叉树的中序遍历。这就是为什么我们费劲把森林转成二叉树——遍历算法可以直接复用。

实战经验:我在做一个文件系统差异对比工具时,就用到了森林遍历。把两个目录结构分别建成森林,转成二叉树后做同步遍历,差异点一目了然。代码量不到200行,效率还特别高。

12.4 知识体系总览

下面这张图,把本章的核心知识点串起来了。建议你保存下来,复习时对照着看。

树与森林知识体系 存储结构 森林↔二叉树转换 遍历算法 双亲表示法 孩子表示法 ★ 孩子兄弟表示法 森林 → 二叉树 二叉树 → 森林 右链断开法 树的先根/后根遍历 森林的先序/中序遍历 与二叉树遍历的对应 核心思想:用二叉树的操作去解决树与森林的问题 孩子兄弟表示法是连接树与二叉树的桥梁

12.5 避坑与实战建议

最后,分享几个我这些年踩过的坑:

  • 递归深度:树的递归遍历,如果树特别深(比如上千层),小心栈溢出。我遇到过生产环境因为这个崩溃的。解决方案?改成非递归的迭代版本,或者增大栈空间。
  • 指针野了:孩子兄弟表示法里,每个节点有两个指针。释放内存时一定要先递归释放孩子,再释放兄弟,最后释放自己。顺序错了就内存泄漏。
  • 转换别想当然:森林转二叉树后,原来的“兄弟”关系变成了“右孩子”关系。遍历时别用二叉树的左右孩子概念去套,要时刻记住:左孩子是原来的第一个孩子,右孩子是原来的兄弟。

一句话总结:树与森林的核心,就是孩子兄弟表示法。搞懂了它,存储、转换、遍历全通了。我当年面试时,面试官问树的问题,我都是先画孩子兄弟表示法,再展开讲,基本没失手过。


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