先问个问题:你在学二叉树的时候,是不是也经历过“前中序的迭代写法看一遍就懂,后序的非递归背了三遍还是忘”的阶段?
我当年带实习生,最爱拿这道题摸底。原因很简单:二叉树后序遍历本身定义就一句话——先左子树,再右子树,最后根节点——递归写出来也就五行代码,可一旦你从递归切到非递归,它立刻变成一堆人绕不过去的坎。同样是三种遍历,前序中序改迭代版本都不算痛苦,到了后序这里,几乎所有初学者都会卡在同一个地方。
这篇文章就是来捅破这层窗户纸的。我打算按照“递归实现是多少,递归到底帮我们做了什么;非递归为什么难;然后从贴近递归脑回路的状态标志法,到考验功底的lastVisited法,再到代码最短的双栈逆前序法”这个顺序,把后序遍历的非递归讲透。文章里所有代码都用C++写,你换成Java、Python等语言也很容易,重点是把思路吃透,别只会照抄。
1. 先从递归说起:后序遍历到底在做什么
1.1 三行代码背后藏着一次完整的系统栈操作
先看看教科书级别的递归实现,二叉树节点定义如下:
struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int v) : val(v), left(nullptr), right(nullptr) {} };后序遍历的递归版本是这段:
void postorder(TreeNode* root) { if (root == nullptr) return; postorder(root->left); postorder(root->right); cout << root->val << " "; }三行,干净利落。输出的顺序严格是:左子树所有节点、右子树所有节点、根节点。举个例子,一棵最简单的手工二叉树,根节点1,左孩子2,右孩子3,跑一遍输出是2 3 1。
这个顺序怎么保持的?依赖的是操作系统帮你做的函数调用栈。你可以把每次postorder(root->left)理解成“把当前函数压栈,等左子树处理完再回来”。后序和前序、中序最大的区别就在于:它要等左子树、右子树都处理完,才轮到当前节点输出。也就是说,一个节点在递归过程中会被“碰到”多次,但真正输出的时机是所有后代节点都处理完之后。
我经常用一个收快递的类比来解释这事:递归版后序遍历相当于你收到三个包裹,规则是必须先把邻居帮你签收的两个包裹都拆完了,才能拆自己的。系统栈就是那张“谁还没拆完”的签收单,递归调用让你不用自己记接下来该干什么,函数调用的返回地址天然帮你记着。
1.2 递归的问题:不是不好,是怕深
递归版的缺点也很明确。第一,面试笔试场景里,面试官经常明确要求“用非递归实现”,因为他想考察你对栈这个数据结构的掌握,考察你对过程控制的理解,而不是让你背个递归模板。第二,如果真的碰上一棵极端退化的树(比如每个节点只有右孩子的链状树,深度1e5级别),递归会导致函数调用栈溢出,程序直接崩掉。第三,递归会重复创建函数栈帧,虽然现代编译器有尾递归优化,但树遍历本质上不是尾递归,优化不了太多。
所以,非递归版本不是“炫技”,是实打实需要掌握的技能。
2. 为什么后序的非递归比前序中序难
2.1 先看前序和中序:它们为什么好写
前序遍历的迭代版本,是最直观的。碰到节点就输出,然后把右孩子、左孩子压栈,循环弹栈处理。整个过程“一条路走到黑”,完全不需要记住某个节点的当前状态:
void preorder(TreeNode* root) { if (!root) return; stack<TreeNode*> st; st.push(root); while (!st.empty()) { TreeNode* cur = st.top(); st.pop(); cout << cur->val << " "; if (cur->right) st.push(cur->right); if (cur->left) st.push(cur->left); } }中序的迭代也不难。核心思路是借助一个指针cur,一路向左压栈,走到空之后弹栈输出,然后转向右子树:
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(); cout << cur->val << " "; cur = cur->right; } }前序和中序的共同特点是:你不需要为一个节点区分“现在该处理右子树了”还是“右子树处理完该输出了”。前序碰到就输出,结束了;中序左走到底输出,转向右就行。
2.2 后序的核心矛盾:一个节点会被“看到”两次
后序为什么难?因为走到某个节点面前时,你并不知道它处于哪个阶段:
- 阶段一:左子树刚处理完,正准备转向右子树。
- 阶段二:右子树刚处理完,该输出当前节点了。
这两个阶段对着同一个节点,在递归实现里由函数调用栈的“返回地址”自动区分;但在非递归实现里,栈只存节点指针,无法自动表达这个信息。换句话说,非递归后序遍历需要自己额外记录“当前节点进行到哪一步了”。
理解了这一点,后序的难题就拆成了两个方向去解决:要么给每个节点额外标记状态,要么用某种方式判断它是否已经处理完右子树。下面的三种常见非递归写法,本质都是解决同一个问题,只是解决思路不同。
3. 最贴近递归脑回路的解法:状态标志法
3.1 思路:给每个节点贴一张“访问状态”便利贴
既然问题的根源是“不知道当前节点处于哪个阶段”,那最直接的办法就是给节点打标记。具体做法是用一个pair<TreeNode*, bool>入栈,bool标记这个节点是否“已经完成了左右子树的入栈准备”。
- 初次碰到的节点,标记为false。
- 第一次处理它时,把状态改成true,然后按右、左的顺序把子节点压栈。
- 下次再从栈顶弹出这个节点时,如果它是true,说明孩子都已经处理完了,直接输出。
先压右孩子,再压左孩子,是后序非递归的一个关键点。因为栈是后进先出,所以后入栈的左孩子会先被弹出处理,正好满足“先左后右”的顺序。
这个写法几乎照搬了递归的思考方式:先处理左子树,再处理右子树,最后输出根。
3.2 代码实现:状态标志法
vector<int> postorderTraversal(TreeNode* root) { vector<int> res; if (root == nullptr) return res; stack<pair<TreeNode*, bool>> st; st.push({root, false}); while (!st.empty()) { TreeNode* cur = st.top().first; bool visited = st.top().second; if (!visited) { // 第一次碰到这个节点,标记为已入栈,把左右孩子压进来 st.top().second = true; if (cur->right) st.push({cur->right, false}); if (cur->left) st.push({cur->left, false}); } else { // 孩子处理完了,输出当前节点 st.pop(); res.push_back(cur->val); } } return res; }我拿之前那个三层小树模拟一下。假设树结构是:根1,左孩子2(2没有孩子),右孩子3(3没有孩子)。
- 初始栈
[(1,false)]。 - 弹栈顶,cur=1,visited=false,改为
(1,true),压入右孩子3、左孩子2。栈变成[(1,true),(3,false),(2,false)]。 - 处理栈顶
(2,false),改为(2,true),2没孩子。下次循环碰到(2,true),弹出并输出2。 - 接着处理
(3,false),同上,输出3。 - 最后栈里只剩
(1,true),弹出输出1。
结果依次是2 3 1,完全正确。
3.3 为什么我推荐把它当作第一套非递归写法
状态标志法最大的优点是“不用背口诀,逻辑顺下来就写对了”。它几乎是递归的翻译版:每次处理孩子时,照着“先右后左”压入即可,不需要像后面介绍的写法那样绕圈思考。对于刚看完递归实现、想尽快掌握非递归的读者,这套写法是最保险的。
代价也很明显:每个节点多存了一个bool,空间消耗虽然还是O(h),但常数项变大了。在LeetCode这类平台上,如果内存卡得特别严,会有微小劣势。不过对绝大多数场景,这套写法足够用了。
4. 进阶:经典lastVisited写法与优雅的双栈逆前序法
4.1 经典单栈lastVisited写法:真正考验“手动模拟调用栈”的功底
如果你不想给每个节点额外存标志位,那就得靠“记录上一个输出的节点”来判断右子树是否已经处理完。
这个思路的核心是:后序遍历里,某个节点的右子树处理完以后,紧接着输出的下一个节点就是这个节点本身。所以,当我们从一个右孩子回到父节点时,如果发现“上一个输出的节点正好是当前栈顶的右孩子”,那就说明右子树已经处理完了,可以放心输出当前栈顶。
具体流程是这样:
- 用一个
cur指针,一路上把所有左孩子都压入栈,走到底。 - 当
cur为空时,看一眼栈顶节点top:- 如果
top有右孩子,且右孩子不是lastVisited,说明右子树还没处理过,把cur指向top->right,继续循环。 - 如果
top没有右孩子,或者右孩子就是lastVisited,说明左右子树都处理完了,弹出top,输出,并更新lastVisited = top。
- 如果
我直接给出代码:
vector<int> postorderTraversal(TreeNode* root) { vector<int> res; stack<TreeNode*> st; TreeNode* cur = root; TreeNode* lastVisited = nullptr; while (cur || !st.empty()) { if (cur) { st.push(cur); cur = cur->left; } else { TreeNode* top = st.top(); if (top->right && top->right != lastVisited) { cur = top->right; } else { res.push_back(top->val); st.pop(); lastVisited = top; } } } return res; }这段代码里最难理解的就是lastVisited这个变量。我打个比方:你在一栋楼里挨家挨户送外卖,走到一个门口时,需要判断“这家人的邻居是不是已经送完了”。lastVisited就是“我刚从哪家出来”,只要栈顶节点的右孩子正好是我刚出来的那家,说明右半边的活都干完了,现在轮到自己了。
这个写法空间复杂度是严格的O(h),时间复杂度O(n),不引入额外标志位,是很多教程和面试答案里的标准版本。缺点是对初学者不那么友好,需要吃透“右孩子等于lastVisited”这个判定的含义。
4.2 双栈逆前序法:代码最短,最“优雅”的方案
接下来是我个人最喜欢也最推荐别人去理解的一套写法,代码量最小,思路也有点小聪明。
如果回忆一下前序遍历的顺序:根、左、右。我们稍微变一下,用“根、右、左”的顺序遍历,再把结果反转,得到的就是“左、右、根”,这不是正好就是后序遍历吗。
实现上,不再像真正的后序那样“绕弯弯”,而是套用前序的模板。区别在于:入栈时先压左孩子、再压右孩子,这样出栈顺序会先右后左,得到“根右左”;然后借助另一个栈(或者用vector再反转)把结果倒序输出。
代码写出来是这样:
vector<int> postorderTraversal(TreeNode* root) { vector<int> res; if (!root) return res; stack<TreeNode*> st; stack<int> out; // 也可以用 vector 最后 reverse st.push(root); while (!st.empty()) { TreeNode* cur = st.top(); st.pop(); out.push(cur->val); // 输出到 out,相当于结果前置 if (cur->left) st.push(cur->left); if (cur->right) st.push(cur->right); } while (!out.empty()) { res.push_back(out.top()); out.pop(); } return res; }或者更省事一点,直接用一个vector保存“根右左”序列,最后一次性反转:
vector<int> postorderTraversal(TreeNode* root) { vector<int> res; if (!root) return res; stack<TreeNode*> st; st.push(root); while (!st.empty()) { TreeNode* cur = st.top(); st.pop(); res.push_back(cur->val); if (cur->left) st.push(cur->left); if (cur->right) st.push(cur->right); } reverse(res.begin(), res.end()); return res; }注意:这里压栈顺序和前面状态标志法刚好相反。状态标志法先压右再压左,是要保证左子树先处理;双栈法先压左再压右,是因为它构建的是“根右左”序列,出栈时先出右子树,所以先把左孩子压进去。这个压栈顺序的区别,很多人一乱就写岔,一定要在注释里标清楚。
双栈法的巧妙之处在于:它把后序问题退化成了前序问题,处理过程完全不需要判断阶段,每个节点只进出栈一次,代码量在所有方法里最小。缺点是引入了结果栈(或reverse),计算输出的顺序时要多想一步。
4.3 三种非递归写法对比
说了这么多,把三种方法放在一张表里对比一下,你会看得更清楚:
| 实现方式 | 额外标志 | 空间复杂度 | 代码量 | 理解难度 | 核心优点 |
|---|---|---|---|---|---|
| 状态标志法 | pair存bool | O(h) | 适中 | 低 | 最贴近递归思路,不易写错 |
| lastVisited法 | 无(多一个指针) | O(h) | 适中 | 中 | 不依赖额外标志,面试常考 |
| 双栈逆前序法 | 结果栈或vector | O(n)(结果序列) | 最短 | 中低 | 代码最优雅,逻辑最精简 |
时间上三者都是O(n),因为每个节点严格被处理一次。空间上,前两种是O(h),h是树高;第三种由于要保存“根右左”结果,空间是O(n)。不过在普通算法题里这个差距通常可忽略,真正忌讳的是写错顺序。
5. 常见问题与排查技巧实录
5.1 问题一:左右子树的处理顺序搞反了
这是后序遍历里排第一的高频错误。栈是后进先出,所以想让某个子树先被处理,就要把它后压栈。状态标志法里,先压右后压左,左孩子后被压入,却先被弹出处理,正好是“先左后右”。双栈法里,为了让出栈顺序是“右先左后”,就要先压左后压右。如果你写的版本输出结果是2 3 1而不是3 2 1,如果树的根是1、左子树是2、右子树是3,那说明顺序是对的;如果输出成1 3 2之类,十有八九是压栈顺序反了。
遇到这种问题,不要盯着代码猜,直接拿三层小树手动跑一遍栈的过程。
5.2 问题二:lastVisited忘记更新,或者判断条件写错
lastVisited法的典型错法是忘记在所有“输出并弹出”的地方更新lastVisited,结果就是右子树明明处理完了,top->right != lastVisited一直成立,程序会反复尝试进入右子树形成死循环。
另外一个常见细节是,判断右子树是否处理完时,很多人会写成if (top->right != lastVisited)而忘记先判断top->right是否为空。如果不判空,访问空节点的val会直接崩溃。一定记得写成if (top->right && top->right != lastVisited)。
5.3 问题三:递归改非递归之后,LeetCode报栈溢出
有些题测试用例会故意构造深度很大的单链表树。递归实现代码简单,但深到十万层时,函数调用栈会爆掉。如果你用了非递归实现还是栈溢出,先检查是不是用了pair状态标志法时把stack<pair<TreeNode*, bool>>写成了递归调用;其次检查lastVisited法是不是因为死循环卡死了。真正常见的爆栈原因是有些同学把非递归写在函数里,回调递归方法,等于没改成非递归。
5.4 问题四:双栈法打印出来后序是“反序”
双栈法的结果如果直接输出,出来的是“根右左”,不是“左右根”。很多人用了一个输出栈来反转,但忘了reverse(res.begin(), res.end()),或者用stack<int>时弹出顺序搞错。每次写完,建议用一棵三层简单树验证一下。
5.5 我的排查小技巧:先画树,再对着模拟
不管是自己写还是帮别人排错,我都建议在纸上画一棵三层左右的小树(根、左右孩子、左右孩子的左右孙子),把每个方法的栈变化写成手写表格来模拟。这个习惯花不了两分钟,但能让你快速定位是压栈顺序错、弹栈时机错还是判断条件错。比对着代码发呆有效一万倍。
6. 后序遍历的典型应用:从“背代码”到“用起来”
6.1 删除整棵二叉树
如果你写过内存管理或者手动清除一整棵树的代码,你会发现后序是最自然的删除顺序。理由很简单:想删一个节点,必须先把它的左子树、右子树都删干净,最后才能删自己。如果先删根,子节点就变成野指针,没法继续访问了。
非递归后序遍历在这类场景里也有现实价值,因为树的深度可能很大,递归删除容易爆栈,用状态标志法改成迭代版删除就能稳稳跑完。
6.2 计算表达式树:后序就是后缀表达式
表达式树是编译器领域的老朋友,叶子节点是操作数,内部节点是运算符。对表达式树做后序遍历,得到的序列正好就是后缀表达式(也叫逆波兰表达式,RPN)。
比如(1 + 2) * 3 - 4这棵表达式树,后序遍历结果是1 2 + 3 * 4 -。用栈对这个序列求值时,遇到数字就入栈,遇到运算符弹出两个数计算结果再压回去。整个过程完美映射了后序遍历的顺序,也解释了为什么计算器程序天然依赖这种遍历方式。
6.3 求二叉树的深度/高度
后序遍历求深度是个常考的应用,经典递归实现如下:
int maxDepth(TreeNode* root) { if (!root) return 0; return 1 + max(maxDepth(root->left), maxDepth(root->right)); }仔细看这个递归,它本质上就是后序遍历:先算左子树深度,再算右子树深度,最后拿两者较大值加1返回。所以理解了后序,很多树相关的算法你都会有一种“原来都是同一套骨架”的感觉。
如果你怕深递归爆栈,也可以把后序非递归改成深度计算,思路就是遍历时记录当前栈的高度(即当前节点到根的路径长度),取最大值。
6.4 再往前走一步:线索二叉树与Morris后序遍历
既然聊到后序的空间优化,不妨提一嘴Morris遍历。它用“线索”的思想——利用树中的空指针指向前驱或后继——把空间复杂度压到O(1)。前序和中序的Morris遍历实现相对简单,后序的Morris遍历老实说比较复杂,面试里能讲清楚思路的人并不多。
我建议不要把“会写Morris后序”当成目标,而是借此记住这个概念:非递归遍历的空间优化方向有两个,一个是像lastVisited法那样减少辅助信息,另一个是像线索二叉树那样复用空指针。当你有一天真的碰到内存紧张到必须按O(1)空间处理树的场景时,再回来翻Morris的论文也不迟。
到这里,递归、状态标志法、lastVisited法、双栈逆前序法,以及后序的几个实际应用,就都串起来了。说实话,我自己在实际写代码时,最后用的是双栈法和状态标志法居多,lastVisited法用于面试答案更合适。但不管最后用哪个,都建议从递归的视角去理解问题——后序的本质就是“先孩子后自己”,所有非递归版本都是在这个本质上加了不同的辅助手段。把这句话记在心里,代码怎么变你都不会迷路。