资讯动态

二叉树中序遍历详解:递归、迭代与常见错误排查

发布时间:2026/10/5 3:45:43 来源:尧图企业网站定制
1. 为什么我还在认真整理“二叉树的中序遍历”带过的新人不少每次聊到二叉树我第一反应不是问“你会不会写前序”,而是让他先写一个中序遍历。原因很简单中序遍历是三种深度遍历里最容易被“背下来就忘”的一个也是最能看出一个人到底有没有理解递归序的题目。前序好写后序难一点但模板固定而中序夹在中间既要处理左子树的“先去不访问”又要处理访问时机稍不注意就是空指针、错序、死循环。很多人觉得二叉树的中序遍历太基础LeetCode 上那题简单难度面试也常考翻来覆去不就是“左根右”吗但实际上能把递归版本写得漂亮的人不少能把迭代版本一次跑通的人却不多能讲清楚为什么中序遍历在二叉搜索树也叫搜索二叉树、BST里能得到升序序列的人更少。至于线索二叉树、Morris 遍历、递归爆栈这类进阶话题大部分人是“听过名字没动手写过”。这篇文章我不想写成教科书式的知识点堆砌而是按我平时帮人 review 代码、排查运行时错误时积累的经验来聊。先拆中序遍历的本质再给递归和迭代的完整实现接着集中聊我在实际调试中见过的高频错误最后说说中序结果在工程里的真实用途包括 BST 第 k 小、线索二叉树和表达式处理。如果你正准备面试、正在补数据结构基础或者莫名其妙被线上一个“递归爆栈”打懵过这篇文章应该能帮你少走点弯路。1.1 中序遍历并不只是“左根右”网上几乎所有资料都会告诉你中序遍历就是先左子树、再根节点、再右子树简写“左根右”。这句话没错但它只描述了结果顺序没描述过程。真正写代码时你的函数会在左子树和右子树之间来回切换每一次递归调用都带一个隐式的“返回现场”。对二叉树这样的递归结构来说遍历顺序是由“访问当前节点的时机”决定的而不是由“代码行的物理顺序”决定的。我见过不少初学者把三行调用顺序背得滚瓜烂熟可真到了需要把遍历顺序和“递归返回”结合起来的场景比如在中序遍历中找前驱后继、判断 BST 合法性、把中序序列和另一个序列重建二叉树时就完全接不上。所以我建议大家把“左根右”忘掉记住一句话中序遍历的本质是“先深入左子树到底沿途先不处理节点从最左节点返回时处理然后进入右子树重复整个过程”。这句话才是写迭代版中序遍历的钥匙。因为迭代版里你没有递归系统帮你保存路径你必须自己维护一个栈而栈里压的正是“沿途还没处理的祖先节点”。理解了这一点你就能理解为什么迭代中序的代码里要先一路把左孩子压栈再弹栈访问再转右孩子。1.2 这篇文章能给谁省时间如果你处于下面几种状态之一这篇文章会比较对口准备算法面试中序遍历的递归和迭代都要手写但迭代版本总是绕晕。工作中写搜索二叉树相关的功能需要用到中序遍历结果但不确定什么时候该用递归、什么时候该显式开栈。调试二叉树相关程序时频繁遇到运行时错误比如空指针崩溃、栈溢出、死循环想系统梳理一下排查思路。对线索二叉树、Morris 遍历这类优化方案好奇想知道它们和中序遍历是什么关系。下面我不会按“数据结构教材”的顺序从定义讲到性质而是直接从中序遍历的“递归骨架”切入然后给可复现代码最后落到实践和坑上。2. 中序遍历的本质从遍历框架看递归顺序二叉树遍历不是算法是“框架”。所有递归遍历都可以套同一个模板唯一的区别是当前节点的访问动作放在哪里。这个模板你看一遍就会记住比死记“左根右”可靠得多。void traverse(TreeNode* root) { if (root null) return; // 这里放处理逻辑就是前序 traverse(root.left); // 这里放处理逻辑就是中序 traverse(root.right); // 这里放处理逻辑就是后序 }代码里那三个位置正好对应“进入节点时处理”“从左子树返回后处理”“从右子树返回后处理”。中序取的是中间那个位置所以它天然适合那些需要“先拿到左子树信息再处理当前节点再拿到右子树信息”的场景。2.1 递归序的骨架什么时候访问当前节点递归序这个概念我是在一次排查“为什么树的深度和遍历顺序对不上”时彻底想明白的。每个节点在递归过程中其实会被访问三次第一次是刚进入函数第二次是左子树递归返回后第三次是右子树递归返回后。前序取第一次中序取第二次后序取第三次。放到一棵只有三个节点的树上走一遍就清楚了。根节点 1左孩子 2右孩子 3。递归过程是进入根节点 1第一次经过。进入左孩子 2第一次经过左孩子无子树直接返回第二次经过左孩子再返回。回到根节点 1第二次经过此时打印 1。进入右孩子 3第一次经过无子树返回第二次经过右孩子。回到根节点 1第三次经过函数结束。中序打印结果是 2、1、3正好是每个节点的“第二次经过”顺序。为什么是这个顺序因为左子树必须完整返回当前节点才能轮到“第二次经过”而右子树还没进去所以当前节点又排在右子树之前。这就是“为什么中序序列在 BST 里有序”的最底层原因也是后面理解线索二叉树的基础。用一句话总结中序遍历 按递归返回顺序访问每个节点位置在左右子树之间。2.2 二叉树深度与遍历顺序的关系二叉树的深度和遍历顺序是两个容易混淆的概念。深度描述的是树的结构层级遍历顺序描述的是节点的访问次序。但它们有一个实际的结合点递归遍历的每一层调用恰好对应树上的一层深度。很多人在“写二叉树程序时为什么总是报运行时错误”时会遇到一个现象递归深度等于二叉树深度而二叉树深度在最坏情况下可能等于节点数。比如一棵只有左孩子的链状树深度就是 N此时中序递归会递归 N 层。如果 N 达到几万、几十万系统栈一旦扛不住程序直接崩溃。计算二叉树深度本身也经常借助遍历来做。比如求最大深度int maxDepth(TreeNode* root) { if (root null) return 0; return Math.max(maxDepth(root.left), maxDepth(root.right)) 1; }这个递归的后序味道很浓先拿到左右子树深度再在当前节点加 1。我们讨论中序遍历时常忽略它还承担着一个隐形作用——如果一棵树的深度较大中序递归的栈开销和前序是一模一样的都是 O(深度)。所以不要觉得“中序就是几行代码的事”在大深度树上该考虑迭代版本就得考虑。2.3 为什么中序遍历能排序搜索二叉树搜索二叉树也就是 BST有一个核心性质左子树所有节点值小于当前节点右子树所有节点值大于当前节点。结合中序的“左子树全部处理完再处理当前节点再处理右子树”你会得到严格的升序序列。这个性质不是巧合而是由 BST 的定义和中序遍历的位置共同决定的。每次递归到当前节点时左子树里所有比它小的值都已经打印完毕右子树里所有比它大的值都还没开始所以每个节点打印时它前面的节点值一定小于它后面的节点值一定大于它。工程上我们常利用这个性质做三件事校验一棵树是否真的是 BST只需要中序遍历一遍检查序列是否严格递增。找 BST 中第 k 小的元素可以直接中序遍历数到第 k 个就停。把中序序列和 BST 相互转换比如把一棵 BST 转成双向链表很多解法就是基于中序遍历。我记得第一次用中序序列校验 BST 时还犯过一个低级错误只检查了当前节点和左孩子、右孩子的直接大小关系没检查整棵子树。后来才发现正确做法是看中序序列整体是否递增而不是分散地检查父子关系。这算是一个很典型的“理解遍历顺序比记住定义更重要”的例子。3. 递归实现和迭代实现不能只会递归考试写递归开发写迭代这句话虽然极端但在大深度场景下是成立的。我把两种实现都写出来并且把每一步为什么这么做讲透。代码我用类似 Java/C 的风格写核心逻辑一致你换语言时只需要改语法。3.1 递归版本几行代码背后的调用栈递归版本是最短的中序实现public void inorder(TreeNode root, ListInteger result) { if (root null) { return; } inorder(root.left, result); result.add(root.val); inorder(root.right, result); }这段代码为什么能按左根右输出关键在于inorder(root.left, result)这行会完整执行完毕包括它内部所有的递归调用之后才会执行result.add(root.val)。你可以把递归调用想象成一个“暂停点”当前函数执行到这里先把当前节点信息保存到系统栈然后一头扎进左子树等左子树全部结束才回到刚才的暂停点继续。注意一个细节递归版本的顺序是靠“系统栈”维持的不是靠额外数据结构。所以只要你理解了函数调用栈的压栈、弹栈过程递归版本就不会写错。最容易错的反而是退出条件很多人会忘记if (root null) return或者把返回条件写成if (root.left null root.right null)后一种写法看似“优化”实际上会把单子树节点弄崩。我建议在本地跑代码时把递归函数里打印节点的位置看成是唯一的“业务代码”其他都是“框架”。只要框架不出错中序序列一定对。3.2 用显式栈模拟系统调用递归代码短但是每一层调用都有函数调用开销还要消耗系统栈空间。深度大时容易爆栈这时候就得用显式栈把“系统帮我们做的事情”自己来一遍。迭代版中序的标准写法public ListInteger inorderTraversal(TreeNode root) { ListInteger result new ArrayList(); DequeTreeNode stack new ArrayDeque(); TreeNode cur root; while (cur ! null || !stack.isEmpty()) { // 一路向左沿途节点全部压栈不访问 while (cur ! null) { stack.push(cur); cur cur.left; } // 弹出一个节点此时说明它的左子树已经处理完 cur stack.pop(); result.add(cur.val); // 访问当前节点 // 转向右子树重复上述过程 cur cur.right; } return result; }这段代码最好对着递归版本读。递归里inorder(root.left)的作用是“把当前节点挂起处理左子树”迭代里对应stack.push(cur); cur cur.left;。递归里从调用返回后执行result.add(root.val)迭代里对应cur stack.pop(); result.add(cur.val);。递归里接着进入右子树迭代里对应cur cur.right。刚开始学迭代时很多人会问为什么弹出节点之后不用管它的左子树因为在弹出来之前这个节点左子树里的所有节点都已经被弹出来访问过了。你仔细想想压栈过程只要当前节点有左孩子就一直压一直压到最左下角然后弹出最左下角节点时它的左子树为空所以可以直接访问访问完它的右子树如果右子树为空再弹出它的父节点此时父节点的左子树确实已经全部处理完。整个逻辑闭环。这里还要提醒一个点while (cur ! null || !stack.isEmpty())这个外层条件很多人会写成while (!stack.isEmpty())一上来就漏了根节点为 null 的边界。还有人在弹栈之后忘了cur cur.right导致同一个节点反复访问程序死循环。这类错误我集中放在下一节说。3.3 Morris 遍历不额外开栈的“高级版”Morris 遍历是解决中序迭代的另一种思路它的核心是利用树里大量空闲的 right 指针把返回路径临时记录下来实现 O(1) 额外空间。很多人听到“线索化”会觉得这是竞赛内容但我认为理解它有助于把中序遍历的本质看得更清。Morris 中序的大致步骤当前节点cur从root开始。如果cur没有左孩子直接访问cur然后cur cur.right。如果cur有左孩子找到左子树的最右节点pre这个节点在左子树中序遍历中是cur的前驱。如果pre.right为空说明这个前驱还没挂线索把pre.right指向cur然后cur cur.left。如果pre.right已经指向cur说明左子树已经处理完这时把pre.right恢复为空访问cur然后cur cur.right。这段代码比显式栈版本抽象得多第一次看记不住正常。我的建议是先理解“为什么要找左子树最右节点”因为在中序序列里访问完左子树最后一个节点之后下一个就是当前节点Morris 利用这个空隙把当前节点暂存在前驱的右指针上让遍历在访问完前驱后能“顺着线索”回到当前节点。我平时不推荐业务代码里无脑上 Morris因为它修改了树的临时结构稍不注意就会在并发场景下出问题。但在题目明确要求“额外空间 O(1)”时它是标准解法。面试时如果你能把 Morris 的思路讲清楚绝对比只背代码的人高一截。4. 现场写中序时最容易踩的运行时错误“写二叉树程序时为什么总是报运行时错误”是很多初学者和面试者最头疼的问题。二叉树相关代码短但崩溃率非常高。我整理了中序遍历场景下最常见的三类运行时错误每类都给出排查思路。4.1 最常见的运行时错误空指针访问空指针问题在中序代码里至少有三种出现方式。第一递归函数没处理空节点。比如你在root.left或者root.right上直接取val而它恰好是 null运行时必崩。正确的退出条件就是函数一进来先判断root null。有些人想“我给每个节点都判断一下左右孩子是否为空再调用”这样也能跑但代码会啰嗦很多而且容易漏。我见过最离谱的写法是if (root.left ! null) inorder(root.left); result.add(root.val); if (root.right ! null) inorder(root.right);这段代码的问题在于如果root本身是 null第一行root.left就崩了。你还需要在外面先判root null。与其这样不如统一在函数开头判空。第二迭代版本里压栈循环写错位置。比如先判断了cur不为空但压栈之后没有把cur更新成cur.left下一次循环还在处理同一个节点这不算空指针但会导致栈无限增长。反过来如果错误地把空节点也压栈弹出后访问stack.pop().val也会空指针。第三Morris 遍历里找前驱时的死循环隐患。找左子树最右节点时必须通过pre.right ! null pre.right ! cur来限制否则你会顺着自己挂的线索一路跑回祖先节点然后在循环里转圈。这类问题在逻辑上是“运行时错误”的另一种表现不崩溃但卡死。4.2 递归深度过大导致爆栈栈溢出在 LeetCode 上不一定触发因为测试数据通常控制深度。但真实项目里如果二叉树接近链状比如把1、2、3、4…依次插成只有右孩子或只有左孩子的树递归中序栈深度就是 NN 到几万就可能爆。有一个典型的排查场景程序跑着跑着突然报StackOverflowError你第一反应是代码死循环但加上日志后发现打印的顺序正常只是打印到某个深度后崩了。这就是典型的递归深度过大。解决方案不外乎三个把递归改成显式栈迭代版本栈深度还是 O(深度)但栈在堆上可控性更好而且不会触发系统线程栈限制。使用 Morris 遍历把空间复杂度降到 O(1)彻底不依赖递归深度。从源头控制树深度比如使用平衡树结构把树的深度维持在 O(log N) 级别。我自己的经验是业务代码里如果无法保证树是平衡的就直接上迭代版本别跟系统栈较劲。不要觉得迭代版本“不优雅”稳定不出事比优雅重要。4.3 迭代写法里的死循环现场迭代中序的死循环我见过三种形态。第一种是外层循环条件里少了cur ! null。根节点为空时栈也是空此时如果不进入循环直接返回空结果是对的行为但如果你写的是while (!stack.isEmpty())遇到空树不会报错只是结果为空。这不算死循环但属于边界 bug。第二种是弹栈后没有更新cur cur.right。栈会不断弹出同一个节点吗不一定但它会造成“当前节点访问完后又去访问它的左子树”的假象。因为循环回到外层后如果还有节点在栈里会继续走内层while (cur ! null)而cur还停留在刚访问过的那个节点它可能还有左孩子于是又把一整条路径压了一遍之后又弹出重复节点。表现就是结果里出现重复元素或者调试时感觉节点被访问了两次。第三种是更新方向写反cur cur.left本该转向右孩子时继续钻向左子树。这种会不断把左子树重新压栈栈越压越大最终内存溢出。排查方法很简单在中序迭代里访问节点时打印一下cur.val如果出现连续重复值先检查弹栈后是不是忘了cur cur.right如果出现结果顺序明显违反“左根右”检查是不是把左右搞反了。我还遇到过一个隐蔽问题使用ArrayDeque时禁止向其中添加 null。有人在迭代版本里把空节点也往里塞直接抛NullPointerException。用LinkedList的话它允许 null但语义不清晰等于把空指针问题隐藏了。所以我的建议是中序迭代栈永远只存非空节点通过cur cur.left;的方式推进而不是把cur.left直接压栈前不判断。5. 中序结果在真实项目里到底怎么用中序遍历的“升序”性质在真实业务里非常有用。下面我挑三个典型场景展开搜索二叉树相关操作、线索二叉树的构建、表达式处理。这三个方向覆盖了从基础到进阶的大部分应用。5.1 搜索二叉树与中序序列先看一个最常见的需求给定一棵搜索二叉树求出第 k 小的值。最直接的办法就是中序遍历数到第 k 个节点时返回。这个思路在节点数不多时完全够用代码也不用多想。public int kthSmallest(TreeNode root, int k) { int count 0; int result -1; DequeTreeNode stack new ArrayDeque(); TreeNode cur root; while (cur ! null || !stack.isEmpty()) { while (cur ! null) { stack.push(cur); cur cur.left; } cur stack.pop(); count; if (count k) { return cur.val; } cur cur.right; } return -1; }这个代码本质上就是中序遍历的迭代版只是在访问节点时加了计数。如果你需要在大量查询里频繁找第 k 小可以提前把中序序列缓存成数组每次查用二分但如果树经常动态插入删除缓存更新成本高不如每次现遍历。还有一个经典操作判断一棵树是不是 BST。正确姿势是中序遍历一遍看序列是否严格递增。比如[5, 3, 8]就不是递增这棵树肯定不是 BST。判断时注意“严格”两个字因为 BST 定义里通常不允许重复出现相等值就说明结构不合法。我踩过的坑是一开始只比较相邻节点没考虑中序序列整体递增结果把一棵局部满足条件但整体非 BST 的树误判成 BST。后来我统一用“保存前一个节点值”的方式做递增校验一次遍历就够空间 O(1)但前提是你对中序遍历的理解够深。5.2 线索二叉树中序的另一种缓存方案线索二叉树这个名字听起来玄乎但它其实是“把空指针利用起来”。一棵普通二叉树里有大量空指针特别是叶子节点的 left 和 right。线索二叉树的思路是如果某个节点的左指针为空就让它指向前驱节点在中序序列里的前一个节点。如果某个节点的右指针为空就让它指向后继节点在中序序列里的下一个节点。用额外的标志位区分左右指针到底是“真实子节点”还是“线索”。构建中序线索二叉树的过程本质上就是做一次中序遍历在遍历过程中记录前驱节点并且把前驱的右线索指向当前节点、当前节点的左线索指向前驱。这里的关键是你不能在访问当前节点时直接设置它的左线索因为此时前驱已经确定了但后继要等访问到下一个节点时才能确定所以右线索总是“滞后”设置的。用线索二叉树做中序遍历时不需要递归也不需要栈只需反复找“最左节点”然后利用右线索向右移动。如果右指针是真实子节点就继续找它的最左子树。空间 O(1)比 Morris 更好理解是一种“事前建好线索”的缓存方案。我实际使用线索二叉树不多因为它修改了树的指针结构增大了读写复杂度和出错面。但在“需要频繁中序遍历、且树结构基本不变”的场景比如做某种范围查询缓存它确实能把每次遍历从 O(N) 空间降到 O(1) 空间很实用。5.3 表达式求值与中序遍历表达式树是一种特殊二叉树叶子节点是操作数内部节点是运算符中序遍历它你会得到原表达式的常规写法中缀表达式比如(1 2) * 3对应的树中序输出大致是1 2 * 3。这里有个坑光靠中序序列并不足以唯一恢复运算符优先级。1 2 * 3如果没有括号你会以为先算加法但实际可能对应的是1 (2 * 3)也可能对应(1 2) * 3所以表达式树的中序输出一般加上括号才能唯一还原。做法是在递归输出时如果当前节点是运算符且子树里有更低优先级的运算符就加上括号。虽然这个方向已经不算单纯的“二叉树中序遍历”但它能帮你理解同一个遍历序在不同语义下的解释方式。中序序列本身是线性的但树是有层次的把线性序列还原成树时往往还需要额外的信息比如前后序、括号、或者节点类型。我建议初学者做一个练习给定一棵表达式树用中序遍历给表达式加括号再解析并计算。这个练习能同时锻炼递归、括号匹配和二叉树的直觉比单纯刷“二叉树的中序遍历”那题收获大得多。6. 复盘我的几个中序实现选择最后聊点个人体会。我在项目里写中序遍历基本遵循一个原则默认递归遇到深度风险就换迭代遇到空间限制就上 Morris 或线索化方案。先说递归。在节点数几千、深度几十的情况下递归中序是最清晰、最不容易写错的。尤其是配合函数式语言或者 Java 的流式处理代码可读性极高。团队 review 时递归版本几乎不需要注释。但你别把递归当成唯一解面试时如果只写出递归对方追问迭代版本你就得能接住。再说迭代。我偏好显式栈版本因为它在所有普通场景下都能替代递归而且调试起来思路可控。很多人觉得迭代代码“丑”但我认为它的每一步都能对应到递归的某一帧理解以后写起来并不慢。最后是 Morris 和线索二叉树。这两个方案我都只在特定场景用一是题目明确要求 O(1) 空间二是树结构不许轻易修改但可以接受线索化后的逻辑。日常业务代码里我不会为了让代码“高级”而强行使用因为临时线索的恢复过程一旦漏掉一步就把树改坏了线上问题会非常难排查。关于“写二叉树程序时为什么总是报运行时错误”我最大的体会是大部分错误不是你不会遍历而是没有把递归栈、空指针、节点指针方向这三件事想清楚。你可以在本地多写几遍中序迭代版每写一遍都打印每一步的栈变化坚持几次这类错误率会明显下降。我最后一次遇到中序相关的线上事故是递归统计 BST 节点时在深树上爆栈。后来改成显式栈中序遍历问题没有再出现过。如果你看到这里也准备优化自己的二叉树代码建议先拿一棵深度 10000 的链状树压一下你自己的实现能扛过这一步你才算真的吃透了中序遍历。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑