树的存储结构一直是一个让初学者挠头的话题。我记得第一次接触二叉树时,满脑子都是“这玩意到底在内存里长什么样”的疑惑,链表、数组这些线性结构闭着眼睛都能画出来,但树一旦分支多起来,内存布局就成了抽象概念。
后来在实际项目里用树形结构做组织和权限管理时,才真正理解存储结构选型的重要性——选对了,业务逻辑顺滑如丝;选错了,维护成本和遍历复杂度能让你深夜怀疑人生。本文就围绕树的三种经典存储结构展开,把原理讲透,把实现代码贴出来,再用实际踩过的坑帮大家把那些文档里不会写的细节补齐。
1. 三种经典存储结构的底层逻辑对比
1.1 双亲表示法——用数组搞定“向上找爹”的需求
双亲表示法的核心思想很简单:既然每个节点只有一个爹(根节点除外),那我只需要保存每个节点本身的数据,外加它爹在数组里的下标,就足够表达整棵树的结构了。
具体实现就是用一维数组顺序存储所有节点,每个节点是一个结构体,包含数据域和双亲域:
#define MAX_TREE_SIZE 100 typedef struct { int data; // 数据域 int parent; // 双亲位置域,记录父节点在数组中的下标 } PTNode; typedef struct { PTNode nodes[MAX_TREE_SIZE]; int n; // 当前节点总数 } PTree;这种存储结构的最大优点是找“爹”非常快——时间复杂度直接就是O(1),因为父节点的位置就存在自己身上。这在判断两个节点是否属于同一棵树时会派上大用场,只需要不断向上回溯到根节点,然后比较根节点下标是否一致即可,不需要遍历整棵树。我做某高校组织架构系统时,靠这个特性几行代码就实现了部门归属校验,效率让同事刮目相看。
缺点也非常明显:找孩子就很痛苦。你想我为了找到某个节点的所有孩子,得遍历整个数组,挨个检查谁的parent等于当前节点的下标,时间复杂度是O(n)。如果一棵树有上万个节点,每次找孩子都扫一遍数组,即使是现代CPU也顶不住高频调用。
实际操作中还有一个容易被忽略的点——即使只是新增一个节点,也需要提前确定它在数组中的位置。如果树是动态插入的,数组的长度管理和下标分配就变成了一个重要课题。我的习惯是另外维护一个空闲下标栈,专门记录哪些位置被释放过,下次插入时优先复用这些空洞,避免数组尾部无限增长。
1.2 孩子表示法——把每个节点的孩子串成链表
既然双亲表示法找孩子太费劲,那我们把每个节点的孩子信息直接邻接存储不就行了?这就是孩子表示法诞生的逻辑。它的做法是:每个节点除了数据域,还挂一个单链表,链表中每个结点就是该节点的一个孩子。
#define MAX_TREE_SIZE 100 typedef struct ChildNode { int child; // 孩子在数组中的下标 struct ChildNode *next; // 指向下一个兄弟孩子 } ChildNode; typedef struct { int data; ChildNode *firstChild; // 第一个孩子结点的指针 } CTNode; typedef struct { CTNode nodes[MAX_TREE_SIZE]; int n; } CTree;这里有个关键抉择:孩子们是按下标升序串,还是按插入顺序串?这直接决定了后续遍历时孩子访问的先后顺序。我在实际业务系统中发现,很多场景下孩子节点有明确的业务顺序,比如菜单排序、目录顺序,此时必须在插入时维护链表顺序,而不能简单地头插法了事。我踩过这个坑:一开始图方便用头插法,结果整棵树的节点顺序全是倒着的,前端渲染菜单时全都乱七八糟,后来改为尾插并增加一个尾指针,问题才解决。
孩子表示法最大的优势是找孩子特别利索,顺着链表走就能拿到所有直接孩子,时间复杂度是O(度)。同时它天然适配稀疏树结构——节点分支数差异很大的场景也不会浪费太多内存。但反向的代价就是找爹不便,因为孩子结点里只存了自己的下标,并没有存父亲是谁,想找爹就得从根节点一路找下来,对于频繁向上回溯的操作,这个成本是无法接受的。
1.3 孩子兄弟表示法——树的“二叉树化”关键路径
前面两种方法在实际工程中都会遇到“找兄弟要遍历父亲的孩子链表”的问题。有没有一种办法,把找孩子、找兄弟、找父亲都变成接近O(1)的操作?答案就是孩子兄弟表示法,也叫二叉链表表示法。
这个方法的思路非常巧妙:每个节点只保留两个指针——firstChild指向自己的第一个孩子,nextSibling指向自己的下一个兄弟。用这种方式,任意普通树都能被唯一地转换成一棵二叉树。
typedef struct CSNode { int data; struct CSNode *firstChild; // 指向第一个孩子 struct CSNode *nextSibling; // 指向下一个兄弟 } CSNode, *CSTree;这种结构在树的存储领域地位极高,因为它让普通树和二叉树之间建立了自然对应关系。标准库和很多框架在处理层级数据时,底层转换逻辑都有它的影子。把一个多分支的菜单树用这种方式存储后,就能直接套用二叉树的遍历算法来处理,大幅简化了代码复杂度。
从内存角度看,孩子兄弟表示法也是三种结构里最省空间的——每个节点只需要两个指针,而对于n叉链表(每个节点存n个孩子指针),大量指针是空的,浪费严重。所以如果你对空间的敏感度比较高,孩子兄弟表示法会是不错的选型。
1.4 三种结构横向对比速查表
为了让你在选型时心里有底,我整理了一个对比表:
| 存储结构 | 找父亲 | 找孩子 | 找兄弟 | 空间开销 | 典型应用场景 |
|---|---|---|---|---|---|
| 双亲表示法 | O(1) | O(n) | O(n) | 较省 | 并查集、分类归属校验 |
| 孩子表示法 | O(n) | O(度) | O(度) | 视孩子链表而定 | 菜单树、目录树 |
| 孩子兄弟表示法 | O(1)(借助父指针) | O(1)(第一个孩子) | O(1) | 较省 | 通用树转二叉树、文件系统 |
实际工程里没人会抱着一种结构用到死。我的做法是:先画清楚业务的核心操作是“向上回溯”还是“向下展开”,然后挑选对应优势的结构,再通过增加辅助指针来弥补它的短板。比如双亲表示法为了弥补找孩子的劣势,可以额外挂一个孩子链表的索引——其实就是孩子表示法和双亲表示法的混合体,这种场景驱动式的结构设计思路,比死记硬背任何一种标准结构都实用得多。
2. 实操环节——从树到二叉树的转换细节
2.1 为什么要把树转换成一棵二叉树
很多初学者不理解:我好好的树结构不用,非得转成二叉树干什么?这是被考试题折磨多了之后留下的应激反应,但实际上这个转换在真实项目中非常重要。
原因有两个。第一,二叉树是“标准件”,算法体系成熟得不能再成熟,先序、中序、后序、层序遍历都有现成的实现和成熟的复杂度分析,各种平衡树、堆、搜索树都建立在二叉树之上,转成二叉树就等于接入了一整个算法生态。第二,存储上更加紧凑,因为孩子兄弟表示法不浪费指针,却表达了一棵任意多叉树的完整信息。
我在做图片分类系统时,一个分类下可能有几十个子分类,用多叉树存储非常占内存,转成孩子兄弟表示法后的二叉树之后,内存占用降低了将近一半,处理速度反而更快了,因为遍历时只需处理两个指针域而不是N个。
2.2 孩子兄弟表示法转二叉树的关键步骤
转换规则其实就一句话:左指针指向第一个孩子,右指针指向下一个兄弟。听起来简单,但操作时有不少细节需要注意。
第一步,确定树的根节点作为二叉树的根节点,它的firstChild指针指向树根的第一个孩子。
第二步,递归处理每个节点:当前节点的firstChild指针保持不变,指向第一个孩子。当前节点的每个孩子,它的nextSibling指针要作为二叉树的右子树处理。
第三步,每一个孩子节点继续递归重复上述过程。
void convertToBinaryTree(CSTree tree) { if (tree == NULL) return; // 将每个孩子视为右子树的前驱条件 CSTree child = tree->firstChild; while (child != NULL) { convertToBinaryTree(child); child = child->nextSibling; } }这里有一个特别容易踩的坑:转换完成后,原来“右指针”的含义从“下一个兄弟”变成了“二叉树的右孩子”,遍历时语义完全不同。很多人转换完之后下意识沿用旧逻辑,导致访问顺序错乱。我的做法是转换完成后加一个断言函数,专门校验每个节点的左右子树是否符合二叉树定义,尤其是不能出现“左子树为空但右子树还有孩子”这种异常形态。
2.3 转换后的遍历顺序变化规律
转换完成后,遍历顺序会发生有趣的变化。对于一棵树来说,它的先序遍历结果和转换后的二叉树先序遍历结果一致——这个性质在考试中反复出现,实际工程中同样有它的价值:你可以用树的原始先序序列来校验转换后的二叉树是否保持正确。
后序遍历也有对应规律:树的后序遍历等同于转换后二叉树的中序遍历。这个结论初看反直觉,理解起来却很简单——因为树的后序顺序是“从左到右后序遍历每个孩子,最后访问根”,而转换后的二叉树中序遍历恰好是“先访问左子树(最左边的孩子),再访问根,最后访问右子树(下一个兄弟)”。搞懂这个规律后,你在不同表示法之间切换时就不用再逐个验证遍历结果了。
2.4 节点删除时的指针悬挂问题
转换过程中最考验功底的是删除操作。在普通二叉树中删除节点只需处理左右两个孩子,在孩子兄弟表示法中删除一个节点意味着它的所有孩子都要重新挂接,这个过程极容易产生指针悬挂。
例如,删除节点D,它有三个孩子E、F、G。E作为D的firstChild进入二叉树左子树,F和G通过右指针串联。删除D后,E、F、G这三个节点如何处理?正确答案是:将E作为D父节点的新firstChild,同时F和G依然通过右指针挂在E的右链上。这个接法看起来是一个简单步骤,但实际执行时E的右链原本可能已经挂了一堆兄弟节点,万一E原有右链非空,需要先遍历到链尾再拼接,否则链表结构直接丢数据。
我写过一个删除操作的辅助函数,专门维护这些指针关系,经过多次重构才稳定下来。核心代码片段大致如下:
void deleteNode(CSTree *root, int target) { if (*root == NULL) return; if ((*root)->data == target) { // 实际场景中还要处理父节点的指针更新 *root = NULL; return; } // 递归删除左子树和右子树,但注意右子树实际是兄弟链 deleteNode(&((*root)->firstChild), target); deleteNode(&((*root)->nextSibling), target); }这种递归删除实现简单,但在树深度较大时有爆栈风险。生产环境中我一般会改成显式栈的迭代版本,虽然代码长一些,但稳得多。
3. 存储结构之上的遍历实现与算法推演
3.1 双亲表示法下的层序遍历思路
双亲表示法最直观的遍历方式是层序遍历。因为数组中节点的顺序可以按层存放,按序访问数组就能天然得到层序序列。但如果插入顺序被打乱,你需要额外引入队列来完成。
实际操作时,层序遍历的逻辑是:从根节点开始,逐个扫描数组,找出所有parent等于当前节点的节点,全部入队。然后队首出队,继续扫描。你会发现每次扫描都是O(n),整棵树层序遍历的时间复杂度会退化为O(n²)。这个复杂度在实际生产环境是不可接受的,尤其是数据量过万时性能会急剧恶化。
所以,层序遍历是双亲表示法的弱项。如果系统主操作确实是层序展开,我建议换用孩子表示法,或者干脆用孩子兄弟表示法。这也是我项目里经常提醒团队的一句话:不要在错误的结构上硬扛,时间复杂度恶化的本质是你的数据结构没有匹配上操作需求。
3.2 孩子表示法下的深度与广度优先遍历
孩子表示法在遍历上要顺手得多,因为找孩子是它的主场。深度优先遍历(先序、后序)可以理解为对树根到叶子的递归下降:
void preOrder(CTree *tree, int rootIndex) { if (rootIndex == -1) return; printf("%d ", tree->nodes[rootIndex].data); ChildNode *child = tree->nodes[rootIndex].firstChild; while (child != NULL) { preOrder(tree, child->child); child = child->next; } }广度优先遍历则需要引入队列辅助。从根节点开始入队,队首出队后把它的所有孩子依次入队。这种孩子链表的臂展长度决定了队列的增长速度,在新手写代码时很容易出现队列溢出,我习惯在实现前先估算树的宽度,再设定队列初始容量,避免运行时动态扩容带来的无谓开销。
另外注意一个细节:孩子链表如果本身无序,遍历结果就无序。业务上如果需要稳定的顺序输出,必须在构建孩子链表时就按业务排序插入。这种“建树时就考虑遍历顺序”的思路,能帮你省掉大量后续排序工作,是工程老手和新手的分水岭。
3.3 孩子兄弟表示法下的四种遍历统一框架
孩子兄弟表示法最大的优势在于它把树的遍历统一到了二叉树的遍历框架里。树的前序遍历就等于二叉树的先序遍历,树的后序遍历就等于二叉树的中序遍历。层序遍历同样用队列实现,只是入队时要把“同一层所有兄弟”一并入队,这点需要特别注意。
void preOrderCS(CSTree root) { if (root == NULL) return; printf("%d ", root->data); preOrderCS(root->firstChild); // 遍历第一个孩子 preOrderCS(root->nextSibling); // 遍历下一个兄弟(右子树) }很多人在这一步卡壳的原因是搞混了firstChild和nextSibling的遍历语义。一个经验方法:把纸上的多叉树先画成孩子兄弟形态的二叉树,然后用二叉树那套成熟模板走一遍,最后再对照原始树的遍历顺序,你会发现规律非常自然。我强烈建议刚学到这里的朋友都亲手把这四种植入一遍,别只看代码,一定要在纸上画“树→转换→遍历”三步图,画过一遍之后记忆会牢固很多。
3.4 遍历复杂度分析与版本选型
三种存储结构下的遍历复杂度差异其实不在大O层面——只要不出现双亲表示法那种逐个扫描找孩子的情况,常规遍历都是O(n)。真正的差别在常数因子和局部性上。
| 存储结构 | 前序遍历 | 中序遍历 | 层序遍历 | 实际性能短板 |
|---|---|---|---|---|
| 双亲表示法 | 不推荐 | 不推荐 | O(n²) | 找孩子成本太高 |
| 孩子表示法 | O(n) | O(n) | O(n) | 孩子链表指针跳跃 |
| 孩子兄弟表示法 | O(n) | O(n) | O(n) | 递归深度风险 |
孩子兄弟表示法虽然形式上优雅,但递归实现时树的深度就是栈的深度。如果树退化成一条超长链表——比如一万层深——递归必然爆栈。这种场景下必须改用迭代法。我在做目录树处理时就碰到过长路径目录,递归直接段错误,改成显式栈之后问题才解决。
4. 常见问题与排查技巧实录
4.1 下标越界的隐藏陷阱——数组家长式管理的痛
双亲表示法用数组存储,下标管理是重灾区。比如插入节点时如果使用静态数组且没有记录空闲位置,删除一个节点后那个位置会变成空洞,新插入节点如果直接放尾部,数组的使用率会越来越低,最终上限很快触顶。
排查时,我会建议团队在结构体里加一个count和freeList字段。freeList是一个栈,存储所有被释放的下标。删除节点时先把它下标压栈,插入时优先从栈顶弹出一个下标复用。经过这个改造,同样的MAX_TREE_SIZE存储的节点数几乎翻倍,因为空洞全部被有效利用了。
还有一个小坑:parent字段在根节点处必须用特殊值标记,比如-1。很多人用0标记根节点,导致无法区分“根节点”和“指向下标为0的节点”。这种隐性bug极难排查,因为数据量大时偶尔报错,但复现率不高。我建议所有数组型存储结构都用-1或者一个明显越界的常量作为空指针标记,并在初始化时统一写入。
4.2 递归遍历导致的栈溢出
孩子表示法和孩子兄弟表示法都天然适合递归,但现实数据往往不讲武德。树可能退化成链式结构,递归深度直接等于节点数,一万个节点的树就能让默认栈大小告急。
排查思路是先用一个深度探测函数统计当前树的最大深度,如果超过系统栈上限就切迭代。迭代实现需要显式维护一个栈结构,以孩子兄弟表示法的中序遍历为例:
void iterativeInOrder(CSTree root) { CSTree current = root; CSTree stack[1000]; int top = -1; while (current != NULL || top != -1) { while (current != NULL) { stack[++top] = current; current = current->firstChild; } if (top != -1) { current = stack[top--]; printf("%d ", current->data); current = current->nextSibling; } } }注意这个迭代版本遍历的语义与递归版本完全相同,但栈空间由我们自己掌控。如果树特别大,可以把固定数组改成动态扩容的栈,甚至用内存池方案避免频繁分配释放。真实场景下,这一步优化往往能救回一个看起来“必挂”的模块。
4.3 孩子链表中查找兄弟的时间损耗
孩子表示法中,如果一个节点的孩子很多,查找指定顺序的兄弟节点需要从链表头开始遍历。每次操作都是O(k)(k为该节点的孩子数)。如果业务操作频繁“按顺序访问第N个孩子”,这个O(k)会变成热点。
一个实用的优化方案是在孩子链表中额外存储前驱指针,改造为双向链表。这样从最后一个孩子向前找指定位置的兄弟时也能O(1)操作。虽然多了一个指针的内存开销,但如果你后续有大量的兄弟重排、子树上移下移操作,这8个字节的代价完全值得。
我见过一个同事的菜单树,在深度只有五层的情况下因为每层孩子都上百个,遍历菜单时每次都从链头走到链尾,导致页面渲染卡顿。改成双向孩子链表之后,卡顿立刻消失。这种细粒度的结构微调,往往是性能提升的真正来源。
4.4 常见问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 插入节点后遍历缺失 | 孩子链表插入了但没有更新firstChild或表尾指针 | 检查插入函数,务必维护头指针和尾指针 |
| 层序遍历输出顺序不对 | 孩子链表顺序与业务预期不一致 | 构建孩子链表时按业务顺序尾插,不用头插 |
| 删除节点后访问野指针 | 父节点的firstChild或兄弟nextSibling未正确更新 | 编写标准的删除辅助函数,更新所有引用 |
| 递归遍历大深度树崩溃 | 树退化为链表,递归深度过大 | 统计最大深度,超过阈值改用显式栈迭代 |
| 双亲表示法插入找不到空闲位 | 删除节点的下标未回收 | 增加freeList空闲栈,删除时压栈,插入时弹栈 |
这张表是我在实际项目中沉淀下来的运维经验。项目交付半年多后,团队新成员遇到类似报错时基本靠这张表就能快速定位,不用再翻旧代码。
4.5 工程调试技巧:用可视化方式辅助排障
树形结构的调试比线性结构痛苦得多,用GDB打印几个指针根本看不出树的形状。我的经验是写一个小的导出函数,将树的节点关系转成dot语言描述的可视化图:
void exportTreeAsDot(CSTree root, FILE *fp) { if (root == NULL) return; if (root->firstChild != NULL) { fprintf(fp, " \"%d\" -> \"%d\";\n", root->data, root->firstChild->data); exportTreeAsDot(root->firstChild, fp); } if (root->nextSibling != NULL) { fprintf(fp, " \"%d\" -> \"%d\";\n", root->data, root->nextSibling->data); exportTreeAsDot(root->nextSibling, fp); } }生成dot文件后直接用Graphviz渲染成图片,节点之间的关系一目了然。这个技巧不只用于树,很多复杂结构调试都可以用类似思路。我甚至用它排查过一个隐藏很深的环形指针问题——两条兄弟链意外指向了同一个节点,在数据量小时完全没暴露,渲染成图后一眼就看出了那个异常的交叉线。
数据库索引类的树在调试级别也有类似工具,但自己导出一套就是完全定制化,不受外部工具限制,能处理任意复杂度的结构。
5. 不同存储结构的使用场景选型建议
5.1 从数据规模维度选择
如果树节点数量少于几百个,三种结构性能差异基本可以忽略,选最顺手的就行。但如果节点数突破几万,双亲表示法的O(n²)遍历成本就会变得非常明显。我在处理某个模拟业务的分类树时,节点数大约三万个,用双亲表示法做层序遍历跑一次要几百毫秒,换成孩子兄弟表示法之后,同样的操作用不了10毫秒。数据规模一上来,结构选型的差距就是数量级的差距。
节点数超过十万级时,递归实现基本被排除在外,必须用迭代遍历。此时孩子兄弟表示法的迭代版本配合显式栈和预留容量,能保持稳定的吞吐。
5.2 从操作特征维度选择
核心操作决定了存储结构。如果你需要频繁地从节点向上查找祖先,双亲表示法的O(1)性能无法替代。如果业务是从父节点向下展开子节点,那么孩子表示法或者孩子兄弟表示法更合适。如果既要向上又要向下,我建议在一种结构的基础上增加辅助索引。比如孩子兄弟表示法里每个节点再加一个parent指针,这样向上查找的复杂度降到O(1),代价只是一个指针。这是典型的空间换时间。
还有一个容易被忽略的维度是“是否需要经常移动整个子树”。这个操作在双亲表示法里近乎不可能——你需要遍历整个数组修改所有相关节点的parent值;在孩子表示法中需要修改一堆指针;在孩子兄弟表示法中,只需要修改三个指针:根节点前驱的nextSibling、父节点的firstChild或兄弟链上的前驱节点、以及该子树的根节点自身的nextSibling。所以如果你的系统需要频繁调整树结构,孩子兄弟表示法几乎是唯一合理的选择。
5.3 从内存约束维度选择
在嵌入式等内存受限环境中,每个字节都很珍贵。能用short存下标就不用int,能用一个字节存度就不用两个。孩子兄弟表示法两个指针的固定开销是确定性的,适合内存预算严格的场景。而孩子表示法在树节点普遍低度的场景下,每个孩子结点还需要存储指向下一个孩子的指针,开销反而比孩子兄弟表示法更大。
C语言里可以用位域或union进一步压缩指针占用,不过这种技巧仅在极低内存环境才值得用。我早年做过一段时间的嵌入式菜单框架,树的节点存储就用的是孩子兄弟法配合内存池,主题、菜单、子菜单一共上千个节点,整体内存占用才几十KB,运行得非常稳定。
5.4 工程实践中的选型决策框架
一个实用的决策框架,供你参考:
- 先画出系统的主要操作路径,标出每个操作的频率。
- 如果90%的操作都是向下展开,单向孩子链表结构就是基础选项。
- 如果存在大量的兄弟节点重排或子树移动,直接选孩子兄弟表示法。
- 如果存在“判断两个节点是否同根”的高频操作,双亲表示法或额外Parent指针是你必须要考虑的。
- 如果树的遍历方式明确且固定,选择对应遍历最自然的结构,减少封装层级。
树形结构在工程里很少单独存在,它往往和数组、哈希表、链表混搭使用。真正成熟的开发者不是死记住某个数据结构的标准定义,而是理解每种存储结构的性能和语义边界,再根据业务自由组合。这也是为什么很多高级面试题会聚焦在“为什么选这个结构而不是那个结构”上面——选型的思考路径,往往比最终的正确代码更能反映一个人的技术功底。
6. 几个容易忽略的编码细节与统一建议
树存储编码里还有一堆边边角角的细节,虽然不起眼但踩中一次就能折腾大半天的。这里统一列一下,都是我逐个趟过的坑。
第一点是初始化所有指针。用malloc或者new创建节点后,务必立即把所有指针域置空。因为孩子兄弟表示法的遍历高度依赖NULL作为结束条件,一个未初始化的野指针会让遍历函数跑飞,报错还算小事,要是偶发崩溃才真要命。
第二点是尽量少用递归。虽然递归代码短而直观,但生产环境的数据深度是不可控的。我会在注释里特别说明递归版本的复杂度边界,并配套提供迭代版本。团队代码规范也明确要求:树的深度超过一个阈值,强制使用迭代实现。这个规矩帮我们避免了无数次线上崩溃。
第三点是构建和遍历的相辅相成。很多人在构建孩子链表时不维护尾指针,导致后续遍历、插入、删除操作处处别扭。构建时多维护一个尾指针字段,链表插入的复杂度从O(n)降到O(1),收益是长期且稳定的。
不以“总结”收尾,以我的一次实战体会来结尾
有一次处理一份覆盖全公司部门的组织架构数据,节点有几千个,树深超过二十层,一开始用孩子表示法存,配合递归遍历,数据少的时候一切正常,后来数据量翻倍后直接出现栈溢出,排查半天才锁定根因。用孩子兄弟表示法重构,配合显式栈迭代遍历,把存储结构和遍历算法同时换掉,整体耗时降了将近两个数量级。这次经历让我彻底记住了两件事:第一,不存在万能的存储结构,脱离业务谈数据结构都是耍流氓;第二,树形算法的递归版本只是教学工具,生产环境里一定要有迭代版本的Plan B。
如果你正在学习或使用树形结构,我建议亲手把三种存储结构各实现一遍,再用同一组测试数据去跑各种操作,记录每一种的耗时差异。这个过程一旦走完,你对树存储的认知会从“背定义”真正转化为“肌肉记忆”,以后不管是写业务代码还是应付面试,都有了一整套自己验证过的方案打底。