news 2026/11/5 20:29:05

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java二叉树前序遍历:递归与非递归实现及栈模拟原理详解

二叉树的前序遍历看着简单,但真要把它讲透,其实能牵出一整套关于递归本质、栈与调用栈关系、以及遍历统一框架的知识。很多人刷题时背下了模板代码,面试官稍微追问一句"非递归版本里为什么先压右孩子"就卡壳了。这篇内容就是围绕 Java 实现二叉树前序遍历的递归与非递归两种方式展开,把每一步背后的逻辑、参数选择、边界处理都摊开讲清楚。不管你是刚开始接触数据结构的新手,还是想重新梳理遍历体系的开发者,都能从中拿到可以直接复用的代码和排查思路。

1. 前序遍历到底在"遍历"什么

1.1 从访问顺序说起

前序遍历的定义非常直白:先访问根节点,再递归遍历左子树,最后递归遍历右子树。用一句话概括就是"根—左—右"。这个顺序不是随便定的,它决定了前序遍历有一个非常实用的性质:序列的第一个元素永远是整棵树的根。这个性质在重建二叉树、序列化与反序列化、表达式树求值等场景里反复被用到。

拿一棵具体的树举例,假设结构是这样的:

A / \ B C / \ \ D E F

按照"根—左—右"走一遍:先访问 A,然后进入左子树;左子树的根是 B,访问 B,再进入 B 的左子树;D 没有孩子,访问 D 后回退;接着访问 B 的右子树 E;B 这棵子树走完,回退到 A,再进入右子树 C;访问 C,C 没有左孩子,进入右孩子 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, List<Integer> 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 递归的执行轨迹:把栈帧画出来

很多人写递归是"凭感觉",觉得它能跑通就行。但要真正掌握,得能在脑子里模拟调用栈的变化。以上面那棵树为例,调用过程大致是这样的:

  1. 调用preorder(A),A 不为空,把 A 加入结果,然后调preorder(A.left)即preorder(B)。
  2. preorder(B)把 B 加入结果,调preorder(B.left)即preorder(D)。
  3. preorder(D)把 D 加入结果,调preorder(D.left),D 的左孩子是 null,直接返回。
  4. 回到preorder(D),继续调preorder(D.right),也是 null,返回。preorder(D)执行完毕,出栈。
  5. 回到preorder(B),继续调preorder(B.right)即preorder(E),E 加入结果,它的左右孩子都是 null,返回后出栈。
  6. preorder(B)执行完毕出栈,回到preorder(A),调preorder(A.right)即preorder(C)。
  7. preorder(C)把 C 加入结果,左孩子 null 返回,调preorder(C.right)即preorder(F),F 加入结果后返回。
  8. 所有调用出栈,遍历结束。

结果序列A B D E C F,和手工推导一致。这里的关键认知是:递归的"回退"不是靠什么魔法,而是靠方法栈帧的弹出。每次方法返回,控制权就交还给上一层调用,从上次中断的位置继续往下执行。理解了这一点,非递归版本为什么需要栈就顺理成章了。

2.3 递归版本的边界与陷阱

递归写起来简单,但有几个坑必须注意。

第一个是空树处理。如果根节点就是 null,preorder直接返回,结果是空列表。这没问题,但调用方要清楚返回的是空列表而不是 null,避免后续空指针。

第二个是结果集合的传递方式。上面用的是把result作为参数传进去,这样避免了每次递归都创建新列表。如果写成每次返回一个新列表再合并,代码会变成:

public List<Integer> preorder(TreeNode root) { List<Integer> 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 List<Integer> preorderTraversal(TreeNode root) { List<Integer> result = new ArrayList<>(); if (root == null) { return result; } Deque<TreeNode> 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 而不是 Stack

Java 里有个java.util.Stack类,名字就叫栈,为什么不用它?原因有几个。

Stack继承自Vector,而Vector的所有方法都加了synchronized锁。这意味着每次push和pop都要获取和释放锁,在单线程场景下这是纯粹的性能浪费。遍历操作本身是单线程的,用带锁的容器属于杀鸡用牛刀。

Deque接口配合ArrayDeque实现是官方推荐的栈替代方案。ArrayDeque底层是循环数组,没有同步开销,入栈出栈都是 O(1),性能比Stack好不少。官方文档里明确写了,当需要栈结构时应该优先用Deque。

Deque<TreeNode> stack = new ArrayDeque<>(); stack.push(root); // 入栈 stack.pop(); // 出栈 stack.peek(); // 看栈顶不出栈

注意Deque的push/pop/peek操作的是队首,正好对应栈顶。别和addFirst/removeFirst混着用,虽然效果一样,但语义上push/pop更清晰。

3.3 另一种写法:沿着左链一路走到底

上面那种写法是"弹出即访问",思路简单。还有一种更贴近递归直觉的写法,模拟的是"一路向左,走不动了再回退找右子树":

public List<Integer> preorderTraversal2(TreeNode root) { List<Integer> result = new ArrayList<>(); Deque<TreeNode> 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; List<Integer> r1 = new ArrayList<>(); preorder(a, r1); List<Integer> 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指针是最稳的写法。而前序作为三者中最简单的,正好是理解这套框架的起点。把前序吃透,后面两个就是顺水推舟。

我在实际带人做树相关题目时发现,凡是前序非递归能讲清楚"为什么先压右再压左"的人,后面学中序后序基本不会卡;而靠背模板过前序的人,到后序非递归往往就崩了。所以别急着刷题量,先把这一棵树的调用栈在纸上画三遍,比刷十道题都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 2:24:24

Proteus 8.17 安装避坑完整指南:版本选型、激活汉化与仿真验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/4 23:36:02

Word/WPS接入DeepSeek R1:VBA集成实战与办公AI协作者构建

简介&#xff1a;本资源是一份面向办公自动化场景的AI集成实战指南&#xff0c;专为希望提升Word与WPS文档处理效率的专业人士及AI工具尝鲜者设计&#xff0c;解决传统办公软件缺乏智能文本生成、润色与理解能力的痛点。教程系统覆盖DeepSeek R1 API密钥申请规范、Word宏配置全…

作者头像 李华
网站建设 2026/10/30 4:57:55

ROS2小车从仿真到实车:Autoware部署全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 16:09:08

随机减量技术RDT实战:MATLAB提取模态阻尼比与避坑指南

简介&#xff1a;本资源面向土木、航空航天及机械等领域的结构动力学研究者与工程技术人员&#xff0c;提供一套在MATLAB环境下基于随机减量技术&#xff08;RDT&#xff09;的结构阻尼比识别程序。针对传统阻尼比识别易受噪声与人为因素干扰的问题&#xff0c;该程序通过RDT从…

作者头像 李华
网站建设 2026/10/26 16:07:39

I8kfanGUI戴尔风扇控制全攻略:从底层原理到参数调优

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/26 22:12:43

TM1804 RGBW灯带驱动开发:从协议时序到工程避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华