先说个现象。我见过不少同学,学树的时候能把定义背得滚瓜烂熟——节点、根、叶子、子树、深度、层次,说起来头头是道,真让写代码就卡住了。原因倒也不复杂:树在逻辑上非常直观,可一旦要落到内存里,立刻就会碰到一个核心矛盾——树的分支是不固定的,一个节点可能有两个孩子,也可能有二十个孩子,你怎么知道该给它分配多少空间?这篇就围绕“树的三种表示方法”展开,把双亲表示法、孩子表示法、孩子兄弟表示法逐一讲透,每一种是怎样设计的、代码怎么实现、适合放在什么场景,全部给出完整可编译的C语言实现。适合刚学数据结构的学生,也适合工作几年后想系统地补基础、或者要处理文件目录遍历、设备树解析、表达式树这类真实需求的开发同学参考。
我第一次把这三种表示方法真正想明白,是在做一个小型文件系统遍历工具的时候。一开始对着“树”这种结构发愁,后来把三种存储方式各写了一遍,才发现它们本质上是同一件事的三种取舍:你要快速找到父节点,还是快速找到孩子节点,还是要一种能统一适配各种树形算法的通用表示。没有哪种是绝对好的,只有合不合适。这篇我把源码、测试过程、踩坑记录全部放出来,供你对照着动手跑一遍。
1. 树到底该怎么存:三种表示方法的设计思路
1.1 先从一棵最简单的树说起
在讨论“怎么存”之前,得先把“存什么”这件事说清楚。一棵树由若干节点构成,节点之间有明确的父子关系,每个节点最多只能有一个父节点,但可以有任意多个子节点。叶子节点是没有子节点的节点,根节点是没有父节点的节点,同一个父节点下的几个节点互为兄弟。
举个例子,下面这棵树非常典型:
A / \ B C / / \ D E FA是根,B和C是A的孩子,D是B的孩子,E和F是C的孩子。B和C互为兄弟,E和F也互为兄弟。
逻辑结构很简单,但存储的问题立刻出现了。如果每个节点都固定留两个指针,那这棵树跟二叉树没区别;可现实里一个节点可能有三个、五个甚至二十个孩子。“到底预留多少空间才够”就成了一道没有标准答案的题。预留少了,节点多了放不下;预留多了,又浪费内存。这种“分支数不确定”带来的存储尴尬,就是我们需要多种表示方法的根本原因。
1.2 三种方法的共同出发点
既然树的形状不固定,那核心思路就只剩下一条:把不固定的关系,用一种固定的、计算机好处理的方式存下来。围绕这个目标,常见的解法和设计思路主要有三种,也是数据结构教材里最常见的三种:
第一种是双亲表示法,核心思路是“找爸爸”。因为树里每个节点只有一个父亲,这是一个非常稳定的信息,所以只需要给每个节点额外记录一个“父亲是谁”的下标就可以了。
第二种是孩子表示法,核心思路是“记儿子”。既然孩子数量不确定,那就用动态的链表,把每个节点的所有孩子串起来,谁有几个孩子就存几个,不浪费空间。
第三种是孩子兄弟表示法,核心思路是“把树变成二叉树”。每个节点只记录两个关键信息:第一个孩子是谁、下一个兄弟是谁,这样不管原来有多少个分支,最终都能统一成一种二叉结构。
这三种方法,前两种保留了“树的原貌”,第三种干脆把多叉树降维成了二叉树。理解这一层,你后面学B树、字典树、表达式树、哈夫曼树、设备树解析之类的具体应用时,就会轻松很多——因为万变不离其宗,它们都要回答同一个问题:这棵树在内存里怎么放。
2. 双亲表示法:找爸爸最快的存储方案
2.1 核心思想与结构设计
双亲表示法的设计思路非常朴素:每个节点只有唯一的父节点,那我用一个数组把所有节点按顺序存下来,每个节点里不只有自己的数据,还记录一个“父节点在数组中的下标”。根节点没有父节点,所以它的父下标用-1表示。
这种存储方式完全不需要指针,也不需要链表,只需要一组连续的内存空间。它牺牲了“直接找孩子”的能力,换来了“直接找爸爸”的高效率。只要给我一个节点的位置,我马上就能通过它记录的父节点下标,找到它的父亲,时间复杂度是O(1),这是三种方法里找父节点最快的。
结构体定义可以这样设计:
#define MAX_TREE_SIZE 100 // 树节点:数据 + 父节点下标 typedef struct { char data; // 节点数据,这里用 char 做演示 int parent; // 父节点在数组中的下标,根节点为 -1 } PTNode; // 整棵树:本质上就是一个节点数组 + 当前的节点个数 typedef struct { PTNode nodes[MAX_TREE_SIZE]; int n; // 当前节点数 } PTree;数据域你完全可以根据业务需求改成int、结构体、字符串等其他类型。这里用char,是为了让代码运行结果看起来更直观。
2.2 完整代码实现
光说理论没意思,我直接放一段可以编译运行的完整代码。这段代码实现的功能是:初始化一棵树、向树里添加节点、指定父节点下标来建立父子关系、打印每个节点的父子关系、查找某个节点的所有孩子。
#include <stdio.h> #define MAX_TREE_SIZE 100 #define INVALID -1 typedef struct { char data; int parent; } PTNode; typedef struct { PTNode nodes[MAX_TREE_SIZE]; int n; } PTree; // 初始化空树 void InitTree(PTree* T) { T->n = 0; } // 向树中添加一个节点,返回新节点在数组中的下标 int AddNode(PTree* T, char data, int parentIndex) { if (T->n >= MAX_TREE_SIZE) { printf("树已满,无法添加节点 %c\n", data); return INVALID; } int index = T->n; T->nodes[index].data = data; T->nodes[index].parent = parentIndex; T->n++; return index; } // 打印整棵树的父子关系 void PrintTree(PTree* T) { printf("下标\t数据\t父节点下标\n"); for (int i = 0; i < T->n; i++) { printf("%d\t%c\t%d\n", i, T->nodes[i].data, T->nodes[i].parent); } } // 查找某个节点的所有孩子,打印出来 void PrintChildren(PTree* T, int index) { if (index < 0 || index >= T->n) { printf("节点下标非法\n"); return; } printf("节点 %c 的孩子:", T->nodes[index].data); int hasChild = 0; for (int i = 0; i < T->n; i++) { if (T->nodes[i].parent == index) { printf("%c ", T->nodes[i].data); hasChild = 1; } } if (!hasChild) { printf("无"); } printf("\n"); } int main() { PTree T; InitTree(&T); int root = AddNode(&T, 'A', INVALID); int b = AddNode(&T, 'B', root); int c = AddNode(&T, 'C', root); int d = AddNode(&T, 'D', b); int e = AddNode(&T, 'E', c); int f = AddNode(&T, 'F', c); PrintTree(&T); PrintChildren(&T, root); PrintChildren(&T, c); return 0; }运行结果很直观:数组下标0到5依次对应A、B、C、D、E、F,父节点下标则清楚标记了每个节点的来源。A的父下标是-1,B和C的父下标都是0,D的父下标是1,E和F的父下标都是2。
2.3 这样存有什么好处,又有什么坑
双亲表示法最大的优势是:找父亲极快,且结构极其简单,不需要动态内存管理,不会出现内存泄漏、悬空指针这类问题。在一些频繁向上追溯的场景中,这种表示法非常合适。比如并查集,本质上就是一个简化版的双亲表示法——每个集合的代表元素当作树根,其他元素记录“上级是谁”。
但它也有明显的短板:找孩子必须遍历整个数组,时间复杂度是O(n)。如果你频繁需要“一个节点下面有哪些子节点”,这种结构就不太舒服。另外,它按物理顺序把节点塞进数组,这棵树本身在逻辑上是乱的,你无法从数组顺序直接还原出树的层次结构。也就是说,双亲表示法很适合“维护隶属关系”,但不太适合“按层次遍历”。
实操中有一个容易踩的坑:父节点下标必须小心处理,千万别用0当“无父节点”的哨兵值,因为数组下标0是合法位置。根节点的parent一定要设成-1,否则后面遍历逻辑里可能会出现一个莫名其妙的“根节点的父亲是下标0的节点”,排查起来很费劲。
3. 孩子表示法:把“孩子”串起来
3.1 核心思想与结构设计
既然双亲表示法的痛点是找孩子太慢,那就自然有人想到反过来:干脆把每个节点的“孩子列表”直接存下来。这就是孩子表示法的思路。
实现方式通常是把数组和链表结合:整棵树仍然有一个节点数组,但每个数组元素不只是存数据了,它还带一个指针,指向该节点的第一个孩子;每个孩子节点又通过自己的另一个指针指向下一个兄弟。这样,绕着某个节点的孩子链表走一圈,就能拿到它的所有孩子。
这种方式把“动态的孩子数量”交给了链表处理,内存使用非常灵活。它不需要提前预设每个节点有几个孩子,也不会浪费大量空闲指针空间。结构体如下:
#define MAX_TREE_SIZE 100 // 孩子链表节点:存孩子下标 + 指向下一个孩子 typedef struct CTNode { int child; // 孩子在数组中的下标 struct CTNode* next; // 指向下一个孩子节点 } CTNode; // 树节点:存数据 + 指向孩子链表的头指针 typedef struct { char data; CTNode* firstChild; // 第一个孩子 } CTBox; // 整棵树 typedef struct { CTBox nodes[MAX_TREE_SIZE]; int n; } CTree;需要提一句,很多教材还会在这个结构上再加一个parent域,构成“带双亲的孩子链表”。那样既保留找孩子快的能力,又弥补找父亲慢的短板,代价就是每个节点多存一个int,属于典型的时间换空间的取舍。这篇文章先按不带parent的经典版讲,哪天你真需要双向找,加一个字段就行,逻辑完全一样。
3.2 完整代码实现
下面这段代码构建一棵和孩子表示法匹配的树,并支持打印某个节点的孩子、按先根顺序遍历整棵树。注意释放内存的细节,我在代码里做了相应处理。
#include <stdio.h> #include <stdlib.h> #define MAX_TREE_SIZE 100 typedef struct CTNode { int child; struct CTNode* next; } CTNode; typedef struct { char data; CTNode* firstChild; } CTBox; typedef struct { CTBox nodes[MAX_TREE_SIZE]; int n; } CTree; // 初始化 void InitTree(CTree* T) { T->n = 0; for (int i = 0; i < MAX_TREE_SIZE; i++) { T->nodes[i].firstChild = NULL; } } // 添加节点 int AddNode(CTree* T, char data) { if (T->n >= MAX_TREE_SIZE) { printf("树已满\n"); return -1; } int index = T->n; T->nodes[index].data = data; T->nodes[index].firstChild = NULL; T->n++; return index; } // 把 childIndex 挂到 parentIndex 的孩子链表中 void AddChild(CTree* T, int parentIndex, int childIndex) { if (parentIndex < 0 || parentIndex >= T->n || childIndex < 0 || childIndex >= T->n) { printf("下标非法\n"); return; } CTNode* node = (CTNode*)malloc(sizeof(CTNode)); node->child = childIndex; node->next = NULL; if (T->nodes[parentIndex].firstChild == NULL) { T->nodes[parentIndex].firstChild = node; } else { // 尾插,保持孩子顺序 CTNode* p = T->nodes[parentIndex].firstChild; while (p->next != NULL) { p = p->next; } p->next = node; } } // 打印某个节点的孩子 void PrintChildren(CTree* T, int index) { if (index < 0 || index >= T->n) { printf("下标非法\n"); return; } printf("节点 %c 的孩子:", T->nodes[index].data); CTNode* p = T->nodes[index].firstChild; if (p == NULL) { printf("无\n"); return; } while (p != NULL) { printf("%c ", T->nodes[p->child].data); p = p->next; } printf("\n"); } // 先根遍历整棵树:输出根节点,再依次遍历每棵子树 void PreOrder(CTree* T, int rootIndex) { if (rootIndex < 0 || rootIndex >= T->n) { return; } printf("%c ", T->nodes[rootIndex].data); CTNode* p = T->nodes[rootIndex].firstChild; while (p != NULL) { PreOrder(T, p->child); p = p->next; } } // 释放整棵树的所有孩子链表内存 void FreeTree(CTree* T) { for (int i = 0; i < T->n; i++) { CTNode* p = T->nodes[i].firstChild; while (p != NULL) { CTNode* tmp = p; p = p->next; free(tmp); } T->nodes[i].firstChild = NULL; } } int main() { CTree T; InitTree(&T); int root = AddNode(&T, 'A'); int b = AddNode(&T, 'B'); int c = AddNode(&T, 'C'); int d = AddNode(&T, 'D'); int e = AddNode(&T, 'E'); int f = AddNode(&T, 'F'); AddChild(&T, root, b); AddChild(&T, root, c); AddChild(&T, b, d); AddChild(&T, c, e); AddChild(&T, c, f); PrintChildren(&T, root); PrintChildren(&T, c); printf("先根遍历:"); PreOrder(&T, root); printf("\n"); FreeTree(&T); return 0; }运行这段代码,你会看到A的孩子是B和C,C的孩子是E和F,先根遍历输出的顺序是A B D C E F,和树本身的层次结构完全对应。
3.3 优缺点与典型场景
孩子表示法最大的优点,是找一个节点下的所有子树特别快,直接扫链表就行。因为每个节点的孩子链表独立存储,树的结构也一目了然。这种特性让它非常适合做“自上而下”的递归遍历操作,比如文件目录树的展开、组织架构的向下查询、菜单树渲染等。
它的缺点也很明显:找父节点麻烦。每个节点没有记录父亲,想找父亲就只能把所有节点扫一遍,时间复杂度高。另一个问题是引入了动态内存分配,代码写起来比纯数组复杂,一个不留神就会造成内存泄漏。
实际操作中,我建议你在确保逻辑正确后,一定要把这棵树的释放函数写好。尤其是用孩子链表时,很多人只释放了节点数组,忘了释放链表上的每个孩子节点块,跑一次测试看不出来,长时间运行内存就会越涨越高。上面代码里的FreeTree就是专门做这件事的。
4. 孩子兄弟表示法:把树变成一棵二叉树
4.1 核心思想与结构设计
前两种方法都是在“树”的框架内想办法,孩子兄弟表示法换了一个更取巧的角度:把多叉树强行转成二叉树。
它的做法是,每个节点只保留两个指针:firstChild指向自己的第一个孩子,nextSibling指向自己的下一个兄弟。这样一来,一棵五叉树、十叉树,都会被组织成一种标准的二叉链表结构。比如前面那棵树的节点B,它的第一个孩子是D,下一个兄弟是C,于是B的左指针指向D,右指针指向C。
这种转换带来的好处非常明显:所有二叉树的成熟算法——先序、中序、后序遍历,线索化,按层遍历,甚至AVL树、红黑树里的一些调整思路——都可以直接拿过来用。对于复杂树形结构的算法研究和工程实现来说,这是一种非常优雅的降维手段。
结构体定义如下:
typedef struct CSNode { char data; struct CSNode* firstChild; // 第一个孩子 struct CSNode* nextSibling; // 下一个兄弟 } CSNode;这里我用的是动态指针,而非数组下标。因为它本身就是一个二叉链表,用指针来表达两个方向的关系最自然。如果后面需要加数据域、加父指针,思路也完全一样。
4.2 完整代码实现
下面这段代码构建同样的A、B、C、D、E、F树,再把每个节点的孩子和兄弟关系建立起来,最后用类似二叉树先序遍历的方式输出全部节点。为什么说是“类似”?因为这里的结构虽然在物理上是二叉链表,逻辑含义仍然是多叉树,遍历时你要按“先根、再所有孩子”的顺序来写。
#include <stdio.h> #include <stdlib.h> typedef struct CSNode { char data; struct CSNode* firstChild; struct CSNode* nextSibling; } CSNode; // 创建单个节点 CSNode* CreateNode(char data) { CSNode* node = (CSNode*)malloc(sizeof(CSNode)); node->data = data; node->firstChild = NULL; node->nextSibling = NULL; return node; } // 在父节点下追加一个孩子节点 void AddChild(CSNode* parent, CSNode* child) { if (parent->firstChild == NULL) { parent->firstChild = child; } else { CSNode* p = parent->firstChild; while (p->nextSibling != NULL) { p = p->nextSibling; } p->nextSibling = child; } } // 先根遍历:输出当前节点,然后遍历所有孩子 void PreOrder(CSNode* root) { if (root == NULL) { return; } printf("%c ", root->data); CSNode* p = root->firstChild; while (p != NULL) { PreOrder(p); p = p->nextSibling; } } // 后根遍历:先遍历所有孩子,最后输出当前节点 void PostOrder(CSNode* root) { if (root == NULL) { return; } CSNode* p = root->firstChild; while (p != NULL) { PostOrder(p); p = p->nextSibling; } printf("%c ", root->data); } // 释放整棵树 void FreeTree(CSNode* root) { if (root == NULL) { return; } FreeTree(root->firstChild); FreeTree(root->nextSibling); free(root); } int main() { CSNode* A = CreateNode('A'); CSNode* B = CreateNode('B'); CSNode* C = CreateNode('C'); CSNode* D = CreateNode('D'); CSNode* E = CreateNode('E'); CSNode* F = CreateNode('F'); AddChild(A, B); AddChild(A, C); AddChild(B, D); AddChild(C, E); AddChild(C, F); printf("先根遍历:"); PreOrder(A); printf("\n"); printf("后根遍历:"); PostOrder(A); printf("\n"); FreeTree(A); return 0; }这段代码的核心点在于AddChild函数。它先判断父节点有没有孩子,如果没有,直接把新孩子挂在firstChild上;如果有,就沿着孩子的nextSibling链走到末尾,再挂上去。细心的读者会发现,这其实就是“兄弟链表的尾插法”,它保证了同层节点的顺序和插入顺序一致。
4.3 三种表示法在项目里怎么选
每次我在工程里需要选择树形存储方案时,都会把三种方法的关键特性拉出来对比一遍。这里直接整理成一张表,方便你查阅:
| 表示方法 | 找父节点 | 找孩子节点 | 空间特点 | 适合场景 | 代码复杂度 |
|---|---|---|---|---|---|
| 双亲表示法 | O(1) | O(n)遍历数组 | 紧凑,固定数组 | 并查集、隶属关系查询、设备树静态配置 | 低 |
| 孩子表示法 | 需要遍历 | O(k)扫链表 | 每个节点带一个链表,动态分配 | 文件目录、菜单渲染、组织架构 | 中等 |
| 孩子兄弟表示法 | 需另加parent域 | O(k)扫兄弟链 | 二叉链表,每个节点两个指针 | 表达式树、通用树形算法、森林转换 | 中等偏上 |
如果你面对的问题主要是“这个节点属于谁、它的上级链是什么”,用双亲表示法最省事;如果是“给我遍历出这棵树的所有子节点”,孩子表示法更顺手;如果你不只处理一棵树,还要处理森林、或者要做大量树的算法变形,那孩子兄弟表示法几乎是不二之选。很多系统里的目录树、UI组件树、语法分析中的表达式树,底层结构都跟孩子兄弟表示法思路一致。
5. 实操过程:用三种方法实现同一棵树
5.1 测试树的搭建与运行
写数据结构代码,光看理论容易眼高手低。我建议你拿到代码后,第一步先别急着看输出,而是自己动手构建一棵树,然后分别用三种表示法实现一遍。我这次用的测试树就是前面反复出现的那一棵:A为根,B、C为A的孩子,D为B的孩子,E、F为C的孩子。
三种代码我都编译运行过,环境是Linux下的gcc,直接用gcc编译即可,比如:
gcc parent_method.c -o parent_method ./parent_method三个程序的核心输出如下:
- 双亲表示法:打印出一张节点表,清楚展示每个节点的父节点下标,比如D的父下标是1。
- 孩子表示法:打印出A的孩子为B、C,C的孩子为E、F,同时先根遍历输出A B D C E F。
- 孩子兄弟表示法:先根遍历输出A B D C E F,后根遍历输出D B E F C A。
前后根遍历的结果很有意思。先根遍历结果就是树的自然自上而下顺序,后根遍历则是“先子树后根”。如果你把后根遍历结果和双亲表示法中的父子关系对照,会更容易理解树结构的递归性质。
5.2 三种实现结果的横向对比
同样一棵树,三种代码的写法完全不同,但表达的逻辑关系完全一致。这就是数据结构课程里经常强调的“逻辑结构”与“存储结构”分离:树的逻辑形态是A下面有B和C,这是确定的;至于用数组存父亲下标、用链表存孩子、还是用二叉链表存孩子和兄弟,都是存储层面的选择。
我实践中发现一个特别值得体会的点:孩子兄弟表示法的先根遍历代码最短,只有几行递归,却能处理任意复杂的树形结构。这正是“降维”带来的收益——把不规律的多叉树变成规律的二叉树之后,递归逻辑变得非常统一。很多高性能的树形处理库,底层处理逻辑都遵循这个设计哲学。
反过来,双亲表示法的打印逻辑也很简洁,但是它没法做这种递归遍历——你想从根节点出发走到所有叶子,就必须在数组里反复扫,效率层面明显吃亏。这说明了一个很朴素的规律:数据结构的选择,本质上就是在选“哪些操作要快、哪些操作可以慢”。
5.3 内存细节与稳定性
三种方法里,双亲表示法完全不用malloc,所以不存在内存泄漏问题;孩子表示法和孩子兄弟表示法都用到了动态内存,释放顺序就得特别注意。
我在最初写孩子兄弟表示法的FreeTree时,犯过一个特别典型的错误:先free了根节点,再去递归释放它的孩子和兄弟。结果程序跑起来就崩,因为free之后再去访问root->firstChild已经属于悬空指针操作。正确的顺序是:先递归释放左子树和右子树,最后释放根节点。这跟二叉树后序遍历的顺序是吻合的。
孩子表示法的FreeTree也容易踩坑:不能只遍历节点数组,把firstChild对应的链表释放完就急着结束,因为有的链表节点可能被子节点复用或引用混乱。更安全的做法是在释放一个节点时,先从头到尾释放它的孩子链表,再把该节点对应数组位置的firstChild置空,避免二次释放。
另外一个细节:孩子表示法用尾插法维持孩子顺序时,每次都要从头遍历链表找尾节点,时间复杂度是O(k)。如果插入操作特别频繁,可以额外维护一个tail指针,或者用头插法牺牲顺序来换效率。面试里聊到这种问题,能主动说出这个权衡,通常会让面试官印象更深。
6. 常见问题与排查技巧实录
6.1 我踩过的几个坑
第一个坑是根节点父下标设置。用双亲表示法的时候,有人习惯把根节点的parent设成0或-1,觉得无所谓。实际上如果设成0,就相当于把根节点自己当成了自己的父亲,遍历或查找祖先节点时会陷入死循环。强烈建议统一用-1作为“无父节点”的哨兵值,因为数组下标0是合法数据位置,不能污染。
第二个坑是孩子表示法的内存释放。我最早写的时候只释放了每个节点的firstChild链表,没把所有孩子节点都释放干净。后来用valgrind查内存泄漏,一排红字,才意识到孩子链表里每个CTNode都是独立malloc出来的,必须逐个free。这一点在做长生命周期服务时特别重要,比如一个常驻的进程反复构建和销毁树结构,内存泄漏会越积越多,最后只能重启服务来缓解。
第三个坑是孩子兄弟表示法的遍历顺序。很多人一看到二叉链表,就按照二叉树的中序遍历去写,结果输出完全不对。原因在于,孩子兄弟表示法虽然物理形态是二叉树,但语义仍然是“第一个孩子”和“下一个兄弟”,不是“左子树”和“右子树”。遍历时一定要清楚:firstChild方向走的是深度方向,nextSibling方向走的是广度方向。写错了,整棵树的顺序就全乱了。
6.2 常见问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 双亲表示法打印时出现“根节点的父亲是0号节点” | 根节点parent设成了0而不是-1 | 统一使用-1作为空父节点标记 |
| 孩子表示法遍历时少了某些节点 | 插入孩子时用头插法或尾插法逻辑混乱 | 明确头插和尾插的适用场景,保持插入方式一致 |
| 程序运行一段时间后内存持续上涨 | 孩子链表或兄弟链表的节点没有释放 | 遍历整棵树,逐个free动态分配的节点 |
| 孩子兄弟表示法输出顺序和预期不一致 | 错误使用二叉树遍历思路 | 先根时先访问当前节点,再沿firstChild进入深度方向,最后沿nextSibling进入广度方向 |
| 节点数量大时双亲表示法找孩子极慢 | 该场景频繁访问子节点,双亲表示法不占优 | 更换为孩子表示法或孩子兄弟表示法 |
还有一点补充,关于“如何把一种表示法转换成另一种”。这在实际工程里挺常见的,比如你拿到一个设备树的静态配置,内部是类似键值对的层级结构,你要在内存里构建成方便遍历的树。我的习惯是:先建一个孩子表示法的中间结构,把孩子关系明确存储下来,然后再转换成其他存储方式。因为孩子表示法最贴近人对树形结构的直觉,转换逻辑最能保证不出错。
6.3 几个能让你少走弯路的小技巧
确定使用哪种表示法之前,先把你最频繁的操作列出来。如果你有80%的操作都是“从叶子节点往上找祖先”,那双亲表示法就是最优解;反之,如果你主要做“从根出发,递归扫描整棵树”,孩子表示法或孩子兄弟表示法更合适。不要一上来就套用某个模板,存储结构的选择应该跟着操作走。
如果需要在一个项目里同时用到多种表示法,可以在结构体里预留一个“类型标记”字段,比如用枚举标识当前树采用哪种存储策略,这样后续扩展或转换时会轻松很多。真实项目里,一棵树在运行过程中也可能发生结构变化,从静态配置变成动态增删,这时表示法本身可能也需要随之调整。
写测试用例时,我习惯用很小的树先验证逻辑,比如只有两三个节点的树,甚至是一个只有根节点的树。边界条件往往最容易暴露问题:根节点的父亲是不是-1?叶子节点的孩子链表是不是空?空树遍历时会不会崩溃?把这些边界情况过一遍,代码的稳定性能提升一大截。
6.4 三种表示法之外的延伸思考
聊到这里,你可能会发现,树形结构的存储本身就是一个“空间换时间、时间换空间”的经典权衡问题。双亲表示法用固定数组换来了极简的内存管理;孩子表示法用链表换来了灵活的孩子扩展;孩子兄弟表示法用统一的二叉结构换来了算法的通用性。这些思想不只适用于“树”本身。
回过头看,B树为什么每一层存多个关键字、为什么节点分裂合并?字典树为什么用字符作为路径而不是在节点里存字符串?设备树解析、语法分析里的表达式树,又是怎么在内存中构建和遍历的?它们处理的具体问题不同,但底层都绕不开“如何存储节点、如何表达节点之间的关系”这一核心命题。把三种基础表示方法吃透,再看这些高阶结构,你会觉得它们不过是在这个思路上加了各自的业务规则而已。
我个人在实际项目里最常用的还是孩子兄弟表示法,因为它的通用性最强,能兼容我后续对树做的各种算法扩展。但如果是快速写个原型或者处理简单的父子关系查询,我直接用双亲表示法,代码量最少、也最容易调试。最后分享一个小技巧:在调试树形代码时,养成先画图再写码的习惯,把树画出来,哪一步遍历输出不对,对照图一眼就能看出来,比自己盯着代码干想高效得多。