ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

二叉树后序遍历:从递归到非递归的三种高效实现与实战解析

2026/9/15 3:57:45 拓冰建站 浏览量
二叉树后序遍历:从递归到非递归的三种高效实现与实战解析 1. 整体设计先弄清楚后序为什么“难”先说一个现象很多人递归写后序遍历代码两三行就搞定但一旦面试里被要求“不用递归写一个后序”就卡住了。不是知识点多难而是后序的访问顺序是“左子树、右子树、根”这和机器天然的顺序思维是拧着的。人脑习惯先看到根但后序要求最后处理根所以非递归实现需要绕一个弯。这一篇就围绕“从递归到非递归”这条线把后序遍历彻底拆开。我会先讲递归实现及其隐藏的风险再讲三种非递归思路从容易理解的版本一直递进到空间更优的实现最后补充几个和后序强相关的实战场景和常见坑。内容偏算法和数据结构适合准备面试的人、正在刷题的在校生以及日常要解析表达式、处理AST或做树形结构操作的开发者。先明确一个底层认知二叉树遍历的本质是把一个非线性结构线性化。递归之所以写起来简单是因为系统栈帮我们保存了每一层调用的现场。非递归的本质就是用显式的栈去模拟这个现场保存过程。后序麻烦的地方在于当一个节点从左子树返回时还不能立即访问它得先去右子树等右子树也返回了才能访问根。所以非递归后序的核心问题就一句话如何判断一个节点是从左子树返回还是从右子树返回。理解了这句话后面所有非递归写法都能串起来。1.1 三种遍历顺序的直观理解与场景差异先花点时间把前、中、后序放在一起看。前序是“根左右”中序是“左根右”后序是“左右根”。同样的树顺序不同解决的问题也不同。前序复制一棵树、序列化树、打印目录结构都是先访问根再往下递归。中序二叉搜索树中序遍历得到有序序列很多和排序相关的树操作依赖它。后序树的销毁先删除孩子再删除根、计算子树高度、表达式求值这些都需要先处理完子问题再处理父问题。后序和前序是天然的镜像关系。这个镜像思路特别重要后面写双栈法时就是靠它。后序最常见的实际应用是求二叉树的深度。树的高度定义是从根到叶子节点的最长路径上的节点数这个值必须先知道左右子树的高度然后取最大值加一。这正好是后序的逻辑。热词里出现了“二叉树的深度”这里给个结论后序遍历是求树高最自然的模板递归写起来只有三行非递归和它对应的是栈里存状态。1.2 为什么大批学习者卡在非递归后序递归代码太优雅反而成了理解障碍。很多人的问题不是不会写递归而是不知道递归背后发生了什么。举个例子递归调用后序时你会在函数开头检查节点是否为空然后调用自己处理左子树再调用自己处理右子树最后打印当前节点。每次递归调用进入子函数当前函数的局部变量和还没执行完的代码位置都会被压入系统栈。等子调用返回系统从栈里恢复现场继续往下走。也就是说递归隐式地把“当前处理到哪一步”记录了下来。非递归实现没有这个系统栈你需要自己在代码里明确区分当前节点入栈后是已经处理完左子树了还是左右子树都处理完了。这个判断一旦想清楚后序非递归就通了。2. 递归实现优雅背后的三要素与隐患递归后序的代码短到你几乎不需要思考但正因为太短很多人把重点放在了背诵而不是理解上。这里把递归的三要素完整拆解一遍顺便说说递归在实际工程中的坑。2.1 递归后序的代码与逐行解读定义二叉树节点struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };后序递归遍历void postorderRecursive(TreeNode* root) { if (root nullptr) { return; } postorderRecursive(root-left); // 先处理左子树 postorderRecursive(root-right); // 再处理右子树 visit(root-val); // 最后访问根节点 }这段代码的核心逻辑是每一层调用都遵循“左右根”。当函数执行到第一句递归调用时它会一直向左深入直到某个节点的left为空才返回。返回后当前函数继续执行第二行去处理右子树。等右子树彻底处理完当前节点才被访问。递归的时间复杂度是O(n)每个节点恰好被访问一次。空间复杂度是O(h)h是树高递归栈最深会压到树的高度。对一棵比较平衡的树h约等于log2(n)空间可以接受。但如果树退化成了链表形状h就等于n递归深度会非常大可能直接栈溢出。2.2 递归的边界条件与“返回值设计”经验写递归还有一个容易被忽略的点函数是否需要返回值返回值如何设计。上面只是遍历打点所以不需要返回值。但如果要用后序求树的深度就得把“子树的处理结果返回给父节点”。求树高的后序写法int treeHeight(TreeNode* root) { if (root nullptr) { return 0; } int leftHeight treeHeight(root-left); int rightHeight treeHeight(root-right); return max(leftHeight, rightHeight) 1; }可以看到左右子树的高度先计算出来父节点基于这两个值做决策这正是后序的思想。如果把这个递归改成非递归你就需要手动管理这些中间值。这也是为什么纯遍历好改成非递归但带有返回值的递归改成非递归时会麻烦一点因为不仅要模拟调用栈还要模拟返回值传递。提示递归设计时要先写终止条件再拆子问题最后处理当前层逻辑。很多人先写处理逻辑后补终止条件一旦漏掉空指针判断代码很容易就越界崩溃。3. 非递归实现三种写法的渐进理解和代码细节非递归后序方案很多我按理解难度从低到高讲三种双栈法、单栈加标记法、单栈无标记法。前两种面试和日常足够用第三种是空间上的优化版本。重点是理解同一种问题不同写法的演进逻辑。3.1 双栈法利用镜像关系绕开难点双栈法是最容易理解的方案巧妙地把后序转成“逆后序”问题。前序是“根左右”如果把前序改成“根右左”再倒序输出得到的就是“左右根”。这个过程一套到树上我们就有了这样一个思路用第一个栈模拟“根右左”的遍历顺序遍历过程中不直接访问节点而是把节点压入第二个栈等整体遍历结束依次弹出第二个栈此时顺序就是后序。原因不复杂第二个栈是后进先出“根右左”压进去再弹出来正好逆序为“左右根”。代码实现如下void postorderTwoStacks(TreeNode* root) { if (root nullptr) return; stackTreeNode* s1, s2; s1.push(root); while (!s1.empty()) { TreeNode* node s1.top(); s1.pop(); s2.push(node); if (node-left ! nullptr) s1.push(node-left); if (node-right ! nullptr) s1.push(node-right); } while (!s2.empty()) { visit(s2.top()-val); s2.pop(); } }注意入栈顺序这里先压左孩子再压右孩子所以第一栈弹出来时先右后左整体是“根右左”。如果你还想深入一点确认可以自己用小树推演一遍这个习惯比任何讲解都管用。空间复杂度是O(n)最坏情况两个栈加起来会存储所有节点。这个方案代码简单还不需要记录访问状态是我个人认为面试中最稳妥的写法之一。3.2 单栈加标记法显式记录左右子树的状态双栈法的缺点是多用了一个栈。只在系统栈中维护“还没有处理完的节点”这一状态则可以节省一半空间。做法是给每个节点附带一个状态值标记当前该访问左子树还是右子树。这里我习惯用 pairTreeNode*, boolbool表示右子树是否已经处理过。也有人用nullptr做标记原理一样只是形式不同。void postorderSingleStack(TreeNode* root) { stackpairTreeNode*, bool st; st.push({root, false}); while (!st.empty()) { auto [node, visitedRight] st.top(); st.pop(); if (node nullptr) continue; if (visitedRight) { visit(node-val); } else { st.push({node, true}); st.push({node-right, false}); st.push({node-left, false}); } } }执行逻辑是从栈里弹出一个节点时如果visitedRight是true说明它的左右子树都已经处理完了可以直接访问否则先把当前节点以“已处理右子树”的状态重新压栈然后把右孩子和左孩子按“未处理”状态依次压栈。由于栈是后进先出左孩子会先被弹出然后右孩子最后再来访问当前节点天然满足“左右根”。这里如果不想用pair可以用一个额外的标记节点。具体做法是压入根节点后再压入一个nullptr作为哨兵每次遇到nullptr就说明下一个栈顶节点左右子树处理完了可以访问。这种写法不增加结构体封装读代码时需要理解哨兵的作用。两种形式我都在工程代码里见过没有本质优劣看团队代码风格。3.3 单栈无标记法只有指针的压缩状态实现前两种方案空间复杂度都是O(n)。如果想要栈里只存节点指针不存状态那就需要完全靠指针位置和栈内剩余节点推断当前应该访问哪个节点。这个写法更绕但空间效率到了同类方案的极限。void postorderOptimized(TreeNode* root) { stackTreeNode* st; TreeNode* cur root; TreeNode* lastVisited nullptr; while (cur ! nullptr || !st.empty()) { // 尽可能向左走 while (cur ! nullptr) { st.push(cur); cur cur-left; } TreeNode* top st.top(); // 如果右子树为空或右子树刚访问完可以访问当前节点 if (top-right nullptr || top-right lastVisited) { visit(top-val); st.pop(); lastVisited top; } else { // 否则继续遍历右子树 cur top-right; } } }这个方案的关键只有一处用一个lastVisited指针记录上一次访问的节点。如果当前栈顶元素的右孩子就是lastVisited说明右子树已经处理完了接下来该访问当前节点了。看起来代码量也不大但理解门槛比前两种高不少。我实际做算法题或者工程里用带标记法的频率更高因为可读性清晰。单栈无标记法更多是为了面试展示思路或者面试官明确要求“额外空间尽量小”时才写。3.4 三种非递归方案对比与选型建议放个对比表方便你按场景快速选型方案思路空间复杂度可读性适合场景双栈法镜像前序 逆序输出O(n)高面试首选逻辑简单不易错单栈标记法显式记录右子树访问状态O(h)高日常开发平衡可读与空间单栈指针法lastVisited判断完成状态O(h)中等追求空间优化或面试展示从空间复杂度上看双栈法最差两个栈加起来最多的确会存储n个节点但实际使用时大部分节点在第一个栈弹出后立刻进入第二个栈栈内总节点数量随时都在流动。单栈法无论标记还是不标记栈的最大深度都是树高理论上更省。我建议初学的人从双栈法起步理解后序的逆序思路掌握后再切换到单栈标记法写日常代码有余力再啃单栈无标记法。三个版本写熟练了前序、中序的非递归也就一并通了因为它们只是入栈顺序和访问时机的差异。4. 实操演示完整可运行的工程验证讲完了原理给一份可以直接复制运行的完整代码包含建树、遍历输出和预期结果。你可以把这份代码当作自测模板用。4.1 构建二叉树并调用后序遍历这里构造一棵最简单的树1 / \ 2 3 / \ 4 5预期后序输出4 5 2 3 1。完整代码#include iostream #include stack #include vector using namespace std; struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} }; // 递归后序 void postorderRecursive(TreeNode* root) { if (root nullptr) return; postorderRecursive(root-left); postorderRecursive(root-right); cout root-val ; } // 双栈非递归后序 void postorderTwoStacks(TreeNode* root) { if (root nullptr) return; stackTreeNode* s1, s2; s1.push(root); while (!s1.empty()) { TreeNode* node s1.top(); s1.pop(); s2.push(node); if (node-left) s1.push(node-left); if (node-right) s1.push(node-right); } while (!s2.empty()) { cout s2.top()-val ; s2.pop(); } } int main() { TreeNode* root new TreeNode(1); root-left new TreeNode(2); root-right new TreeNode(3); root-left-left new TreeNode(4); root-left-right new TreeNode(5); cout 递归后序: ; postorderRecursive(root); cout endl; cout 双栈后序: ; postorderTwoStacks(root); cout endl; return 0; }运行输出递归后序: 4 5 2 3 1 双栈后序: 4 5 2 3 1两份代码输出完全一致说明逻辑等价。你可以在本地把树换成只有左子树或只有右子树的形式再验证边界条件。比如根只有左孩子时后序输出应该是左孩子、根根只有右孩子时输出应该是右孩子、根。这些边界情况最容易暴露非递归写法里的重复访问问题。4.2 手工推演双栈法的一次完整循环光看代码不如动手推一遍。以上面的树为例双栈法的状态变化分几步初始s1压入根1s2为空。弹出1s2压入1s1先压入左孩子2再压入右孩子3。此时s1栈顶是3。弹出3s2压入33没有孩子s1不新增内容。此时s1栈顶是2。弹出2s2压入2s1压入4再压入5。此时s1栈顶是5。弹出5s2压入5无孩子。此时s1栈顶是4。弹出4s2压入4无孩子。s1为空结束第一段循环。依次弹出s2输出顺序为4、5、2、3、1。推演一遍之后你会发现双栈法第二个栈里的元素从栈底到栈顶依次是1、3、2、5、4。这个顺序就是“根右左”的遍历序列逆序输出后刚好是后序“左右根”。理解了这一层面试时手写双栈法基本不会出错。4.3 栈实现细节用 vector 还是 std::stack在实际刷题或工程里我经常用vector模拟栈因为可以很方便地处理元素。比如vectorTreeNode* st; st.push_back(root); while (!st.empty()) { TreeNode* node st.back(); st.pop_back(); // ... }使用vector的好处是内存连续缓存命中率高而且可以随机访问任意位置的元素调试时更容易观察栈内状态。std::stack虽然语义清晰但底层默认是deque实现的调试时不好直接遍历。两者功能等价刷题时我推荐vector模拟栈面试时两种都可以。此外当你用双栈法时第二个栈其实不一定非要真用栈。你可以用一个vector先保存“根右左”的遍历序列最后从末尾往前遍历输出效果完全相同。这种细节在工程里能让代码更简洁。5. 常见问题与排查技巧实录后序的坑多集中在边界判断和非递归状态管理上。这一节把高频问题整理成速查表再补充几个和后序强相关的进阶场景。5.1 后序遍历高频问题与速查对照问题现象可能原因解决思路递归树过大时程序崩溃树高过大系统栈溢出改用非递归或者显式栈模拟双栈法输出顺序与前序一致第二个栈没做逆序输出或压栈顺序写反检查是否在s1中先压左后压右单栈标记法重复访问节点状态标记更新不及时每次入栈前确认节点的右子树状态已经标记为true单栈指针法循环不退出lastVisited更新位置不对确认在访问节点后才更新lastVisited空树遍历报错入口没判空非递归函数开头一律加if (!root) return;这些错误我自己都踩过。最典型的一次是单栈标记法里我忘记把“重新压入当前节点”和“压入右孩子”的顺序分开导致右子树被处理了两遍输出结果多出了重复节点。排查思路很简单打印每一步栈顶元素和状态观察节点有没有被重复弹出。5.2 用后序遍历计算二叉树深度与子树信息热词里有“二叉树的深度”这里展开讲。求深度和求每个节点为根的子树大小都是典型的自底向上问题天然适合后序。用递归前面已经写过了非递归版本怎么改很多人的第一反应是栈里存当前深度实现思路也很直接int maxDepthIterative(TreeNode* root) { if (root nullptr) return 0; stackpairTreeNode*, int st; st.push({root, 1}); int maxDepth 0; while (!st.empty()) { auto [node, depth] st.top(); st.pop(); maxDepth max(maxDepth, depth); if (node-left) st.push({node-left, depth 1}); if (node-right) st.push({node-right, depth 1}); } return maxDepth; }严格说这个方案是DFS遍历和前序更接近。如果想用纯后序的方式算树高就需要确保子节点先返回、父节点基于子节点的值做计算。这时再回头看单栈标记法你会发现它的价值不止在遍历还在“当所有依赖信息收集完之后再处理当前节点”这一机制上。5.3 后序与线索二叉树、Morris遍历的关系热词里出现了“线索二叉树”。简单说线索二叉树是在空闲的左右指针上存储前驱或后继信息从而让线性遍历不需要额外栈。它的二进制表示里每个节点需要有标记位来区分指针是指向子树还是线索所以实际工程中更多是考试或研究场景使用。后序的Morris遍历是基于线索二叉树思路的O(1)空间遍历方法。它把树的叶子节点指出的空指针利用起来在遍历过程中临时修改树的结构遍历完再恢复。前序和中序的Morris实现比较常见后序的更绕因为需要额外添加一个虚拟头节点并且逆序输出路径上的节点。虽然理论空间复杂度是O(1)但时间复杂度常数较大而且破坏了树的只读性所以实际工程中很少用。如果你不是在做语言库级别的极端优化我不会建议在日常项目里用Morris后序。你要是看代码的话会觉得非常绕要先建立临时链接访问完又得断开逻辑链条比我们前边讲的三种非递归都长。线索二叉树倒是对理解Morris有帮助。它本质上是在问树里所有的空指针能不能利用起来实现不需要系统栈也不需要显式栈的遍历。理解这个动机就够了。面试时能答出“Morris遍历利用了空指针做线索空间O(1)”这个层级就已经胜过多数人。5.4 树退化成链表时的内存与性能陷阱最后一类常见问题是树退化的场景。比如二叉搜索树连续插入有序数据时结构会变成一个单向链表。此时递归后序的空间复杂度会从O(log n)恶化到O(n)处理一万个节点都可能栈溢出。非递归单栈法因为栈深跟着树高走同样会存n个节点但好在用的是堆内存不受系统栈大小严格限制。这时双栈法反而有两个栈最多存2n个节点的额外成本但好处是逻辑简单不容易出现状态错误。实际工程中如果已经知道树可能严重不平衡我更建议直接用非递归双栈法牺牲一点空间换稳定性。同时注意树的操作中要养成判空的习惯防止遍历到空指针时访问成员变量。5.5 后序在表达式求值与自动机场景中的扩展应用后序最有意思的应用是表达式求值。把中缀表达式转成后缀表达式逆波兰表达式后栈求值只需要一个栈遇到数字就压栈遇到运算符就弹出两个数字计算再压回结果。这本质上就是树的后序遍历表达式树中操作符是内部节点数字是叶子节点后序遍历即可得到后缀表达式。如果你写过简单的数学计算器解析器一定对这个流程很熟。解析表达式时先构建一棵表达式树然后通过后序遍历输出前缀或后缀表达式或直接计算值。这里充分体现了后序“先子后父”的特点因为操作符计算必须等左右操作数都准备好了才能执行。另一个扩展是算法题里常见的“路径求和”问题。比如判断是否存在从根到叶子节点路径和为target这类问题通常DFS更直接。但如果要统计每个子树的路径信息再向上合并比如求二叉树中最大BST子树的规模就必须后序判断左子树是否是BST、右子树是否是BST再判断当前节点作为根能否组成更大的BST。这种自底向上的信息聚合场景用带返回值的递归后序非常顺手。6. 一些实际使用后的经验与建议聊了这么多最后分享一点我自己的心得。双栈法虽然多了一个栈但它是我在面试高压环境下唯一敢保证一次写对的非递归后序。原因在于它不需要维护任何状态标记逻辑链条短出错概率低。日常刷题时我推荐单栈标记法因为不用额外存储逆序序列代码维护起来也更直观。另外我强烈建议你把递归、双栈、单栈标记三种写法都单独手写一遍而不是直接复制运行。手写才能真正暴露出理解漏洞。写完后用不同形态的树去测空树、只有根节点、只有左子树、只有右子树、完全二叉树、随机二叉树。一组用例跑下来三种写法各自的差异和适用场景就很清楚了。如果你想在这个方向上继续延伸下一站可以试试“后序的非递归实现如何配合求LCA最近公共祖先”。因为LCA问题本质上是某个子树访问结束后才知道这个子树里是否包含目标节点这也是一种自底向上的判断逻辑。理解了后序的“先子树后根”特性LCA的递归思路会清晰很多。再往下就是树形DP状态从子树向上合并几乎所有树形DP问题都是在做后序。