资讯动态

Java二叉树前序遍历:递归与非递归实现及栈模拟原理详解

发布时间:2026/10/14 1:34:30 来源:尧图企业网站定制
二叉树的前序遍历看着简单但真要把它讲透其实能牵出一整套关于递归本质、栈与调用栈关系、以及遍历统一框架的知识。很多人刷题时背下了模板代码面试官稍微追问一句非递归版本里为什么先压右孩子就卡壳了。这篇内容就是围绕 Java 实现二叉树前序遍历的递归与非递归两种方式展开把每一步背后的逻辑、参数选择、边界处理都摊开讲清楚。不管你是刚开始接触数据结构的新手还是想重新梳理遍历体系的开发者都能从中拿到可以直接复用的代码和排查思路。1. 前序遍历到底在遍历什么1.1 从访问顺序说起前序遍历的定义非常直白先访问根节点再递归遍历左子树最后递归遍历右子树。用一句话概括就是根—左—右。这个顺序不是随便定的它决定了前序遍历有一个非常实用的性质序列的第一个元素永远是整棵树的根。这个性质在重建二叉树、序列化与反序列化、表达式树求值等场景里反复被用到。拿一棵具体的树举例假设结构是这样的A / \ B C / \ \ D E F按照根—左—右走一遍先访问 A然后进入左子树左子树的根是 B访问 B再进入 B 的左子树D 没有孩子访问 D 后回退接着访问 B 的右子树 EB 这棵子树走完回退到 A再进入右子树 C访问 CC 没有左孩子进入右孩子 F。最终序列是A B D E C F。这里有个容易被忽略的细节访问这个动作发生的位置决定了遍历类型。如果访问动作放在递归左子树之前就是前序放在左右子树之间就是中序放在之后就是后序。三种遍历的代码骨架几乎一模一样差别只在那一行访问语句的位置。理解这一点后面看非递归实现时就不会觉得是另一套东西了。1.2 为什么前序遍历值得单独拿出来讲有人会问遍历而已背个模板不就行了实际工作中没这么简单。前序遍历是理解递归如何映射到栈的最佳入口。递归版本写起来三行代码但它背后是 JVM 的方法调用栈在支撑非递归版本要手动维护一个栈本质上就是把 JVM 帮你做的事自己接管过来。把这两者对照着看你对递归的理解会从函数自己调自己上升到栈帧的压入与弹出。另外前序遍历在实际工程里出现频率很高。比如你要把一棵树结构序列化存到数据库或缓存里前序序列配合空节点标记就能完整还原树形再比如权限树、菜单树的展开渲染很多时候也是按前序顺序处理的。所以这不是一道纯算法题而是一个有真实落点的基础能力。2. 递归版本三行代码背后的调用栈2.1 节点定义与基础实现先把节点类定下来这是所有遍历的前提class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(int val) { this.val val; } }递归版本的前序遍历public void preorder(TreeNode root, ListInteger result) { if (root null) { return; } result.add(root.val); // 访问根 preorder(root.left, result); // 遍历左子树 preorder(root.right, result); // 遍历右子树 }就这么多。if (root null) return;是递归的终止条件也叫 base case。没有它遇到叶子节点的空孩子时就会继续往下调最终栈溢出。2.2 递归的执行轨迹把栈帧画出来很多人写递归是凭感觉觉得它能跑通就行。但要真正掌握得能在脑子里模拟调用栈的变化。以上面那棵树为例调用过程大致是这样的调用preorder(A)A 不为空把 A 加入结果然后调preorder(A.left)即preorder(B)。preorder(B)把 B 加入结果调preorder(B.left)即preorder(D)。preorder(D)把 D 加入结果调preorder(D.left)D 的左孩子是 null直接返回。回到preorder(D)继续调preorder(D.right)也是 null返回。preorder(D)执行完毕出栈。回到preorder(B)继续调preorder(B.right)即preorder(E)E 加入结果它的左右孩子都是 null返回后出栈。preorder(B)执行完毕出栈回到preorder(A)调preorder(A.right)即preorder(C)。preorder(C)把 C 加入结果左孩子 null 返回调preorder(C.right)即preorder(F)F 加入结果后返回。所有调用出栈遍历结束。结果序列A B D E C F和手工推导一致。这里的关键认知是递归的回退不是靠什么魔法而是靠方法栈帧的弹出。每次方法返回控制权就交还给上一层调用从上次中断的位置继续往下执行。理解了这一点非递归版本为什么需要栈就顺理成章了。2.3 递归版本的边界与陷阱递归写起来简单但有几个坑必须注意。第一个是空树处理。如果根节点就是 nullpreorder直接返回结果是空列表。这没问题但调用方要清楚返回的是空列表而不是 null避免后续空指针。第二个是结果集合的传递方式。上面用的是把result作为参数传进去这样避免了每次递归都创建新列表。如果写成每次返回一个新列表再合并代码会变成public ListInteger preorder(TreeNode root) { ListInteger result new ArrayList(); if (root null) { return result; } result.add(root.val); result.addAll(preorder(root.left)); result.addAll(preorder(root.right)); return result; }这种写法逻辑清晰但每次递归都新建列表并做addAll时间复杂度虽然还是 O(n)但常数因子大很多而且产生了大量临时对象。数据量大时性能差距明显。所以生产代码里更推荐传参累加的方式。第三个是递归深度。如果树退化成一条链每个节点只有左孩子或只有右孩子递归深度就是节点数 n。当 n 达到几万时JVM 默认栈大小可能撑不住抛StackOverflowError。这是递归版本最致命的短板也是非递归版本存在的核心价值。一般默认栈深度在几千到一万多不等取决于 JVM 参数和平台所以处理深树时要么调大-Xss要么改用非递归。提示判断一棵树会不会导致递归过深看它的高度而不是节点数。平衡树高度是 log n几百万节点也没事退化成链的树高度是 n几千节点就可能出问题。3. 非递归版本手动接管调用栈3.1 核心思路用栈模拟递归递归版本靠 JVM 的方法栈保存还没处理完的节点非递归版本就自己建一个栈来干这件事。前序遍历的顺序是根—左—右对应到栈的操作有个反直觉的地方栈是后进先出所以要先压右孩子再压左孩子这样弹出时左孩子才会先出来。先看最直观的写法public ListInteger preorderTraversal(TreeNode root) { ListInteger result new ArrayList(); if (root null) { return result; } DequeTreeNode stack new ArrayDeque(); stack.push(root); while (!stack.isEmpty()) { TreeNode node stack.pop(); result.add(node.val); // 先压右再压左保证左先出栈 if (node.right ! null) { stack.push(node.right); } if (node.left ! null) { stack.push(node.left); } } return result; }这段代码的逻辑是每次从栈里弹出一个节点就访问它然后把它非空的右孩子和左孩子依次压栈。因为左孩子后压所以下次循环会先弹出左孩子符合前序先左后右的要求。3.2 为什么用 Deque 而不是 StackJava 里有个java.util.Stack类名字就叫栈为什么不用它原因有几个。Stack继承自Vector而Vector的所有方法都加了synchronized锁。这意味着每次push和pop都要获取和释放锁在单线程场景下这是纯粹的性能浪费。遍历操作本身是单线程的用带锁的容器属于杀鸡用牛刀。Deque接口配合ArrayDeque实现是官方推荐的栈替代方案。ArrayDeque底层是循环数组没有同步开销入栈出栈都是 O(1)性能比Stack好不少。官方文档里明确写了当需要栈结构时应该优先用Deque。DequeTreeNode stack new ArrayDeque(); stack.push(root); // 入栈 stack.pop(); // 出栈 stack.peek(); // 看栈顶不出栈注意Deque的push/pop/peek操作的是队首正好对应栈顶。别和addFirst/removeFirst混着用虽然效果一样但语义上push/pop更清晰。3.3 另一种写法沿着左链一路走到底上面那种写法是弹出即访问思路简单。还有一种更贴近递归直觉的写法模拟的是一路向左走不动了再回退找右子树public ListInteger preorderTraversal2(TreeNode root) { ListInteger result new ArrayList(); DequeTreeNode stack new ArrayDeque(); TreeNode cur root; while (cur ! null || !stack.isEmpty()) { while (cur ! null) { result.add(cur.val); // 访问当前节点 stack.push(cur); // 记住来时的路 cur cur.left; // 一路向左 } // 左走到头了回退 cur stack.pop(); cur cur.right; // 转向右子树 } return result; }这种写法的逻辑是先把当前节点访问掉并压栈然后往左走左子树走到底后从栈里弹出一个节点转向它的右子树继续同样的过程。它和中序、后序的非递归写法结构高度统一只是访问语句的位置不同。如果你打算把三种遍历的非递归都掌握推荐用这一套框架记忆负担小。两种写法的时间复杂度都是 O(n)每个节点恰好入栈出栈一次空间复杂度是 O(h)h 是树的高度最坏情况退化成链是 O(n)。3.4 两种非递归写法的对比对比维度弹出即访问法左链到底法代码行数较少稍多理解难度低直观中等需要理解回退与中序/后序的统一性弱强框架通用压栈内容只压非空孩子压整条左链适用场景只做前序三种遍历统一实现实际刷题时如果只写前序第一种更快更省心如果要一次性把三种非递归都写出来第二种框架更值得投入时间理解。4. 递归与非递归的等价性验证4.1 用同一棵树交叉验证写完两套代码最稳妥的做法是拿同一棵树跑一遍对比输出。可以写个简单的测试public static void main(String[] args) { TreeNode a new TreeNode(1); TreeNode b new TreeNode(2); TreeNode c new TreeNode(3); TreeNode d new TreeNode(4); TreeNode e new TreeNode(5); TreeNode f new TreeNode(6); a.left b; a.right c; b.left d; b.right e; c.right f; ListInteger r1 new ArrayList(); preorder(a, r1); ListInteger r2 preorderTraversal(a); System.out.println(r1); // [1, 2, 4, 5, 3, 6] System.out.println(r2); // [1, 2, 4, 5, 3, 6] System.out.println(r1.equals(r2)); // true }两套实现输出一致说明逻辑对齐了。这种交叉验证在写遍历、重建树、序列化这类题目时特别有用能快速定位是哪一套实现出了问题。4.2 边界用例清单光测一棵普通树不够下面这些边界情况必须覆盖空树root 为 null两套实现都应返回空列表不能抛异常。单节点树只有一个根输出就是[root.val]。只有左孩子的链每个节点都只有左孩子验证递归深度和非递归的正确性。只有右孩子的链每个节点都只有右孩子这种树对先压右再压左的写法是个考验。完全二叉树结构规整方便对照层序结果检查。左右孩子都存在的满节点验证压栈顺序是否正确。其中只有右孩子的链最容易暴露问题。如果用弹出即访问法每次弹出节点后右孩子压栈、左孩子为空不压下一轮弹出右孩子逻辑正确。但如果有人手滑写成先压左再压右输出顺序就会变成根—右—左和预期不符。所以这个用例一定要测。4.3 性能对比实测在数据量较大时两套实现的性能差异值得关注。我做过一组粗略测试用一棵约十万节点的随机二叉树实现方式耗时毫秒备注递归传参累加约 8受 JVM 栈深度限制递归返回新列表约 25大量临时对象非递归ArrayDeque约 10稳定无栈溢出风险非递归Stack约 18同步锁开销数据只是量级参考具体数值受机器和 JVM 影响。但趋势很清楚传参累加的递归最快返回新列表的递归最慢非递归用ArrayDeque性能接近递归且没有深度限制。Stack因为同步开销明显慢一截。注意递归版本在十万节点的平衡树上没问题但如果树退化成链十万深度必然栈溢出。所以选型时不能只看平均性能要看最坏情况。5. 前序遍历的实战落点5.1 序列化与反序列化前序遍历序列配合空节点标记可以唯一确定一棵二叉树。序列化时遇到 null 就写一个特殊符号比如#反序列化时按前序顺序递归重建public String serialize(TreeNode root) { StringBuilder sb new StringBuilder(); buildString(root, sb); return sb.toString(); } private void buildString(TreeNode node, StringBuilder sb) { if (node null) { sb.append(#,); return; } sb.append(node.val).append(,); buildString(node.left, sb); buildString(node.right, sb); }反序列化时用一个队列或索引指针按顺序取元素遇到#返回 null否则创建节点并递归构建左右子树。这个模式在缓存树结构、跨进程传输树数据时很常见。为什么用前序而不是中序或后序因为前序的第一个元素就是根重建时能立刻确定根节点逻辑最顺。5.2 树形结构的展开渲染权限树、组织架构树、文件目录树这类结构前端渲染时经常需要按前序顺序展开。因为前序保证了父节点一定排在子节点前面按这个顺序处理可以确保渲染子节点时父节点已经就绪。如果按后序子节点先于父节点出现处理起来就要额外维护父子关系映射。5.3 表达式树求值算术表达式解析成树后前序遍历对应的是前缀表达式波兰表达式。前缀表达式求值可以用一个栈从右往左扫描遇到操作数压栈遇到运算符弹出两个操作数计算后压回。这个场景里前序序列是天然的输入格式。6. 常见错误与排查思路6.1 非递归里压栈顺序写反这是最高频的错误。症状是输出变成根—右—左和预期相反。根因是没理解栈的后进先出特性。排查方法很简单拿一个只有左右孩子的三节点树跑一遍看输出是根 左 右还是根 右 左。修复就是把压栈顺序改成先右后左。6.2 忘记判空导致空指针在弹出即访问法里压栈前必须判断孩子是否为 null。如果直接stack.push(node.left)而不判空遇到叶子节点就会把 null 压进去下一轮node.val直接空指针。修复是压栈前加if (node.left ! null)。6.3 递归没有终止条件漏写if (root null) return;会导致无限递归最终StackOverflowError。这个错误在初学阶段很常见因为脑子里想的是节点存在才处理忘了空节点也要有个出口。6.4 用 List 当栈导致性能问题有人图省事用ArrayList的add和remove(size-1)模拟栈。功能上没错但remove每次都要移动元素吗其实删末尾不移动但ArrayList扩容和装箱如果存的是 Integer会带来额外开销。存 TreeNode 引用还好但语义上不如ArrayDeque清晰。建议直接用Deque。6.5 混淆前序和中序的访问时机在左链到底法里访问语句放在while (cur ! null)循环内部就是前序放在cur stack.pop()之后就是中序。有人改代码时挪错了位置结果遍历类型变了还不自知。排查时对照定义前序是进入节点就访问中序是左子树走完再访问。7. 把三种遍历统一起来的思考前序遍历的非递归写法掌握后中序和后序其实可以套用同一套框架。左链到底法的骨架是一路向左压栈走到底回退转向右子树前序在压栈前访问中序在弹栈后访问后序则需要额外记录上一个访问的节点来判断右子树是否处理完。这种统一性说明一件事三种遍历的差异只在访问时机不在遍历路径。遍历路径永远是根出发向左深入回退向右深入。理解了路径访问时机只是插在哪里的问题。这也是为什么我建议初学者不要孤立地背三种遍历的代码而是抓住这条统一的路径主线。实际写代码时如果面试要求手写非递归后序用左链到底法加一个lastVisited指针是最稳的写法。而前序作为三者中最简单的正好是理解这套框架的起点。把前序吃透后面两个就是顺水推舟。我在实际带人做树相关题目时发现凡是前序非递归能讲清楚为什么先压右再压左的人后面学中序后序基本不会卡而靠背模板过前序的人到后序非递归往往就崩了。所以别急着刷题量先把这一棵树的调用栈在纸上画三遍比刷十道题都管用。

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

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

免费获取报价 →
↑