树和二叉树这套东西,我当年学的时候也觉得“不就是递归嘛,背个模板就行”,直到面试被问到“不用递归怎么中序遍历”才傻眼。后来自己手动模拟了一百多遍栈的进出,又啃了Morris遍历的线索化思路,才算是真正把这棵树的脉络摸清楚。这篇就围绕树的存储结构、四种遍历体系、迭代实现和几个高频工程场景,把从概念到技巧的一条线完整串起来,适合刚学数据结构的学生,也适合准备面试想系统过一遍的开发者。
1. 树的基本形态与核心概念拆解
1.1 树到底是什么:抛开教材定义看本质
树结构最核心的价值,是把“一对多”的关系变成了可计算的模型。线性表(数组、链表)里每个节点只有一个后继,处理的是排队、序列这类问题;而现实中更多的是目录、组织架构、族谱、比赛的淘汰赛对阵表,一个上级挂着多个下级,这种结构用线性表表达非常别扭,树就是专门为这种关系设计的。
树的定义里有一个“递归”的味道:一棵树由根节点和若干棵互不相交的子树构成。也就是说,树的任意一个节点往下看,它自己也是一棵树的根。理解这一点很重要,因为后面所有的遍历算法,本质都是顺着这句话递归下去的。
几个容易混淆的术语值得单独说清楚:
- 根节点:整棵树唯一的入口,没有父节点。一棵空树的根节点为null,这个边界后面写算法会反复遇到。
- 叶子节点:度(孩子数量)为0的节点,整棵树的“尽头”。
- 节点深度:从根到该节点的唯一路径上的边数。根的深度是0(也有教材定义深度从1开始,关键是团队内统一)。
- 树的高度:所有节点深度中的最大值加1(从节点数角度定义),或者直接等于最大深度(从边数角度定义)。不同教材定义不同,但问到“高度”时先确认口径,曾经有人前序遍历能写对,却挂在高度定义的细微差别上。
- 节点的度:一个节点直接拥有的子树个数。二叉树里节点的度最大为2,这也是“二叉”二字的来源。
树的形态还有两个容易被忽略的角度:树的“有序”与“无序”。教科书常提“有序树”,指节点的各子树从左到右有次序,换位置就算不同结构;无序树则忽略左右次序。二叉树天然是有序树的代表,因为左右子树分得很清楚,即使只有一个孩子,也得标注是左孩子还是右孩子,这点在恢复一棵树的场景里特别关键,后面讲由遍历序列构建二叉树时会绕回来。
1.2 树的常见存储方式:先想明白再动手写代码
树的存储选型直接决定算法实现复杂度。常见方案有三类,各自适用场景完全不同。
第一种是父节点表示法。每个节点只存一个parent指针(或下标),适合题目只关心节点的父亲是谁的场景,比如并查集结构。缺点很明显:想拿到一个节点的所有孩子,得全表扫描,效率很低。
第二种是孩子链表示法。每个节点维护一个孩子链表(数组或链表),兄弟关系一目了然,但节点的结构定义不统一,不同节点的孩子数量不同,需要动态管理,C语言里处理起来比较繁琐。
第三种是左孩子右兄弟表示法。节点只保留两个指针:一个指向第一个孩子,一个指向它的下一个兄弟。这种表示法能把任意一棵树“压缩”成二叉树的样子,这也是“森林与二叉树转换”的理论基础。实际竞赛和工程里,我们最常用的还是二叉树的链式存储:节点里放left和right两个指针。
作为学习者,建议不要把精力耗在造轮子上,把结构定义固定下来,后续所有遍历技巧都在这套结构上展开:
struct TreeNode { int val; TreeNode *left; TreeNode *right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };这种定义简洁、直观,也是绝大多数在线评测系统默认的二叉树结构。后面所有代码示例默认使用这套定义,理解后迁移到别的语言(如Python的class Node)无非是语法层面的变化。
2. 二叉树的类型辨析与核心性质推导
2.1 满二叉树与完全二叉树:一字之差,性质完全不同
二叉树按形态可以分为两类特殊结构:满二叉树与完全二叉树。满二叉树的定义很严格:每一层的节点数都达到最大值,也就是说一棵深度为k的满二叉树,一共有2^k - 1个节点(如果深度从1开始算层数)。这种树左右完美对称,但现实中很少出现,因为它强制要求节点数必须是“2的幂减1”。
完全二叉树的条件相对宽松:除最后一层外,每一层都被填满,最后一层的节点必须严格靠左连续排列。换句话说,完全二叉树允许缺右下角,但不允许中间“挖空”。这个特性非常重要,因为它让完全二叉树可以用数组连续存储,并且父节点与孩子节点的下标可以精确计算:下标i的节点,左孩子下标为2i,右孩子下标为2i+1(从1开始编号时)。堆排序、优先队列都建立在这个性质之上。
有人会把完全二叉树和满二叉树混淆,本质区别只需要记一句话:满二叉树是“全满”,完全二叉树是“从左往右连续满,中间不空洞”。判断一棵二叉树是否是完全二叉树,层序遍历时一旦遇到空节点,其后就不能再出现非空节点,这是最实用的判别法。
2.2 二叉树三大性质:能推导就不靠背
二叉树理论里有三个经典性质,面试和做题时经常拿来当隐含条件:
- 性质一:在非空二叉树的第i层上,至多有2^(i-1)个节点(i≥1)。
- 性质二:深度为k的二叉树至多有2^k - 1个节点。
- 性质三:对任何一棵非空二叉树T,如果叶子节点数为n0,度为2的节点数为n2,则n0 = n2 + 1。
第三个性质最容易被忽略,但它有很强的直觉依据:每出现一个度为2的节点,这棵树的“分叉”就多了一条,整棵树的叶子就一定多一个。严谨的推导方式是用边的数量关系列方程:设节点总数n = n0 + n1 + n2,边的总数e = n - 1(树中边数等于节点数减1),同时边的总数也等于n1 + 2n2(每个度为1的节点贡献一条边,度为2的节点贡献两条边)。联立n0 + n1 + n2 - 1 = n1 + 2n2,化简就得到n0 = n2 + 1。多推导几遍,比死记硬背牢靠得多。
实际做题中这个性质有很大用处。比如“已知一棵二叉树有50个叶子节点,问度为2的节点有多少个”,答案是49。这类题在考研选择题和面试基础题里出现频率很高,掌握推导过程就能放心写答案。
2.3 顺序存储与链式存储:什么时候选谁
二叉树的存储又分成两大类:顺序存储和链式存储。顺序存储就是把二叉树按照“从上到下、从左到右”的层序编号存入数组,利用下标关系表示父子关系。
- 完全二叉树:用顺序存储非常省空间,下标紧凑无空洞。
- 普通二叉树:如果树的形态很不规则,数组里就会出现大量空位,空间浪费严重。比如一棵深度为k的右斜树,用顺序存储只有k个节点,却需要2^k - 1的数组长度,浪费巨大。
链式存储的left/right指针则没有这个问题,它按需分配节点,形态任意都不担心空间空洞,代价是每个节点要额外存两个指针,存储开销增加。
实际操作时怎么选?我的经验是:如果题目明确说“完全二叉树”或“堆结构”,优先考虑顺序存储(配合下标运算),性能更好;如果是一般的二叉树递归遍历、构建类问题,无脑用链式存储,代码可读性和调试友好度都高得多。
3. 遍历体系全景:前序、中序、后序、层序
3.1 一次递归序看懂三种深度优先遍历
二叉树遍历的核心,是搞清楚“什么时机打印节点”。每个节点在递归过程中会被访问三次:第一次是刚进入该节点所在的递归函数时,第二次是处理完左子树回来时,第三次是处理完右子树回来时。如果在这三次访问中把节点打印出来,就分别对应了前序、中序、后序。
前序遍历:第一次到达节点时输出,顺序是根、左、右。 中序遍历:处理完左子树回来后输出,顺序是左、根、右。 后序遍历:处理完左右子树都回来后输出,顺序是左、右、根。
这个“三次访问”的视角极其关键,也是后续理解非递归遍历的一把钥匙。很多人把三种遍历当成三套不同的模板来背,背完就忘。其实只需要记住递归框架:
void traverse(TreeNode* root) { if (!root) return; // 前序:在这里输出 root->val traverse(root->left); // 中序:在这里输出 root->val traverse(root->right); // 后序:在这里输出 root->val }递归代码本身一目了然。但要特别注意:前中后序遍历针对的是“子树之间的相对顺序”,不是单纯“把左中右三个字换个位置”,尤其是中序遍历,它在二叉搜索树场景下会输出递增序列,这个特性后面用的地方非常多。
3.2 层序遍历与“按层处理”的额外需求
层序遍历(BFS)用队列实现,跟深度优先遍历的思路完全不同。代码模板也很固定:
void levelOrder(TreeNode* root) { if (!root) return; queue<TreeNode*> q; q.push(root); while (!q.empty()) { int sz = q.size(); for (int i = 0; i < sz; i++) { TreeNode* node = q.front(); q.pop(); // 处理当前节点 if (node->left) q.push(node->left); if (node->right) q.push(node->right); } } }这里的技巧是,每次进入while循环时先记录q.size(),这个值就是当前层拥有的节点数。如果不记录,队列会混杂不同层的节点,无法区分当前层。这个“先取size再弹出该层”的习惯,在处理二叉树的右视图、层最大值、锯齿形遍历等衍生题时都适用。
我一开始偷懒,直接在while里用!q.empty()处理,结果打印出来的数据顺序混乱。后来把size逻辑加上,所有“按层输出”的题目就都顺了。这一行sz = q.size(),看起来简单,是整个层序遍历的分层核心。
4. 从递归到迭代:非递归遍历的实战技巧
4.1 为什么要手写非递归:避坑和面试双重考量
递归遍历代码优雅,但存在两个实际痛点:一是递归深度等于树高度,如果遇到深度10000+的树(比如某些OJ的极限数据、或退化成链状的树),函数调用栈很容易溢出;二是面试官喜欢追问“你能不用递归实现中序遍历吗”,考察的是对栈机制的理解,而不是背模板。
非递归遍历的思想,是显式模拟递归调用栈。递归版本里每次函数调用都会把现场压入系统栈,返回时再恢复现场;迭代版本里,我们用自己管理的栈来模拟这个过程。
4.2 前序遍历的迭代:最直观的栈解法
前序的迭代版本最好写,因为根节点最先被处理。思路是用一个栈,先压根节点,循环里弹出一个节点处理,然后先压右孩子、再压左孩子。注意顺序是反着来的:栈是LIFO,要让左子树先被弹出处理,就得先压右。
void preorder(TreeNode* root) { if (!root) return; stack<TreeNode*> st; st.push(root); while (!st.empty()) { TreeNode* cur = st.top(); st.pop(); // 访问 cur if (cur->right) st.push(cur->right); if (cur->left) st.push(cur->left); } }这个版本依赖栈的LIFO特性,代码简洁。还有一个“边压栈边左移”的写法,模拟的是递归中“一路走到最左”的过程,风格更统一,也能直接迁移到中序和后序。我个人更推荐后一种,因为只用记一套“向左走到黑”的框架。
4.3 中序遍历的迭代:一路向左走到黑
中序遍历的迭代核心是:先沿着左子树一路压栈,压到最左的叶子;再开始弹栈处理;处理完当前节点后,转向它的右子树,重复上述过程。
void inorder(TreeNode* root) { stack<TreeNode*> st; TreeNode* cur = root; while (cur || !st.empty()) { while (cur) { st.push(cur); cur = cur->left; } cur = st.top(); st.pop(); // 访问 cur cur = cur->right; } }如果不理解“当前节点访问完成后为什么要切到右孩子”,可以这样想:中序遍历的顺序是左、根、右,根输出后,左子树和根都处理完了,剩下唯一未处理的就是右子树,所以接着对以右孩子为根的新子树执行同样的循环。这本质上就是在手动模拟递归里的“回到上一层,然后去右子树”。
这个代码是面试高频考点,建议亲手在纸上模拟一遍完整流程,包括空树(cur为空、st为空)的情况。
4.4 后序遍历的迭代:两种思路对比
后序迭代是三种遍历里最容易写乱的,因为根节点最后一次访问,必须在左右子树都处理完后才能输出。这里有两个实用的解法。
第一种:使用两个栈。第一个栈按“根、右、左”的顺序压入节点,弹出的节点进入第二个栈;由于栈的逆序特性,第二个栈最终弹出的顺序就是“左、右、根”。这个思路简单,代码也不容易错:
void postorder(TreeNode* root) { if (!root) return; stack<TreeNode*> st1, st2; st1.push(root); while (!st1.empty()) { TreeNode* cur = st1.top(); st1.pop(); st2.push(cur); if (cur->left) st1.push(cur->left); if (cur->right) st1.push(cur->right); } while (!st2.empty()) { // 访问 st2.top() st2.pop(); } }第二种:单栈加lastVisited标记。维护一个prev指针记录上一个完整处理完的节点。当cur->right为空或者cur->right == prev时,说明右子树已经处理完,可以安全输出当前节点。这个写法更省空间,但细节多,稍不留神就死循环。二栈法虽然多一个栈,但正确性更容易保证,新手阶段建议先用二栈法,后续再挑战单栈。
4.5 层序之外的进阶:遍历结果的应用场景
四种遍历各有各的应用土壤:前序遍历适合复制一棵树(先复制根,再复制左右子树);中序遍历在二叉搜索树中直接得到有序序列;后序遍历适合先处理子树再处理根的场景,比如释放整棵树的内存、计算树的高度(需要先得到左右子树的高度再取最大值加1);层序遍历用于求树宽、判断完全二叉树、找最底层最左节点。
有一种常见的错误是把“使用队列”等同于层序,把“使用栈”等同于深度优先。实际上深度优先遍历(前中后序)用栈模拟,层序用队列,这个区分是遍历方式的性质决定的,不是刻意设计。
5. Morris遍历:线索化思想下的O(1)空间遍历
5.1 Morris遍历的核心突破点在哪
递归和栈模拟的空间复杂度都在O(h)(h是树高),因为必须用栈记录待返回的路径。Morris遍历却能把空间压到O(1),思路是利用叶子节点空闲的左右指针,临时构建“线索”,让遍历过程不回退,真正做到“边走边访问”。
讲道理,这是树遍历里最有“啊哈”感的技巧。它的基本线索规则是:对于当前节点cur,如果cur没有左子树,直接访问cur并向右移动;如果cur有左子树,找到cur在中序遍历下的前驱节点(左子树中最右的节点),分两种情况处理:
- 前驱节点的右指针为空:说明还没线索化,把它指向cur,作为“回去的路标”,然后cur向左移动。
- 前驱节点的右指针指向cur:说明线索已经建立,左子树完整访问过,此时恢复前驱右指针为空,访问cur,然后cur向右移动。
整个过程像在树上“画”临时回路,跑完一圈再拆掉回路,树的原结构保持不变。空间上只用了常数级别的指针,所以是O(1)。
5.2 前序与中序的统一模板
Morris中序遍历的代码很有节奏感:
void morrisInorder(TreeNode* root) { TreeNode* cur = root; while (cur) { if (cur->left) { TreeNode* pre = cur->left; while (pre->right && pre->right != cur) { pre = pre->right; } if (!pre->right) { pre->right = cur; cur = cur->left; continue; } else { pre->right = nullptr; // 恢复原结构 } } // 访问 cur cur = cur->right; } }如果想改成前序,只需要调整访问时机:在“第一次到达cur”时访问(即刚建立线索、准备向左走之前),而不是等左子树处理完回来再访问。
5.3 Morris的实际价值:不只是炫技
我第一次看到Morris遍历的代码,第一反应是“谁会真的在生产环境用这个?”后来在内存极度受限的嵌入式场景和某些大文件遍历工具中想减少栈开销时,才意识到它的价值。它不需要额外的栈或队列,不修改树的结构(最终会恢复),而且中序遍历的访问顺序天然递增,适合某些需要边遍历边判断的场景。
不过也要说清楚它的短板:线索化过程中需要反复找前驱节点,每个节点的左子树最右链可能被多次访问,时间复杂度看起来是O(n log n),但均摊分析下依然是O(n)。实际跑起来比递归和栈模拟都要慢一些,胜在省空间,笔试和面试中属于加分项,不要作为默认实现。
6. 高频实操场景与经典问题速查
6.1 由遍历序列恢复二叉树:中序+另一种是关键
只给一个遍历序列,通常无法唯一确定一棵二叉树。比如前序序列“AB”,既可能是根A带左孩子B,也可能是根A带右孩子B。如果给中序序列再配上前序(或后序)序列,就能唯一恢复。
原理很简单:前序序列的第一个元素一定是根;在中序序列中找到这个根的位置,根左侧是左子树的中序序列,右侧是右子树的中序序列;再根据左右子树的长度,把前序序列切成对应左右子树的前序序列;递归建树。递归终止条件是序列为空。
这里有个非常容易踩的坑:恢复时要把左右子树的两段序列边界算清楚,尤其是后序序列的右子树起点。建议先写出“由中序+前序恢复”的递归函数,再类比“中序+后序”,不要背模板,边界错一位就全错。
面试中更高频的变形是“给定层序序列如何恢复二叉树”,这种题目需要借助队列和下标映射,把层序序列当作“按层依次进入队列”的顺序,配合中序的区间定位来递归构建,复杂度明显上了一个台阶,建议先把中序+前序做扎实。
6.2 树的高度、直径与形态判断
树的高度是递归的经典入门题:高度 = max(左子树高度, 右子树高度) + 1,空节点高度为0。这段代码几行就写完,但要注意语言里的递归深度限制,数据规模大时要转成迭代后序遍历。
二叉树的直径是指树中任意两个节点路径上的最大边数。直觉上很多人的第一反应是“左子树高度+右子树高度”,这个思路对但不完整,因为最长路径不一定经过根节点,可能完全落在某个子树内部。正确的做法是在计算每个节点高度的过程中,顺便用leftHeight + rightHeight更新全局答案,最后取最大值,就是整棵树的直径。这个“在递归过程中顺便记录全局值”的模式在树形DP题目中非常常见。
判断一棵二叉树是否平衡,核心是每棵子树都满足高度差绝对值不超过1。只算根节点左右子树高度差是典型错误,必须递归检查所有子树。后序位置恰到好处:先检查左右子树是否平衡,再用左右高度判断当前节点是否平衡。
6.3 二叉树的镜像与序列化
翻转二叉树(镜像)是前序遍历思路的典型应用:先交换当前节点的左右孩子,再递归翻转左右子树。递归终止条件是叶子和空节点。交换操作要在递归调用之前还是之后,取决于你是“自上而下”还是“自下而上”,两种都可以,只要保证每个节点的左右孩子都被交换过一次即可。
树的序列化是把二叉树编码成字符串,反序列化则是还原。LeetCode风格的层序序列化用null标记空节点,是面试高频题。序列化用层序遍历时,注意输出要保持“完全二叉树式”位置,这样反序列化才能按层填回正确位置。这里要特别小心空节点的子节点不进入队列,否则反序列化时会出现下标错位。
6.4 实战中的几个老生常谈的避坑清单
操作二叉树的题目,有不少坑我几乎是踩了一遍才长记性。集中在以下几类:
- 空指针问题:递归函数入口不判空,或者循环里不判node->left/right就访问,最容易越界。尤其是Morris遍历里找前驱那步,如果前驱结点不存在或最右节点是空,直接取pre->right会崩。
- 递归深度问题:树的形态退化成一棵斜树时,递归深度等于节点数,几千个节点的树在一些环境下就会爆栈。遇到数据量大的场景,优先考虑写成迭代版本。
- 返回类型与“空树”的处理:判断“二叉搜索树是否合法”这类题,空树算合法;判断“树是否对称”时空树也算对称。这些边界条件如果不和出题人对齐,很容易误判。
- 遍历序列种类混淆:前序定根、中序分左右、后序定根但要从序列末尾取根。每次写恢复类题目先明确自己拿到的序列到底是哪两种,再动笔,能省不少调试时间。
7. 面试真题模式提炼与刷题路线建议
7.1 高频题型的“套路化”拆解
刷树相关的面试题多了以后,会发现再新的题目也常常是在几个母题上加壳:
- 路径类问题:如“二叉树的最大路径和”“路径总和”,本质上都是后序遍历或者DFS加全局变量。
- 搜索树类问题:如“验证二叉搜索树”“BST第K小元素”,核心都是利用中序遍历有序性。
- 公共祖先类问题:如“二叉树的最近公共祖先”,核心是后序遍历,看左右子树里是否命中目标节点,再判断当前节点是否就是分叉点。
- 层次类问题:如“右视图”“填充next指针”,核心是层序遍历配合按层size处理。
这四类题基本覆盖了绝大多数面试关卡。我自己的感受是,先别急着刷题矩阵,先把“递归函数表达什么含义”“什么时候返回什么值”“全局变量在哪个位置更新”这三个问题想清楚,比闷头刷五十题更有效。树的题目最怕的就是“逻辑上觉得对,但不知道每一层函数到底在做什么”。
7.2 建议的学习顺序与训练方法
如果是从零开始,我建议按下面这个顺序推进:
- 先熟练掌握链式存储结构定义,手写前中后序递归遍历,每天默写一遍直到不用过脑子。
- 再用栈模拟前序和中序,手写非递归版本,特别是中序的“一路向左”框架,建议反复模拟十棵不同形态的树。
- 手写层序遍历并实现“按层输出”,配套练习“每层最大值”“Z字形遍历”。
- 练习由中序+前序恢复二叉树,把边界条件算明白。
- 练后序遍历相关的经典题(求高度、求直径、判断平衡),理解“后序位置做计算”的套路。
- 如果为了面试冲刺,再看Morris遍历,把它当作“理解线索化思想”的拓展,不要本末倒置。
训练时给自己定个标准:能在白纸上五分钟内写完非递归中序遍历并模拟正确,才算真正掌握。我见过不少人代码能背下来,但一问“循环退出条件为什么是cur非空或栈非空”就卡住,这说明没理解状态转移,面试官多追问两轮也会露馅。
8. 从二叉树到多叉树:概念迁移与能力复用
8.1 多叉树的存储与遍历:核心思路完全复用
二叉树的两个指针(left/right)在多叉树里变成孩子列表,本质的递归框架没有变:遍历一个多叉树节点时,遍历它的所有孩子就行。
多叉树的先序/后序遍历跟二叉树几乎一致,只是少了中序——因为一个节点有多个孩子,“根之后到底是左还是右”的二元次序不存在了。中序遍历是多叉树里没有的概念,这个认知很重要,它提醒我们:中序遍历之所以存在,是因为二叉树天然区分了左与右,这种“配对树”特有的遍历给了二叉搜索树有序输出的本领。
多叉树的层序遍历代码跟二叉树几乎一样,只是把判断left/right改为遍历children数组。数据结构进阶中常见的N叉树题目,本质上就是“把二叉树代码里的左右孩子替换成children循环”,剩下的分析方式不变。
8.2 二叉树技巧迁移到更复杂结构的思路
二叉树的很多技巧,在高级结构里都能复用:
- 树形DP:在二叉树上“后序收集信息”的套路,放在多叉树、表达式树、线段树上变成了“从子节点汇聚信息”。例子包括多叉树的直径、按子树加权的路径统计。
- 序列化:二叉树用null标记空位的做法,放在JSON风格的通用树结构里同样适用,只是把null换成更通用的占位标记。
- Morris线索化:同样适用于部分中序线索二叉树结构,甚至一些空间受限的B树实现也受它启发。
所以学习二叉树遍历时,不要只盯着“会写四种顺序”这件事。把这些顺序背后“什么时候进入节点、什么时候离开节点、什么时候该收集信息”的节奏掌握住,是后续学习图遍历、拓扑排序、树状数组等一系列内容的地基。
9. 一套完整的自测题目与自查清单
为了帮助你把知识内化成手感,我整理了一套自测题。每道题都对应正文里某个关键技能点,如果哪道题写得吃力,往回翻对应的章节补一补。
- 手写递归前序、中序、后序遍历,并用一句话描述每种遍历中根的位置。
- 手写层序遍历,并输出每一层的节点列表(二维数组)。
- 手写非递归中序遍历,说明循环退出条件为什么是cur或栈非空。
- 根据中序序列与前序序列恢复二叉树,并画出递归过程的状态变化。
- 实现函数判断一棵树是否完全二叉树,要求使用层序遍历。
- 实现函数求二叉树的最大宽度(某层最多节点数)。
- 实现“翻转二叉树”,要求同时用递归与非递归版本。
- 实现“验证二叉搜索树”,要求用中序框架完成。
- 写出Morris中序遍历,并说明线索如何建立和拆除。
- 用后序遍历求树的直径,解释为什么要在后序位置更新答案。
自测时给自己计时,卡壳超过十分钟的题目,说明对应知识点还没形成条件反射,建议再过一遍相关小节的代码和模拟过程。
这棵从概念到遍历的树,我就先种到这里。真要说哪里最值得反复琢磨,我会选那个“递归三进三出”的视角和中序遍历的“一路向左”框架,这两块吃透了,后面的迭代、线索、恢复和树形DP都会顺很多。