最近帮一位朋友复盘二叉树前中后序遍历的迭代遍历,我忽然意识到这个知识点在算法学习里的位置确实被低估了。很多人能闭眼背出递归版本的三行代码,但一旦被追问“递归底层靠什么机制跑起来的”“如果二叉树退化成链表,递归会不会出事”,就开始支支吾吾。迭代遍历不是“面试官故意为难你”的花活,它本质上是在回答一个问题:当你不依赖系统帮你维护调用栈时,你自己能不能用显式的栈把递归的行为完整模拟出来。
这篇文章不打算绕弯,直接从“为什么递归好用但有隐患”讲起,把前序、中序、后序三种迭代写法的核心思路、代码模板、踩坑记录一次说透。它适合正在刷题准备面试的人,适合刚学完递归想彻底理解栈与遍历顺序关系的人,也适合那种“代码能跑但讲不清楚为什么”的朋友。读完你至少能达成三个目标:能默写三种迭代遍历;能解释每一行代码背后的意图;遇到后续更复杂的树形DP、最近公共祖先等问题时,敢用栈去模拟递归而不发怵。
1. 为什么迭代遍历是绕不过去的一道坎
1.1 递归的优雅与隐藏的隐患
先看一眼递归版本到底做了什么。前序遍历的递归写法大概是这样的:先处理根节点,再递归处理左子树,最后递归处理右子树。中序和后序只是交换了三句话的顺序。这种写法极简,符合人的思维直觉,阅读起来像在念一段自然的流程说明。
但你有没有想过,递归在机器层面是怎么执行的?每次递归调用,操作系统都会往“调用栈”里压入一个栈帧,栈帧里保存了当前函数的返回地址、参数值、局部变量。递归到叶子节点时,栈已经被压了树的深度那么多层;一层层返回时再弹出这些栈帧。换句话说,递归版的遍历顺序,本质上就是由系统调用栈的压栈、弹栈时机决定的。
这带来两个隐患。第一,二叉树的深度可能很大。最坏情况下,一棵树退化成类似链表的结构,深度就是节点数量。如果有一万个节点,递归调用时系统栈就得压一万层,很容易触发栈溢出。这在工作环境中不是危言耸听,很多线上服务出现栈溢出的崩溃日志,根因就是递归深度失控。第二,面试场景下,考官经常会追问“能不能不用递归写一遍”。如果只背了递归模板,当场就会露怯。这两个问题都指向同一个解:自己维护一个栈,把递归过程显式化。
1.2 用显式栈模拟递归的底层思路
把系统调用栈换成自己的栈,听起来很抽象,其实可以打个比方。递归版本的每一次函数调用,相当于往桌上放一张写着“待办事项”的纸条;处理完一个子问题,就揭掉最上面的纸条,继续看下一张。迭代遍历要做的,就是自己管理这摞纸条:你用代码决定什么时候放纸条、什么时候取纸条、取出来之后做什么。
栈的特点大家都熟,后进先出。这意味着,如果你希望某个节点先被访问,你在压栈时就要让它后进栈。比如前序遍历想先访问根,再访问左子树,最后访问右子树,那么你弹出一个节点后,需要先把右子树压栈,再把左子树压栈。这样左子树节点反而在栈的上面,下一轮循环先被弹出处理。压栈顺序和最终访问顺序正好相反,这个“反直觉”的点,正是迭代遍历最容易出错的地方。
顺着这个思路,你会发现三种遍历的迭代写法不是三个孤立的模板,而是同一件事的不同处理节奏:什么时候把根节点当“待办事项”取出来访问,决定了最终输出顺序。后面几节就按前序、中序、后序的顺序逐个拆开。
2. 前序遍历:先看懂最简单的“压栈顺序”
2.1 思路:根左右,栈是倒着放的
前序遍历的访问顺序是“根节点、左子树、右子树”,在递归写法里,你要在进入左子树前先访问根;在迭代写法里,这个动作变成了“遇到一个节点立刻处理它”。
由于栈是后进先出,想让左子树先于右子树被访问,压栈时就要反着来:先压右孩子,再压左孩子。这个顺序是前序遍历迭代版的灵魂。先压右孩子,右孩子被压在栈底;再压左孩子,左孩子在栈顶。下一轮循环弹出时,左孩子先被处理,正好符合“先左后右”。
代码结构其实很直接:
def preorder_iter(root): if not root: return [] res = [] stack = [root] while stack: node = stack.pop() res.append(node.val) if node.right: stack.append(node.right) if node.left: stack.append(node.left) return res这段代码里有一个容易忽略的小细节:判断空节点。压栈前先检查右孩子和左孩子是否为空,可以避免把空指针压入栈里,省掉后续循环里的空判断。对于空树,开头直接返回空列表,也避免了对空指针取属性报错。
2.2 手跑一遍五节点样例
光看代码可能不够直观,拿一棵具体的树走一遍。
树的结构是这样:根节点是1,1的左孩子是2,右孩子是3;2的左孩子是4,右孩子是5。正确的前序遍历结果是:1, 2, 4, 5, 3。
迭代过程分成几步走:
初始时栈为 [1]。 第一步,弹出1,输出1;压入右孩子3,再压入左孩子2。此时栈从底到顶是 [3, 2],栈顶是2。 第二步,弹出2,输出2;压入2的右孩子5,再压入2的左孩子4。栈从底到顶是 [3, 5, 4],栈顶是4。 第三步,弹出4,输出4;4没有孩子,不做压栈操作。栈剩 [3, 5],栈顶是5。 第四步,弹出5,输出5;5没有孩子。栈剩 [3]。 第五步,弹出3,输出3。
输出序列是1, 2, 4, 5, 3,和递归前序完全一致。整个过程里,节点被压进栈的次数正好等于它被访问的次数,每个节点都只入栈出栈一次,时间复杂度是O(n),空间复杂度最坏情况下是O(n)。
注意:如果你不小心把压栈顺序写成先压左孩子再压右孩子,上面的输出会变成1, 3, 2, 5, 4,这就变成了“根右左”,不是前序遍历了。这种错误特别隐蔽,因为代码照样能跑出结果,只有和标准答案对比时才会发现问题。
2.3 为什么这个写法最“省心”
前序迭代版本之所以被放在最前面讲,是因为它和“每当处理一个节点,立刻输出”的直觉最贴近。你不需要像中序那样考虑“什么时候才能访问根”,也不需要像后序那样判断“根是不是已经从右子树回来了”。只要记住“弹栈即访问,先压右再压左”,基本不会出错。
实际用的时候,我还习惯把空判断放到循环开头,统一处理,代码行数更少。但那种写法会把空节点压入栈,增加无意义的循环次数。个人建议面试时用先判断再压栈的版本,少一些边界情况,讲起来思路也更顺。
3. 中序遍历:一路走到最左边再开始访问
3.1 思路:先奔着左边界去,弹出来再向右
中序遍历的访问顺序是“左子树、根节点、右子树”。和递归版本相对应,迭代时你想模拟的是:先一路递归进左子树,直到左孩子为空,然后访问这个“最左的节点”,再处理它的右子树。
这个“一路向左”的过程,表现在代码里就是用一个内层循环不断压栈:
def inorder_iter(root): res = [] stack = [] cur = root while cur or stack: while cur: stack.append(cur) cur = cur.left node = stack.pop() res.append(node.val) cur = node.right return res外层循环的判断条件是cur or stack,这个条件值得多说两句。cur不为空,说明还有节点需要往左探索并压栈;stack不为空,说明还有已经压过栈但尚未访问的节点。二者缺一不可。只写while cur,一旦走到最左的空节点,循环直接退出,后面的根节点和右子树都不会被处理;只写while stack,初始时栈为空,空树的时候直接不进入循环,看起来没问题,但遇到右子树存在的情况,你没有办法从当前节点继续往右走。
整个算法的节奏可以概括成四步:能往左就往左压栈;压到不能压为止;弹出一个节点并访问;转到该节点的右孩子,继续重复。
3.2 中序迭代为什么要区分“压栈”和“访问”
中序和前序最大的不同,在于访问节点的时机。前序里,节点一从栈里弹出来就访问,因为根最先处理;中序里,节点从栈里弹出来也不能随便访问,必须先把它所有的左子树压完。
你可以把中序的压栈过程理解为“攒着不处理”。每压入一个节点,都是因为它在等待它的左子树先被访问。直到当前节点没有左孩子,才弹出这个节点,这时候它的左子树一定已经全部处理完了,弹出的时机正好对应递归中“左子树递归返回后访问根节点”的瞬间。
为了把这个时机说清楚,还拿上一节的那棵树走一遍中序。
正确的中序输出是:4, 2, 5, 1, 3。
cur 从根节点1开始。 先把1、2、4依次压栈,因为从1出发,左孩子是2,2的左孩子是4。此时栈从底到顶是 [1, 2, 4]。 4没有左孩子,弹4,输出4。此时 cur 被赋值为4的右孩子,为空。 栈顶是2,弹2,输出2。cur 被赋值为2的右孩子5。 cur 为5,非空,把5压栈。5没有左孩子,弹5,输出5。 栈顶是1,弹1,输出1。cur 被赋值为1的右孩子3。 把3压栈,3没有左孩子,弹3,输出3。
输出的顺序恰好是左、根、右一层层展开的结果。整个过程里每一个节点也都是入栈出栈一次,时间复杂度O(n),空间复杂度最坏O(n)。
注意:有些人写中序迭代时,会把
cur = node.right这一步漏掉,或者错误地写成在弹栈时修改栈顶节点的右孩子。漏掉这一步会导致循环陷入死循环,因为 cur 始终停留在已经访问过的节点上,无法推进。正确写法里,cur = node.right扮演的角色就是递归中“处理完左子树和根节点,转向右子树继续递归”的入口。
3.3 中序版本对后续学习的帮助
中序迭代培养的感觉很重要:访问一个“根”节点之前,你必须确认它的左子树已经全部完成。这个“确认前置条件”的思路,会直接迁移到很多树形问题上,比如二叉搜索树中第K小的节点、验证二叉搜索树,都需要按中序顺序逐个扫描节点。此时你不需要递归版的中序,直接用迭代中序配合计数器,就能轻松做到“遍历到第K个节点时提前终止”。
而递归版中序很难做到“提前终止后立刻把状态传出去”,因为递归调用链已经一层层铺开了。用显式栈则可以把退出条件写得很灵活,这也是迭代遍历除了“避免栈溢出”之外的另一个隐藏价值:控制权在你手里,你可以随时中断,可以随时恢复,不必把整棵子树全部处理完才回来。
4. 后序遍历:三种解法,从易到难
4.1 为什么后序遍历会卡住很多人
后序遍历的顺序是“左子树、右子树、根节点”。问题出在根节点上:你在访问根节点之前,必须先确认左右子树都处理完了。但是在迭代过程中,你第一次遇到根节点时,它的左右子树还都没有处理,你既不能立刻访问它,也不能简单地把它丢弃,因为等左子树和右子树完成后,你还要回到根节点上来。
这就产生了一个递归里不存在的问题:你需要在栈里记住“我已经处理到哪一步了”。前序和中序都没有这个烦恼,因为前序遇到根就先访问,中序在左子树处理完后会自然遇到根。唯独后序,根是最后收拾的那个。
解决这个问题的思路,按照从易到难可以分成三种:双栈法、prev指针法、状态标记法。每一种都有自己适合的场景,下面逐个拆。
4.2 解法一:双栈法,取巧但实用
双栈法的洞察来自于前序遍历的对称性。前序遍历顺序是“根左右”,如果把压栈顺序从“先右后左”改成“先左后右”,得到的访问顺序就变成“根右左”。再把“根右左”整体反转,就得到“左右根”,恰好是后序遍历。
实现时用两个栈。第一个栈负责生成“根右左”的顺序,第二个栈负责把顺序反过来:
def postorder_iter_two_stacks(root): if not root: return [] res = [] s1 = [root] s2 = [] while s1: node = s1.pop() s2.append(node.val) if node.left: s1.append(node.left) if node.right: s1.append(node.right) return s2[::-1]这里压栈顺序正好和前端前序写法相反:先压左孩子,再压右孩子。这样弹出的顺序就是“根、右、左”。弹出的每个值都先放进 s2,等到全部结束后,s2[::-1]把顺序倒过来,得到“左、右、根”。
双栈法在理解上特别友好,因为不需要思考“什么时候能访问根”,你只是先凑出一种顺序,再把它整个反转。实践中我不太会为它额外的空间担心,多了一个栈只放了值,复杂度依然是O(n)。缺点是它和递归过程的对应关系不那么直接,如果你想用后序迭代去做某些复杂的回溯逻辑,双栈法很难直接改造成更通用的框架。
4.3 解法二:prev 指针法,一次遍历的标准答案
prev 指针法不再反转顺序,而是直接模拟“系统递归返回”的过程。它维护一个prev变量,记录上一次访问的节点,用来判断当前栈顶节点是否该被访问。
先看代码:
def postorder_iter_prev(root): res = [] stack = [] cur = root prev = None while cur or stack: while cur: stack.append(cur) cur = cur.left node = stack[-1] if node.right and node.right != prev: cur = node.right else: res.append(node.val) prev = node stack.pop() return res这段代码里,内层循环依然负责“一路向左”压栈。压到不能压之后,栈顶就是当前最靠左的、尚未访问的节点。此时分两种情况判断:
如果这个节点的右孩子存在,并且右孩子还没有被访问过,说明右子树还没处理,那么不能访问这个节点,要把 cur 转向它的右孩子,继续下一次循环。这里的node.right != prev是判断“右孩子是否已经被访问过”的关键条件——如果prev正好指向右孩子,说明右子树刚刚整体处理完成,现在是该处理根节点的时候了。
如果右孩子不存在,或者右孩子已经访问过了,那么左子树和右子树都已经处理完,可以弹出并访问当前节点。访问后把prev更新为当前节点,再把该节点从栈里弹出。
这个“看右孩子是否被访问过”的操作,在后序迭代里核心地位很高。你可以把它理解成递归函数返回前要检查的最后两件事:左子树是否完成(已经靠内层循环保证),右子树是否完成(靠prev判断)。
4.4 解法三:状态标记法,为通用模板铺路
状态标记法的想法更直接:在栈里多存一个状态位,标记这个节点是否已经“处理过右子树”。
实现方式很多,最直白的一种是每个栈元素存(node, visited),visited为 True 表示“可以访问了”,为 False 表示“还在等子树”。以后序为例,每次弹出一个 False 的节点,就把它的节点本身标记为 True 后压回栈,再把右孩子和左孩子压入。这样弹出顺序就会变成“左、右、根”。
虽然这个写法会比最简单的前序多几次压栈操作,思想上却是最通用的。下一节会专门把它扩展成一套同时覆盖三种遍历的统一模板。这里先不展开代码,避免和后面重复。
5. 统一模板:一套代码吃透三种遍历
5.1 模板设计:状态标记与压栈顺序的规律
如果只记前中后序三个独立的代码,很容易混。有没有可能用一套统一的框架,只改动入栈顺序就能得到三种遍历?答案是肯定的,思路就是上一节提到的状态标记法。
先明确一个规律:栈是后进先出的,想要最后输出某个顺序,就要按这个顺序的逆序入栈。比如后序的目标顺序是“左、右、根”,那么压栈顺序就应该是“根、右、左”。入栈时把根节点标记为“已就绪”,弹出时如果遇见已就绪节点就直接输出,否则按目标顺序的逆序压栈,并把根标记为就绪。
这个模板大概是这样的:
def iter_traversal(root, mode): res = [] stack = [(root, False)] while stack: node, visited = stack.pop() if not node: continue if visited: res.append(node.val) elif mode == "pre": stack.append((node.right, False)) stack.append((node.left, False)) stack.append((node, True)) elif mode == "in": stack.append((node.right, False)) stack.append((node, True)) stack.append((node.left, False)) elif mode == "post": stack.append((node, True)) stack.append((node.right, False)) stack.append((node.left, False)) return res对照代码里的压栈顺序,可以看到一个清晰的规律:目标访问顺序是什么,压栈顺序就是它的反向,同时把中间那个“根”标记为 True。
前序目标是“根、左、右”,压栈顺序是“右、左、根”,其中根标记为 True,所以依次压入 right、left、node。中序目标是“左、根、右”,压栈顺序是“右、根、左”,所以依次压入 right、node、left。后序目标是“左、右、根”,压栈顺序是“根、右、左”,所以依次压入 node、right、left。把三列放一起对照,差别一目了然:
| 遍历 | 目标访问顺序 | 入栈顺序(先到后) | 根节点是否先标记为已访问 |
|---|---|---|---|
| 前序 | 根 → 左 → 右 | 右 → 左 → 根 | 最后一层再标记 |
| 中序 | 左 → 根 → 右 | 右 → 根 → 左 | 中间层标记 |
| 后序 | 左 → 右 → 根 | 根 → 右 → 左 | 最先标记 |
5.2 模板法背后的统一性
这套模板的优势很明显:对于一个需要同时掌握三种遍历的人,只需要记住“目标顺序的逆序压栈,根节点标记为已访问”这一句话,剩下的就是机械填写。
它是怎么做到统一的?关键在visited标记上。普通的显式栈只管节点本身,分不清“这个节点是第一次遇到还是右子树处理完回来的”。模板法用布尔标记把这两种状态显式区分开。弹出时若标记为 True,说明时机已经成熟,立即输出;若标记为 False,说明子树还没处理完,就按顺序把根、右、左重新压入。这也意味着每个节点会入栈两次:一次是作为 False 未处理的节点,一次是作为 True 可输出的节点。
代价也因此很明显:每个节点会经历多次入栈出栈,常数开销比前序最简版本大。实际工程里如果只是简单遍历一遍二叉树,我更倾向用前序或中序的最简迭代版,少折腾。但如果你正在准备面试,又怕三种写法记串,模板法能显著降低口误和手误的概率。
5.3 模板法在面试中的价值
面试时我通常建议按“分步策略”来讲。先写递归版本,这是基础盘;然后针对前序或中序写最简迭代版本,展示你对栈的掌握;如果被问到后序,再引出模板法或者双栈法。不要一上来就甩出状态标记模板,那样会显得像是在背代码,因为考官更想看到的是你判断“什么时候该用更复杂的标记”的思考过程。
模板法还有一个隐藏好处:它天然支持“自定义遍历规则的扩展”。比如黎曼遍历、按某种混合顺序处理节点,只要改一下入栈顺序就能做到。这在做复杂树形结构的题目时是很好的调试工具。
6. 常见问题排查与面试实战心得
6.1 高频 Bug 清单:你大概率栽在这些地方
迭代遍历代码不长,但坑却格外多。我见过初学者反复踩过的问题,整理成一张速查表:
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 前序输出变成“根右左” | 压栈顺序写反,先压了左孩子 | 记忆口诀:想先访问谁,谁就后入栈 |
| 中序遍历结果少了一截 | 外层循环只写了while cur | 改成while cur or stack |
| 中序死循环 | 弹出节点后忘了cur = node.right | 每次弹栈访问后,必须推进到右子树 |
| 后序输出变成前序 | 双栈法中 s1 压栈顺序错误 | 确认先压左孩子再压右孩子,输出整体反转 |
| 空树报属性错误 | 没有判断root是否为空 | 函数开头加if not root: return [] |
| 后序丢节点 | prev 指针更新位置不对 | 弹出访问后立即更新 prev,再把节点弹出 |
每一类问题都有同一个底层教训:迭代遍历的每一条语句,都在对应递归里的某一个具体动作。理不清这个对应关系,只能靠死记;理清了,出错的概率自然大幅下降。
6.2 推荐一个笨但有效的调试方式:打印栈
调试迭代遍历有个特别适合新手的土办法:在关键位置打印栈的内容。别觉得土,它比单步调试更直观。你可以在压栈后、弹栈前各加一行输出,打印栈里节点的值。这样每跑一次,你都能看到栈的状态变化,和手跑的样例逐行对上。
比如前序遍历时,你会看到栈的变化规律是“不断把根弹出去,再从右侧补进来”,中序遍历时,你会看到栈的特点是“左边一路压到底,结束后再一层层弹出来”。看多了以后,你会对“为什么中序要先压左子树”有一种肌肉记忆,比硬背代码可靠得多。
另一个实用技巧是在本地准备一棵固定的小树,比如上一节那棵五节点树,专门用它验证三种遍历的迭代输出。小树节点少,手动展开速度快,任何时候写新模板都能立刻对照标准输出检查。
6.3 面试官问“为什么不用递归”时,你应该怎么答
面试时遇到二叉树遍历,容易紧张,回答问题容易背模板。我的建议是先用一句话讲核心:“递归隐式使用了系统调用栈,迭代遍历是用显式栈模拟这个过程,好处是可以控制空间、避免栈溢出,而且在需要提前中断或修改遍历顺序时更灵活。”
讲前序时,重点讲“根先输出,所以弹栈就访问,右左顺序压栈”。讲中序时,重点讲“要先走到最左,所以用内层循环压栈,再弹栈访问,然后转向右子树”。讲后序时,如果不打算现场写复杂模板,可以用双栈法救场:先输出“根右左”,再反转成“左右根”。这个解释既简洁又不容易出错。
如果面试官接着追问“中间那层栈深度最坏是多少”,你还要能答出最坏情况下空间复杂度是O(n)。如果想更出彩,可以提一句后续可以利用 Morris 遍历做到 O(1) 空间复杂度,但那是锦上添花,先把迭代基础打牢比什么都重要。
我个人在实际操作中的体会是:迭代遍历真正难的地方不是代码,而是“把递归在心里拆成一步一步动作”的那种图像感。第一次写后序时,我也靠双栈法才稳住了局面;写得多了之后,再去用 prev 指针法和模板法,才逐渐明白每一种写法都是在补同一个缺口——记住“根节点被处理到了哪一步”。如果你正在刷题,建议别只背一个版本,三种写法至少都要动手敲一遍。第一次会错,第二次会慢,第三次基本就能做到随手写出来。这个过程没有捷径,但跑通了之后,后面再做树的深度优先搜索、回溯剪枝,你会明显觉得底盘扎实了很多。