最近好几个读者问我,Java面试里二叉树到底要准备到什么程度。我的回答一直是:先把二叉树的各种属性计算吃透。深度、节点数、对称性、平衡性、完全二叉树判定,这些听着基础,实际却频繁出现在笔试和一二面的手撕环节。你去看各厂的面试经验汇总,二叉树的深度、求叶子节点数、判断平衡二叉树这些题目几乎被问烂了。本文是二叉树属性系列的上篇,专注讲清楚这一类问题的通用解法思路、代码模板和容易出错的细节。
如果只是会背几个答案,换个问法就容易卡壳。我见过太多人会把最大深度写成递归加一,但遇到最小深度突然不知道怎么处理,或者判断平衡二叉树时只检查根节点、没有递归检查每个子树。这些问题的根源,不是题刷得少,而是没有理解二叉树属性计算背后统一的递归方法论。这篇文章就从底层思路讲起,把所有常见的二叉树属性题拆开揉碎。
1. 先统一地基:节点定义与递归方法论
1.1 TreeNode类的标准写法
在Java里操作二叉树,第一步是定义节点结构。大多数在线判题系统已经帮你定义好了TreeNode类,但面试时经常要自己手写,所以这个模板要能默写:
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; } }注意几个细节:val字段默认是int类型,节点值允许重复时处理要小心;left和right默认是null,这正好是二叉树的天然结束标志;构造方法建议重载三个版本,面试时写new TreeNode(5)和new TreeNode(5, left, right)都很常见。
我在实际开发中见过有人把字段设为private再写getter/setter,这在业务代码里是正确的封装习惯,但做算法题时完全没有必要,反而让你的代码变得无比啰嗦。LeetCode风格的类是直接把字段暴露成包可见的,面试手撕代码时也直接访问node.left,不会有任何问题。
1.2 递归三要素:终止条件、返回值、单层逻辑
二叉树本身就是递归定义的:一个节点往下分成左右两个子树,每个子树又是一棵独立的二叉树。这个结构决定了递归是处理二叉树最自然的方式。但很多初学者写递归靠猜,猜对了就过,猜错了就懵。其实递归就三个要素,你在写任何二叉树递归函数前都先问自己这三个问题。
第一个问题:终止条件是什么?对于二叉树来说,九成情况的终止条件都是root == null。这是递归的出口,没有它,递归会一直往下走到空指针才停,然后抛出NullPointerException。
第二个问题:这个递归函数要返回什么?求深度就返回int,判断是否对称就返回boolean,既要高度又要判断结果就返回一个组合值。返回值的类型决定了你函数的第一行怎么写。
第三个问题:单层逻辑是什么?也就是当前这一层要做的事。以最大深度为例,当前节点要做的,就是把左子树深度和右子树深度分别求出来,取较大值再加1。这正好体现出二叉树递归的核心思想——把整棵树的问题,拆成左子树和右子树两个规模更小的相同问题。
1.3 写递归之前先想好的底层规律
如果你问我二叉树属性题有没有套路,有。几乎所有的属性计算都可以归纳成对整棵树的遍历方式,只是遍历过程中做的事情不同。
举个例子,求总节点数和求最大深度的递归结构几乎一样,区别只在递归返回时如何处理子问题的结果。累加就是节点数,取最大就是深度。这个"遍历到底,归去来兮"的思维,本质上是把左右子树的答案计算出来,再在当前节点合并。
另一个容易被忽略的点是,递归函数的返回值设计直接决定了代码的优雅程度。判断平衡二叉树这道题,如果只用boolean作为递归返回值,你只能知道当前子树是否平衡,但无法把子树的高度传上去给父节点用。这时候很多人就写两个函数,一个计算高度,一个判断平衡,结果每个节点都被重复访问两次。后面第4章会给一个更聪明的写法,一次递归同时完成两个目标。这种"返回值设计"的意识,是需要刻意训练的。
2. 深度与高度:树上最常被问的一组属性
2.1 深度和高度,两字之差,概念不同
先把这个基础概念掰扯清楚。一个节点的深度,是从根节点到这个节点的边的条数,根节点的深度是0。一个节点的高度,是从这个节点到最远叶子节点的边的条数,叶子节点的高度是0。但对于整棵树来说,树的深度等于树的高度,都是从根节点到最远叶子节点的路径长度。这个细节在面试时偶尔会被拿出来当考察点,你要是能把概念说得清楚,印象分会加不少。
更需要注意的情况是,有的资料里会从1开始计数,即根节点的深度为1。LeetCode上绝大多数题目的"二叉树的最大深度"指的就是从根到最远叶子节点的节点数,空树深度为0。本文所有代码都按这个口径来。
2.2 最大深度:最简单的递归模板
求最大深度,代码短到让人怀疑是不是漏了什么:
public int maxDepth(TreeNode root) { if (root == null) { return 0; } return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1; }为什么可以这样写?假设你已经知道左子树的最大深度是L,右子树的最大深度是R,那么以当前节点为根的整棵树的最大深度,必然是左子树和右子树中更深的那一个,再加上当前节点这一层,所以是Math.max(L, R) + 1。这个加1,就是当前节点对深度的贡献,也是很多人写代码会漏掉的一步。
2.3 最小深度:一个容易载进坑里的变体
最大深度一出来,很多人想当然地以为最小深度就是把Math.max换成Math.min。如果你真的这么写,交上去会错一半用例。问题出在最小深度的定义上。
最小深度是从根节点到最近叶子节点的节点数,重点在"叶子节点"这四个字。叶子节点是左右孩子都为空的节点。如果一个节点的左子树为空,右子树不为空,那么从当前节点往下走到最近叶子节点的路径必须走右子树,而不是直接返回0。如果把空子树当成深度0参与Math.min比较,就会错误地得出深度为1的结论,但那个空子树的方向根本没有叶子节点,不能算一条完整的路径。
正确写法要把单子树为空的情况单独处理:
public int minDepth(TreeNode root) { if (root == null) { return 0; } if (root.left == null) { return minDepth(root.right) + 1; } if (root.right == null) { return minDepth(root.left) + 1; } return Math.min(minDepth(root.left), minDepth(root.right)) + 1; }这里的两个if就是在处理"只有一条路可以走"的情况。左子树为空,只能走右子树;右子树为空,只能走左子树;两边都不为空,才去比较哪边更小。这个题非常经典,能区分你是真的理解了深度概念,还是只是在背Math.min的模板。
2.4 最大深度的进阶变形:求二叉树直径
了解了深度计算后,有一道高频题可以由它自然延伸出来——求二叉树的直径。直径的定义是任意两个节点之间的最长路径长度,这条路径不一定经过根节点。LeetCode 543考的就是这个。
路径长度用边的条数来计算。从某个节点出发,经过这个节点的最长路径,其实就是这个节点左子树的最大深度加上右子树的最大深度。所以可以在求最大深度的递归过程中,用一个全局变量记录每个节点左深度加右深度的最大值:
private int diameter = 0; public int diameterOfBinaryTree(TreeNode root) { depth(root); return diameter; } private int depth(TreeNode node) { if (node == null) { return 0; } int left = depth(node.left); int right = depth(node.right); diameter = Math.max(diameter, left + right); return Math.max(left, right) + 1; }注意这里depth函数本身返回的还是深度,但在计算深度的过程中顺手把经过当前节点的最长路径更新了。如果路径以节点数计数,结果是left + right + 1,LeetCode这题要求的是边数,所以是left + right。这个区别在面试复盘时经常被追问,你要能说清楚。
3. 节点规模:数一数这棵树有多少节点
3.1 总节点数:后序归并的思想
统计一棵树的总节点数,思路和求深度类似,把左子树的节点数和右子树的节点数加起来,再加当前节点这一个:
public int countNodes(TreeNode root) { if (root == null) { return 0; } return countNodes(root.left) + countNodes(root.right) + 1; }这个代码没有惊喜,但它体现了一个重要的计算模式:先递归得到左右子树的答案,然后在当前节点合并答案。这个模式在二叉树问题里反复出现,你可以在很多题目里看到它的影子。用生活化的话说,就是"让左右子树分别汇报各自的数字,你听完之后汇总,再加一报给上一层"。
3.2 叶子节点数:判断条件不同
叶子节点是左右孩子都为空的节点,所以统计叶子节点时,递归的终态多了一个:
public int countLeaves(TreeNode root) { if (root == null) { return 0; } if (root.left == null && root.right == null) { return 1; } return countLeaves(root.left) + countLeaves(root.right); }很多人在空节点处理上犯迷糊:为什么root == null时返回0,而左右孩子都为空的返回1?因为空节点不是叶子,叶子是真实存在且没有孩子的节点。这个判断逻辑要放在递归函数里想清楚。我在面试别人时,经常看候选人能不能把这两个终止条件写全,写不全的说明对递归终止条件的理解还停留在背模板阶段。
3.3 第k层节点数:递归层数递减
统计第k层有多少个节点,稍微绕一下。可以在递归时带一个层数参数,每向下一层,k减1。当k等于1的时候,说明递归到了目标层,此时节点存在就返回1:
public int countKthLevelNodes(TreeNode root, int k) { if (root == null) { return 0; } if (k == 1) { return 1; } return countKthLevelNodes(root.left, k - 1) + countKthLevelNodes(root.right, k - 1); }这里有个边界条件容易忽略:如果传入的k是0或者负数,这个函数不会直接返回错误结果,而是会一直递归到k等于1为止,但此时已经没有意义了。所以调用方要保证k >= 1。这种参数校验的意识在面试时主动提出来,是很加分的细节。
3.4 完全二叉树的节点数优化:利用数学公式
普通的countNodes已经能统计完全二叉树,但LeetCode第222题要求更高效的做法。完全二叉树有个特点,如果从根到某个节点的左链条深度等于右链条深度,说明这个子树是满二叉树,可以直接用公式2^depth - 1计算节点数,不需要逐个递归:
public int countNodes(TreeNode root) { if (root == null) { return 0; } int leftDepth = 0; TreeNode node = root.left; while (node != null) { node = node.left; leftDepth++; } int rightDepth = 0; node = root.right; while (node != null) { node = node.right; rightDepth++; } if (leftDepth == rightDepth) { return (1 << (leftDepth + 1)) - 1; } return countNodes(root.left) + countNodes(root.right) + 1; }核心思想是每次判断当前子树是不是满二叉树,是就直接用公式,不是就继续递归下去。要注意位运算1 << depth在深度很大时要考虑int溢出情况,面试时一般不会考这么极端。
4. 结构性质判定:对称、平衡、完全、搜索
这一章的题目返回值都是boolean,但每一道题都有各自特有的坑。我把四个高频的结构判定放一起对比,你会更容易看出它们的差异。
4.1 对称二叉树:比较的是子树,不是节点本身
判断一棵二叉树是否对称,不能只判断根节点的左右孩子值相等,那远远不够。真正的对称要求是,左子树的左孩子等于右子树的右孩子,左子树的右孩子等于右子树的左孩子,而且要递归判断所有层。
我习惯先写一个辅助函数,专门比较两棵子树是否互为镜像:
public boolean isSymmetric(TreeNode root) { if (root == null) { return true; } return isMirror(root.left, root.right); } private boolean isMirror(TreeNode left, TreeNode right) { if (left == null && right == null) { return true; } if (left == null || right == null) { return false; } return left.val == right.val && isMirror(left.left, right.right) && isMirror(left.right, right.left); }两个null节点在镜像比较中是相等的,这是终止条件第一行。一个为null一个不为null则一定不等,这是第二行。然后递归比较left.left与right.right以及left.right与right.left。这两对对应的关系,恰好是镜像对称的核心。
4.2 平衡二叉树:一次递归同时返回高度和是否平衡
判断二叉树是否平衡,定义是每个节点的左右子树高度差不超过1。最容易想到的写法是:写一个求高度的函数,然后在每个节点上比较左右子树高度差。但这样做的缺点是,每个节点都会被反复访问,时间复杂度退化到O(n^2)。
更好的做法是利用哨兵值。既然高度不可能为负数,那就让不平衡的子树返回-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 Math.max(left, right) + 1; }这个-1哨兵的技巧非常实用,它把一个"既要判断又要算高度"的问题变成了一个"高度函数顺带判断"的问题。每次递归都先看子树返回值是不是-1,如果子树已经不平衡了,直接剪枝向上返回-1,避免多余计算。这个思想在很多树形DP问题里都能复用。
4.3 完全二叉树:层序遍历中不能出现"空洞"
完全二叉树的定义是除了最后一层外,每一层都是满的,最后一层的节点都集中在左侧。判断方法是层序遍历时,一旦遇到一个空节点,后面就不允许再出现非空节点。
用Java的Queue实现非常简洁,因为LinkedList允许放入null:
public boolean isCompleteTree(TreeNode root) { if (root == null) { return true; } Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); boolean hasSeenNull = false; while (!queue.isEmpty()) { TreeNode node = queue.poll(); if (node == null) { hasSeenNull = true; continue; } if (hasSeenNull) { return false; } queue.offer(node.left); queue.offer(node.right); } return true; }这段代码的思想是把每个节点的左右孩子都无条件放进队列,包括null。一旦队列弹出null,说明遍历到了空洞的位置,后面再弹出非空节点,就说明空洞后面还有节点,这不符合完全二叉树的性质。这个"标记+检查"的技巧在别的题目里也会用到,值得记住。
4.4 搜索二叉树:中序遍历有序才是真判断
搜索二叉树(BST)的属性判定之所以放进来,是因为它也是布尔判定类的典型代表。判断BST最容易踩的坑是只判断左孩子小于根节点、右孩子大于根节点。这只能保证局部有序,不能保证整棵树满足BST性质。
正确做法是利用BST的一个特性:中序遍历结果是递增序列。用一个prev变量记录中序遍历的前一个节点值,一旦发现当前节点值不大于前一个值,说明顺序被破坏:
private TreeNode prev = null; public boolean isValidBST(TreeNode root) { if (root == null) { return true; } if (!isValidBST(root.left)) { return false; } if (prev != null && prev.val >= root.val) { return false; } prev = root; return isValidBST(root.right); }这里把isValidBST反着用,先递归左子树,再比较当前节点,最后递归右子树。整棵树的访问顺序恰好是中序遍历。这个模板同时体现了中序遍历的一种写法,以后在BST相关的题目里会反复用到。
5. 先序+中序还原二叉树:把属性题串成综合题
热搜词里经常出现"知道二叉树先序和中序确定树的样子",这是二叉树部分的一道综合题,它把遍历和属性计算串在了一起。前面第2到第4章讲的是分析一棵现成的树,这一章讲的是如何从遍历序列里把树建出来。
5.1 为什么中序是关键
先序遍历的顺序是"根左右",所以先序序列的第一个元素必然是整棵树的根节点。但关键问题是,根节点在左右子树之间如何划分?先序序列里,根后面的区域既有左子树的节点也有右子树的节点,单靠先序无法区分。
这个时候中序序列就派上用场了。中序遍历的顺序是"左根右",一旦我们在中序序列里找到了根节点的位置,根左边所有的元素一定是左子树的中序序列,根右边所有的元素一定是右子树的中序序列。知道左右子树各有多少个节点后,回过来看先序序列,左子树的节点数确定了,右子树的节点数也就确定了。这就是"先序+中序可以唯一确定二叉树"的根本原因。
作为对比,只有先序和后序是无法唯一确定二叉树的。后序序列虽然能确定根节点,但无法确定哪部分是左子树、哪部分是右子树,除非额外限定这是一棵真二叉树。线索二叉树则是另一种思路,它通过给节点增加前驱和后继指针标志位,把遍历过程中浪费的空指针利用起来,让遍历不需要递归和栈。这个知识点笔试偶尔会考概念,本文先不展开实现,你只需要知道它的出发点是优化遍历。
5.2 递归重建的实现细节
用HashMap存储中序序列中每个值对应的下标,可以在递归时快速定位根节点,把时间复杂度控制在O(n)。
private Map<Integer, Integer> inorderIndexMap; public TreeNode buildTree(int[] preorder, int[] inorder) { inorderIndexMap = new HashMap<>(); for (int i = 0; i < inorder.length; i++) { inorderIndexMap.put(inorder[i], i); } return build(preorder, 0, preorder.length - 1, inorder, 0, inorder.length - 1); } private TreeNode build(int[] preorder, int preStart, int preEnd, int[] inorder, int inStart, int inEnd) { if (preStart > preEnd || inStart > inEnd) { return null; } int rootVal = preorder[preStart]; TreeNode root = new TreeNode(rootVal); int rootIndex = inorderIndexMap.get(rootVal); int leftSize = rootIndex - inStart; root.left = build(preorder, preStart + 1, preStart + leftSize, inorder, inStart, rootIndex - 1); root.right = build(preorder, preStart + leftSize + 1, preEnd, inorder, rootIndex + 1, inEnd); return root; }这段代码最核心也是最容易写错的地方,是leftSize的计算和四个区间的划分。rootIndex是根节点在中序数组中的下标,inStart是当前中序区间起点,两者相减就是左子树的节点数量。知道了leftSize后,先序区间里,根节点后面leftSize个元素就是左子树的先序区间,剩余部分就是右子树的先序区间。
建议你在纸上画一个例子走一遍,比如先序是[3,9,20,15,7],中序是[9,3,15,20,7]。根是3,中序里3在中间,左边9是左子树,右边15,20,7是右子树,左子树大小是1。于是先序里9单独成为左子树,20,15,7成为右子树的先序部分,中序右子树部分是15,20,7。递归下去,树的结构就出来了。这个手推过程做完一遍,区间边界基本上就不会再错了。
5.3 重建之后的属性大礼包
重建方法本身不直接考属性,但很多综合题会先让你重建,再让你计算各种属性。比如题目先给出先序和中序,然后求二叉树的深度、节点数、是否平衡。这时候你可以把前面所有的代码组合起来,构成一个完整的解题流程。
我把它们串一下:先用buildTree还原出根节点,再调用maxDepth计算深度,调用countNodes计算节点数,调用isBalanced判断平衡性。这些函数都可以复用前面章节的代码,它们之间互不干扰,只依赖TreeNode结构。这也是我强烈建议把每个属性计算封装成独立方法的原因——组合起来非常自然,面试时也能体现你的代码组织能力。
有一个经验值得分享:实际手写重建代码时,很多人会在递归里忘记检查区间有效性,导致出现空指针。每次递归最开始写if (preStart > preEnd || inStart > inEnd) return null;,这行要养成条件反射。
6. 递归的工程化改造:避免栈溢出的实用方案
面试时写递归版本很容易获得认同,因为代码简洁、逻辑清晰。但到了工程实践中,如果二叉树深度很大,或者干脆是极端情况下退化成了链表结构,递归调用深度可能达到几千甚至上万层,JVM默认栈空间很容易顶不住。这里分享几个工程化的改造思路。
6.1 先识别风险场景
什么样的情况会栈溢出?在Java中,默认的线程栈大小一般在1MB左右,单次方法调用栈帧消耗不算大,但深度达到上万层时仍然有风险。实际业务里最常见的高风险场景是递归解析深层JSON对应的树结构,或者处理文件目录树。面试和笔试一般不会刻意考察深度特别大的树,但不代表你不需要知道这个隐患。
6.2 迭代版求最大深度:层序遍历解法
求深度可以不用递归,用层序遍历(广度优先搜索)来数层数:
public int maxDepthIterative(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; }这里的关键是int size = queue.size(),它记录的是当前层的节点数量,循环处理完size个节点后再把depth加1。这样一来,循环的次数就是二叉树的层数,也就是最大深度。只要处理完当前层的所有节点,下一层会全部进入队列。这种写法虽然多用了队列的空间,但彻底规避了递归深度过大的问题。
6.3 什么时候坚持递归更合适
并不是所有场景都要改成迭代。判断对称、判断平衡这种题,改成迭代会引入额外的状态记录,代码复杂度会明显上升。我的建议是:如果二叉树深度在可控范围内,递归优先,因为可读性高、写起来快;如果明确知道树可能非常深,才考虑迭代改造。工程人员要懂得在清晰度和性能之间做权衡,不是所有递归都必须消灭。
7. 刷完这组属性题,我最想提醒你的三件事
这组题刷下来,从深度、节点数到结构判定,再到先序中序重建树,覆盖了二叉树属性计算的大部分核心场景。最后分享三个我自己实际刷题和面试总结出来的经验。
第一,递归函数的返回值要想清楚再动手。求深度返回int,判断对称返回boolean,既要高度又要平衡就用-1哨兵。返回值设计对了,代码基本就写对了一半。很多人卡壳不是不会写,而是没想好函数签名就开始写,结果写到一半发现需要的信息没有传回来。
第二,边界条件宁可多写也不要漏写。最大深度的root == null返回0,最小深度的单子树为空判断,统计叶子节点的空节点判断,每一条都是通过反复练习才能形成肌肉记忆的。在面试现场,边界条件写全比算法本身更让面试官放心。
第三,把所有属性计算的函数都独立封装,遇到综合题时才不会慌。二叉树属性题最大的好处就是它们彼此独立,你可以像拼积木一样自由组合。重建树加上求深度,重建树加上判断平衡,这种组合题在笔试题里并不少见,平时打好基础,组合时就能做到不动脑子直接调用。
我自己在实际面试别人的过程中,发现很多候选人不是不会某个具体的题,而是面对"二叉树属性"这类宽泛问题时,缺乏一个成体系的思考框架。先把今天这篇上篇里的基础属性类问题全部吃透,递归函数签名、终止条件、返回值设计这些基本功练扎实,下篇再聊路径问题、二叉搜索树进阶和线索二叉树,你会觉得顺畅很多。