☰
Java开发者必会:手写二叉树遍历与递归实战
2026/10/16 4:31:37 网站建设 项目流程

1. 为什么我建议每个Java开发者都认真手写一遍二叉树

二叉树这个数据结构,在Java开发里出现的频率远比你想象中要高。工作三年以上的服务端开发者,多多少少都会在代码评审里见过红黑树相关的TreeMap、TreeSet源码,或者在生产环境排查过HashMap在极端情况下的树化问题。但真正能脱稿写出二叉树遍历、翻转、求深度这些基础操作的人,比例其实不高。原因很简单,平时业务代码写得多,树的逻辑被各种框架封装掉了,用不到就生疏了。

但面试几乎是必考的。更重要的是,二叉树的思维方式——递归、分治、层级的抽象——会在你写业务逻辑时潜移默化地起作用。我见过不少同事在处理组织结构树、评论区的多级回复、权限菜单的层级展示时,思路混乱,代码写得又长又绕。如果能把二叉树的基本功打牢,这类业务场景的代码会清晰得多。

这篇文章不聊虚的。我用自己的实战经验,把二叉树的Java实现从头到尾拆一遍,包括节点定义、递归与非递归遍历、常见操作(求深度、判断平衡、翻转树等),以及刷题和实际开发中踩过的一些坑。适合三类人看:正在准备面试的Java开发者、工作中需要处理树形数据的后端开发、以及想系统梳理数据结构基础的在校学生。

2. 二叉树的核心设计思路:从现实模型到代码抽象

2.1 二叉树的定义与Java表达

先明确一点,二叉树到底是什么。用人话说,二叉树是一种每个节点最多只有两个子节点的树形结构,左边的叫左孩子,右边的叫右孩子。这两个孩子有严格的左右顺序,调换位置就变成了另一棵树。

在Java里表达一个二叉树节点,最朴素的方式就是写一个内部类:

public class TreeNode { int val; TreeNode left; TreeNode right; TreeNode() {} TreeNode(int val) { this.val = val; } TreeNode(int val, TreeNode left, TreeNode right) { this.val = val; this.left = left; this.right = right; } }

就这么简单。一个字段存值,两个字段分别指向左子树和右子树的根节点。空指针表示“这里没有子树了”。理解了这个结构,就能理解二叉树后续所有操作的递归本质——因为树本身就是递归定义的:一棵二叉树要么为空,要么由一个根节点和左右两棵二叉树组成。

这里我得强调一个很多新手容易忽略的点:二叉树不是链表,但它的存储结构和双向链表有着相似之处。链表是线性结构,只有前后关系;二叉树是层级结构,有父子、兄弟关系和深度概念。但两者在Java里都是用引用(指针)串联起来的,所以在写代码时要时刻注意空指针的边界。

2.2 为什么二叉树问题几乎都是递归问题

二叉树本身就是递归定义的,所以你用递归去处理它,是天然契合的。每次递归,本质上是在做同一件事:处理当前节点,然后交给左右子树。

我用一个生活类比来解释。想象你在组织一场公司年会,你是最大的领导,你需要通知你的两个副手,他们再分别通知自己的下属。这个通知的过程就是递归:每个人只需要通知自己的直接下属,一层层往下传,直到没有下属为止。二叉树的递归遍历就是这个感觉,只是这里的“下属”固定只有两个——左和右。

递归代码的好处是简洁、直观、符合人类思维。坏处是如果递归深度太大,JVM的调用栈会溢出。这个问题我在讲遍历的时候会专门展开。

2.3 二叉树的分类:不只是“一棵树”那么简单

在Java里做二叉树,你得先分清自己在处理哪种树,因为不同的树有不同的性质,处理方式也不同。

  • 满二叉树:每一层节点都满了,最后一层全是叶子。这种树形状完美,总节点数等于 2^深度 - 1。
  • 完全二叉树:除了最后一层,其他层都满了,最后一层的节点从左到右连续排列。堆(PriorityQueue的底层结构)就是完全二叉树。
  • 二叉搜索树(BST):左子树所有节点的值都小于根节点,右子树所有节点的值都大于根节点,左右子树各自也满足这个性质。这个树有个好处——中序遍历的结果就是有序的。
  • 平衡二叉树(AVL树 / 红黑树):在BST基础上增加了平衡约束,防止树退化成链表。Java的TreeMap就是红黑树实现的。

不同的树,操作时的判断逻辑完全不同。比如搜索,普通二叉树只能遍历,二叉搜索树可以按值减半查找。我在实际中见过同事把普通二叉树当搜索树用,遍历半天找不到目标值,最后发现是因为树本身没有排序约束——这是概念没分清导致的低级BUG。

3. 用Java手写二叉树的核心实操:构建、遍历与经典操作

3.1 如何手动构建一棵二叉树

在实际开发中,二叉树最常见的数据来源有两种。一种是线性数据结构转换来的,比如数组、列表;另一种是递归构建的,比如根据业务数据生成组织结构树。

先说手动构建,这种场景在做算法题、写单元测试时最常用。比如我要构建这样一棵树:

1 / \ 2 3 / \ \ 4 5 6

用Java代码构建就是:

TreeNode root = new TreeNode(1); root.left = new TreeNode(2); root.right = new TreeNode(3); root.left.left = new TreeNode(4); root.left.right = new TreeNode(5); root.right.right = new TreeNode(6);

就这么直接,一个个把节点new出来,然后把孩子挂上去。这是调试和测试时最可靠的方式,我建议每个初学者都至少手动建过三棵以上结构不同的树。因为只有亲手把每个节点的引用关系理顺了,才能真正理解“节点是节点,结构是结构”这件事——节点之间是通过引用建立结构的,节点本身并不知道自己是谁的左孩子还是右孩子。

再说说从数组构建二叉树的场景。很多人一开始会误以为数组转二叉树是那样一股脑塞进去就行,其实不然。数组只是我们通过某个线性序列去描述树形结构,最常用的是层序序列化方式,即每一层从左到右放。构建的核心是利用队列,一层层把节点挂上去。

public static TreeNode createTree(Integer[] arr) { if (arr == null || arr.length == 0) { return null; } TreeNode root = new TreeNode(arr[0]); Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); int index = 1; while (!queue.isEmpty() && index < arr.length) { TreeNode node = queue.poll(); if (arr[index] != null) { node.left = new TreeNode(arr[index]); queue.offer(node.left); } index++; if (index < arr.length && arr[index] != null) { node.right = new TreeNode(arr[index]); queue.offer(node.right); } index++; } return root; }

注意这里的细节:用Integer而不是int,因为需要用null表示“没有这个子节点”。我们用队列保存所有待挂接孩子的节点,每弹出一个,就尝试给它挂左右孩子。这个build过程本身就是一个层序遍历的逆用,理解了它,你也就理解了层序遍历的核心思想。

3.2 二叉树的遍历:递归与非递归的完整实现

遍历是二叉树最基础也最重要的操作,就像出门前要会系鞋带一样。遍历分四种:前序、中序、后序、层序。前三种是深度优先遍历(DFS),最后一种是广度优先遍历(BFS)。

前序遍历:根节点 → 左子树 → 右子树。就是优先处理当前节点,再去管孩子。

递归写法非常简短:

public void preorder(TreeNode root) { if (root == null) { return; } System.out.print(root.val + " "); preorder(root.left); preorder(root.right); }

中序遍历:左子树 → 根节点 → 右子树。这个顺序在二叉搜索树里非常关键,因为中序遍历BST得到的是升序序列。

public void inorder(TreeNode root) { if (root == null) { return; } inorder(root.left); System.out.print(root.val + " "); inorder(root.right); }

后序遍历:左子树 → 右子树 → 根节点。这个顺序在做“自底向上”的计算时特别有用,比如求二叉树的最大深度、释放资源等场景。

public void postorder(TreeNode root) { if (root == null) { return; } postorder(root.left); postorder(root.right); System.out.print(root.val + " "); }

三个递归版本长得几乎一样,只是打印语句的位置不同。这个“位置不同”正是三种遍历的核心区别。理解了这三个位置,递归遍历就掌握了八成了。

但递归写法有个致命弱点——树的高度如果很大(比如几万层),递归会栈溢出。这时候就需要迭代写法。迭代写法需要手动使用栈来模拟递归调用过程。

我用前序遍历举例。前序的顺序是根-左-右,我们可以先把根入栈,然后循环弹栈处理节点,处理完先把右孩子入栈,再入左孩子。因为栈是先进后出,先入右再入左,弹出的顺序就是左先右后,正好满足前序要求。

public void preorderIteration(TreeNode root) { if (root == null) { return; } Deque<TreeNode> stack = new ArrayDeque<>(); stack.push(root); while (!stack.isEmpty()) { TreeNode node = stack.pop(); System.out.print(node.val + " "); if (node.right != null) { stack.push(node.right); } if (node.left != null) { stack.push(node.left); } } }

中序和前序的迭代写法差异较大,因为中序要求先处理左子树,再回到根。这里有一个关键的思路:不能把节点弹出来就处理,得一路往左走到底,把沿途经过的节点全部入栈,直到走到空,才弹出栈顶处理,然后转向右孩子。

public void inorderIteration(TreeNode root) { if (root == null) { return; } Deque<TreeNode> stack = new ArrayDeque<>(); TreeNode cur = root; while (cur != null || !stack.isEmpty()) { while (cur != null) { stack.push(cur); cur = cur.left; } cur = stack.pop(); System.out.print(cur.val + " "); cur = cur.right; } }

后序的迭代相对更绕,但有一个取巧的办法:用两栈法,或者用一个栈加一个标记。我更推荐一个“偷懒但实用”的技巧——因为后序是左-右-根,前序是根-左-右,如果我们把前序改成“根-右-左”,然后整体反转输出,结果就是后序。这个思路在代码里就是加一个辅助栈收集结果再倒序。

层序遍历(BFS)用的不是栈,而是队列,从根开始一层一层地遍历。每一层从左到右处理。

public void levelOrder(TreeNode root) { if (root == null) { return; } Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { TreeNode node = queue.poll(); System.out.print(node.val + " "); if (node.left != null) { queue.offer(node.left); } if (node.right != null) { queue.offer(node.right); } } }

这里有个面试高频变体:要求按“每一层一行”输出,比如把第1层放在一个List里,第2层放在另一个List里。实现方式就是在while循环里,先把当前queue的size记录下来,然后连续处理size个节点,这样就能天然切分出一层。

public List<List<Integer>> levelOrderByLevel(TreeNode root) { List<List<Integer>> result = new ArrayList<>(); if (root == null) { return result; } Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { int size = queue.size(); List<Integer> level = new ArrayList<>(); for (int i = 0; i < size; i++) { TreeNode node = queue.poll(); level.add(node.val); if (node.left != null) { queue.offer(node.left); } if (node.right != null) { queue.offer(node.right); } } result.add(level); } return result; }

这个“按层切分”的技巧我强烈建议背下来,因为它几乎是层序遍历类题目的万能模板。

3.3 经典操作实战:求深度、翻转树、判断平衡

遍历只是基础,真正应用二叉树的地方在于对树结构进行运算和改造。下面这几个操作是我在面试和实际业务场景中高频碰到的。

求二叉树的最大深度。深度就是根节点到最远叶子节点的节点数,也可以理解成树的层数。递归写法只有三行:

public int maxDepth(TreeNode root) { if (root == null) { return 0; } return 1 + Math.max(maxDepth(root.left), maxDepth(root.right)); }

这个代码的逻辑非常优雅:一棵树的最大深度 = 左子树深度和右子树深度的较大值加上根节点这一层。典型的后序遍历思路——先算子树,再算当前层。理解这个式子之后,你会发现“自底向上的递归”这个说法不再是抽象的,它就是这样一个不断把子问题的答案往上抛的过程。

迭代写法也顺手给出来,用层序遍历,每走完一层深度加1:

public int maxDepthIteration(TreeNode root) { if (root == null) { return 0; } Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); int depth = 0; while (!queue.isEmpty()) { int size = queue.size(); for (int i = 0; i < size; i++) { TreeNode node = queue.poll(); if (node.left != null) { queue.offer(node.left); } if (node.right != null) { queue.offer(node.right); } } depth++; } return depth; }

翻转二叉树。就是把每个节点的左右子树交换。这题在面试里出镜率很高,但实际业务中也有应用场景,比如前端做右侧拉菜单时想镜像展示树形结构。递归写法极其对称:

public TreeNode invertTree(TreeNode root) { if (root == null) { return null; } TreeNode left = invertTree(root.left); TreeNode right = invertTree(root.right); root.left = right; root.right = left; return root; }

你可以对照前序、后序遍历理解。这里的处理顺序是先递归左右子树(相当于后序),然后交换。也可以先交换再递归(相当于前序),都能得到正确结果。关键在于:每个节点都只交换它的两个孩子,然后让它的孩子递归地去交换各自的孩子的孩子,简单说就是“你只管自己这一层,剩下的交给下层”。

判断一棵树是否平衡。平衡的定义:任意节点的左右子树高度差不超过1。这里不能简单定义“根节点的左右子树高度差不超过1”,因为某个很深的子树下方可能不平衡。所以正确的做法是递归判断每个节点。

我用一个带剪枝的思路,计算深度的同时判断是否平衡:

public boolean isBalanced(TreeNode root) { return height(root) != -1; } private int height(TreeNode node) { if (node == null) { return 0; } int left = height(node.left); if (left == -1) { return -1; } int right = height(node.right); if (right == -1) { return -1; } if (Math.abs(left - right) > 1) { return -1; } return 1 + Math.max(left, right); }

这里用返回-1作为“已经不平衡”的特殊标记。这个写法的精妙之处在于它把“求高度”和“判断平衡”合并成了一个函数,一旦发现不平衡就提前返回,避免了多余的递归计算。我在面试时很喜欢看候选人能不能想到这种带剪枝的做法,因为这说明他对递归的理解不是刻板的。

3.4 交叉验证:基础操作之间的依赖关系

以上几类操作并不是孤立的,而是一个体系。搜索和删除这类操作需要依赖遍历去定位节点,而定位的效率和树的有序性直接挂钩。比如在BST里删除节点,需要先通过搜索找到目标节点,然后分三种情况处理:叶子节点直接删、只有一个孩子的节点让其孩子顶替、有两个孩子的节点找中序后继或前驱来替换。

public TreeNode deleteNode(TreeNode root, int key) { if (root == null) { return null; } if (key < root.val) { root.left = deleteNode(root.left, key); } else if (key > root.val) { root.right = deleteNode(root.right, key); } else { // 找到了目标节点 if (root.left == null) { return root.right; } if (root.right == null) { return root.left; } // 有两个孩子:找右子树中的最小值(中序后继) TreeNode minNode = root.right; while (minNode.left != null) { minNode = minNode.left; } root.val = minNode.val; root.right = deleteNode(root.right, root.val); } return root; }

我建议学习二叉树时按这个顺序进阶:先手动构建树,再写通递归遍历,再写迭代遍历,然后做深度、翻转、判断平衡这类操作,最后涉足二叉搜索树的插入删除。每一步都建立在前一步的基础上,跳着学容易卡壳。

4. 二叉树Java实现中的常见问题与排查技巧实录

4.1 空指针异常的根源:引用悬挂与结构破坏

二叉树操作中最常见的运行时异常就是NullPointerException。主要分两类。

第一类是遍历或访问时没有判空。比如在递归遍历中直接写root.left.val而不确保root.left不为空,一旦左孩子是null就炸了。解决方法是让根节点为空的判断成为递归函数的第一行,这叫“递归出口”,它决定了整个递归函数的安全边界。我在审查代码时,如果看到一个递归方法的第一行不是判空,会立刻打上疑问标记。

第二类是构建树时引用悬挂。比如你把节点A的左孩子设置为B,后来又创建了一个C并设置A的左孩子为C,那么原来的B就“丢了”——没有任何引用指向它,Java的GC会回收它,但你的本意可能是把B和C同时挂在A下面,这在二叉树里是做不到的。完整二叉树的定义就注定了每个节点只能有一个父节点。实际工作中有人想用二叉树存多叉结构(比如组织架构),结果发现丢节点,就是这个原因。

4.2 递归溢出的症状识别与迭代改写

递归深度过大时,JVM会抛出StackOverflowError。这在二叉树退化成链表时特别常见。比如一棵二叉搜索树如果按升序插入数据,它的形状就是一条直线,深度等于节点数。这时候做递归中序,几千个节点就可能把栈打爆。

我实际测过,JVM默认栈深度大概在一万层左右(不同环境和JVM参数差异很大,不要依赖这个数字),安全做法是:当预估树的深度超过几千层时,直接用迭代写法。面试时如果考题明确说明节点数在10^5量级,那你应该条件反射地放弃递归,改用栈或队列的迭代实现。

还有一个容易踩的坑是递归方法里创建大量局部对象,这会加速栈溢出。尽量少在递归里new对象,能用基本类型就用基本类型。

4.3 剪枝失败与错误的结构假设

有些问题你以为自己写的递归是对的,但结果就是不对。常见原因有:

  1. 返回类型设计不对。比如判断平衡时返回boolean,但递归过程又需要子树高度,于是你又写了一个求高度的函数,两套递归相互调用,既慢又容易出错。改用返回-1表示不平衡的写法能同时解决两个问题。

  2. 终止条件缺失或不完整。比如计算“路径总和”这类问题时,你要明确“叶子节点”的定义是左右孩子都为空,而不是“当前节点为空”。把空节点当成叶子会多算或少算路径。

  3. 误把二叉搜索树的特性套在普通二叉树上。比如在普通二叉树里做查找,却按BST的大小判断逻辑去剪枝,直接把节点搜丢了。一定要先分辨清楚你操作的是什么性质的树。

我这里给出一份快速排查清单,平时调试二叉树算法时按这个顺序自查:

  • 递归出口的判空是否在最前面?判空条件覆盖了哪些情况?
  • 递归处理的结果是否正确传递给了上层调用?
  • 对当前节点的处理顺序是否符合该题要求的遍历顺序?
  • 是否混淆了“节点值相等”和“节点引用相同”?
  • 是否考虑了树的形状特征,比如是否是BST、是否平衡?
  • 数据结构用对了吗?DFS用栈,BFS用队列?

4.4 二叉树的序列化与调试技巧

调试二叉树,最痛苦的是看不到结构。不像数组可以一行打印出来,树形结构打印出来很难看。我建议在本地维护一段工具代码,把二叉树按层序遍历输出成数组形式,方便对比。

一个能看的调试方法是用“括号表示法”打印树,形如1(2(4,5),3(,6))。这种格式能清楚地表达节点的父子关系,比自己脑补强得多。实现起来也不难,递归拼接字符串即可。

另一个我在实际用过很多次的技巧:用层序序列化的数组中间态来验算。你先把一棵树的层序数组写出来,然后用createTree方法构建一棵树,再把树层序遍历打印出来,对比一下是否一致。一致说明你的构建和遍历代码都没问题,之后在这个基础上做其他操作,出问题时就能确定不是底层代码的锅。

还有个容易被忽视的细节:Java的ArrayDeque比Stack更好用。Stack是遗留类,继承自Vector,有同步开销,性能不如ArrayDeque。Deque提供了push、pop、peek等方法,语义上完全够用。我建议写迭代遍历统一用ArrayDeque,不要用Stack。

5. 面试与实战中的二叉树经验补充

5.1 三类高频二叉树题型的识别与应对

我在面试辅导和多轮技术招聘中,发现二叉树的题目其实就三大类,套路非常明显。

第一类:遍历类。让你按某种顺序输出节点,不管是前中后序还是层序,核心就是背熟递归和迭代两种模板。有的题目会变着花样考,比如Z字形层序遍历,其实就在层序模板上对偶数层做一次反转。比如求右视图,就把每层的最后一个节点加入结果。认清了它们的本质,再花哨的变形都能拆回基础模板。

第二类:自顶向下类。典型如“判断两棵树是否相同”“判断一棵树是否是另一棵的子树”“路径总和”等。这类题的核心是在递归过程中维护当前路径或比较状态,把上层的结果传给下层。比如判断路径总和是否存在,就是累加当前节点值,到叶子时判断是否等于目标值。

第三类:自底向上类。典型如“求最大深度”“求最近公共祖先”“判断平衡二叉树”。思路是先处理好左右子树,再把子问题结果汇总到当前节点。写这类题时,递归函数要返回“子树计算后的结果”,当前节点负责合并左右结果。

我把这三类题的思路对比一下:

题型核心思路代表题目典型写法
遍历类按顺序访问节点前中后序、层序递归/栈/队列模板
自顶向下把状态从根往下传路径总和、子树判断递归时携带参数
自底向上从叶子往上汇总最大深度、公共祖先先递归后处理

这个分类法能帮你快速定位题目类型,然后匹配做题模板,效率会高很多。

5.2 二叉树和其它数据结构的关系

二叉树不是孤立的,它和其它数据结构有千丝万缕的联系。理解这些联系,你才能真正融会贯通。

  • 与数组的关系:完全二叉树可以用数组存储,父节点下标为i时,左孩子下标为2i+1,右孩子为2i+2(从0开始)。堆排序就是利用了这种关系。
  • 与链表的关系:二叉树节点的存储结构本质上是引用连接,只是从一维的链变成了二维的分叉。很多链表操作技巧比如快慢指针,在树上有了新的变体(比如找树中位数)。
  • 与图的关联:二叉树是图的特例,是没有环的连通性结构。很多图的BFS/DFS思路起源于树的遍历。
  • 与业务表结构的关联:数据库的索引,尤其是MySQL的InnoDB存储引擎,底层用的就是B+树,而B+树的思想源头就是二叉搜索树的变种。理解了二叉树的搜索逻辑,再去看数据库索引原理会顺畅很多。

我在转Java服务端开发第二年的那个阶段,需要设计一个评论系统的多级回复,数据量不大但层级深,用递归从数据库查出来组装成一棵评论树。当时我二叉树知识还很浅,写出来的递归代码又慢又容易乱,后来把树的遍历思路彻底理顺了,代码重写了一遍,效率提升明显。这不是在夸耀,而是想说:树形结构在业务中的出场频率真的很高,值得你花时间系统掌握。

5.3 从二叉树到进阶数据结构的延伸路线

把基础二叉树搞透之后,想再进一步的话,我给的路径是:

  1. 二叉搜索树(BST):在二叉树基础上增加有序性,学会插入、删除、搜索,理解中序遍历有序的性质。
  2. 平衡二叉搜索树(AVL树):掌握LL、RR、LR、RL四种旋转操作,理解什么情况下树会失衡以及如何恢复平衡。
  3. 红黑树:理解它的5条性质,了解为什么Java的TreeMap和HashMap(树化后)选择红黑树而不是AVL树,根本原因在于红黑树的插入删除旋转次数更少,性能更稳定。
  4. B树与B+树:跳出二叉树,理解多路搜索树,看数据库索引和文件系统如何利用宽而浅的树结构减少磁盘IO。
  5. Trie树(前缀树):处理字符串搜索、敏感词过滤等场景,核心思路是把公共前缀合并存储。

这条路线走下来,你已经超越了大多数同级别的Java开发者。不夸张地说,如果你能在不看参考代码的情况下,独立写出AVL树的插入和旋转,你应对绝大多数公司的数据结构面试都绰绰有余了。

我个人在实际调二叉树代码的过程里有一个深刻的体会:很多bug不是逻辑想错了,而是对递归的基本单位没有想清楚。你写递归之前,先问自己三个问题:这个函数的作用是什么?递归出口在哪?返回值向上层传递的是什么?想清楚了再动手,写出来的代码基本一次通过。这三个问题同样适用于工作和面试中遇到的所有递归类问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询