统计二叉树好节点:DFS路径最大值与Java递归实现
2026/9/24 21:18:02 网站建设 项目流程

博主经验也不算多,但LeetCode刷了三百来道,Java版二叉树这块踩坑不少。这次拿Lc336-1448这道“统计二叉树中好节点的数目”来聊聊,题目本身不复杂,但背后的DFS思路、Java实现细节,还有面试时怎么答得让面试官眼前一亮,都有讲究。如果你正在准备Java后端面试,或者刷题卡在二叉树这一类,这篇应该能帮你省点时间。

很多人刷题只关注“AC了没有”,但我要说的是,这道题值得慢下来做一遍。它考察的不只是“能不能写出递归”,而是你对DFS遍历过程的理解深度——你在递归过程中究竟传递了什么信息,这决定了你能不能在 O(n) 时间内数完所有好节点。面试官问这类题,想看的也是你拆解问题的思路,不是背答案。

1. 题目理解与解题思路拆解

1.1 先搞懂“好节点”的定义

题目给一棵二叉树,每个节点上有一个整数值。所谓“好节点”,指的是从根节点到该节点的路径上,这个节点的值大于等于路径上所有其他节点的值。换句话说,它是这条路径上的“历史最大值”。

我第一次读题的时候差点理解歪了,以为是和所有祖先节点比较,后来发现“路径上的最大值”这个表述更准确。根节点天然是好节点,因为从根到根这条路径上只有它自己,没有比它更大的,也没有比它更小的。

举个小例子:

3 / \ 1 4 / / \ 3 1 5

根节点3是好节点。左子树节点1,路径是3->1,1小于3,不是好节点。节点3(左子树的左孩子),路径是3->1->3,最大值是3,当前值也是3,是好节点。右子树节点4,路径3->4,4大于3,是好节点。节点1(右子树的左孩子),路径3->4->1,最大值是4,1小于4,不是好节点。节点5,路径3->4->5,最大值是5,是好节点。所以总数是4个。

这个例子我建议你自己在纸上画一遍,理解“路径最大值”这个变量在递归过程中是怎么变化的,后面写代码就顺了。

1.2 为什么这道题用DFS而不是BFS

拿到二叉树题目,第一反应可能是层次遍历(BFS),但这道题用BFS做会很别扭。原因是:好节点的判定依赖“从根到当前节点的整条路径”,而BFS是逐层扫描的,你需要在队列里额外维护每个节点对应的路径最大值,逻辑上绕了一圈。

DFS(深度优先搜索)天然契合这种“路径关联”的判定。递归向下走的时候,路径是自然串联的,你只需要在每一层把当前的最大值传下去就行。前序遍历、中序遍历、后序遍历都可以做,但最直观的是前序遍历——先处理当前节点,再递归左右子树,和“从根往下判断”的顺序一致。

用生活类比来解释:想象你在山里爬一条路线,每到一个观景台(节点),你比较一下当前海拔和之前到达的最高海拔,如果当前更高,就记一个“新高度记录”。你只需要记住一个“到目前为止的最高海拔”这个变量,走到哪个观景台都比对一下。DFS就是这个“一路走到底再回头”的过程。

1.3 核心思想:把“历史最大值”作为递归参数

这道题的关键就一句话:递归时携带一个参数,记录从根到当前节点的路径最大值。

每次递归进入一个节点,做三件事:

  1. 如果当前节点值为 null,直接返回 0。
  2. 比较当前节点值 cur 和传入的路径最大值 maxSoFar,如果 cur >= maxSoFar,说明当前节点是好节点,计数加一,并更新 maxSoFar = cur。
  3. 递归处理左右子树,把更新后的 maxSoFar 传下去,累加两者的结果。

这个思路非常简单,但你要理解为什么参数传递能生效:因为每一次递归调用都是一个独立的栈帧,参数在调用时被复制。左子树走了更新后的最大值,右子树拿到的是同一个更新后的最大值,互不干扰。这也是DFS回溯的天然优势。

2. Java实现细节与代码实战

2.1 先定义二叉树节点类

LeetCode 默认给的节点类是常规的二叉树节点定义,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; } }

这个定义在日常刷题中太常见了,我建议你直接背下来。它本质是一个“自引用结构”,每个节点持有左右子节点的引用,null 就代表没有子树。写代码的时候注意别把 left 和 right 搞反,我见过不少初学者构造测试用例时把左右子树传反了,导致结果对不上。

2.2 主方法设计:入口方法 + 递归辅助方法

LeetCode 要求实现的是一个方法,通常长这样:

public int goodNodes(TreeNode root) { return dfs(root, Integer.MIN_VALUE); }

递归辅助方法单独抽出来,携带 maxSoFar 参数:

private int dfs(TreeNode node, int maxSoFar) { if (node == null) { return 0; } int count = 0; if (node.val >= maxSoFar) { count = 1; maxSoFar = node.val; } count += dfs(node.left, maxSoFar); count += dfs(node.right, maxSoFar); return count; }

入口方法用Integer.MIN_VALUE作为初始最大值,这个设计很巧妙。因为根节点无论如何都满足root.val >= Integer.MIN_VALUE(除非根节点值也是极小值,但节点值范围在 -10^4 到 10^4 之间,不会出现这种情况),所以根节点自动计为好节点。这就省掉了单独判断根节点的代码,逻辑非常干净。

我把主方法和递归方法拆开的理由是:入口方法保持简洁,递归方法只管“当前节点 + 左右子树”的累加。这样代码可读性高,面试时也好解释。有些人喜欢把初始逻辑直接写在递归方法里加个 if 判断,也能跑通,但多少有点绕。

2.3 完整代码与逐步执行追踪

放出可以直接跑的完整代码:

class Solution { public int goodNodes(TreeNode root) { return dfs(root, Integer.MIN_VALUE); } private int dfs(TreeNode node, int maxSoFar) { if (node == null) { return 0; } int count = 0; if (node.val >= maxSoFar) { count = 1; maxSoFar = node.val; } count += dfs(node.left, maxSoFar); count += dfs(node.right, maxSoFar); return count; } }

我拿刚才那颗树手动追踪一遍:

dfs(3, -∞) → 3 >= -∞,count=1,max=3 dfs(1, 3) → 1 < 3,count=0,max保持3 dfs(3, 3) → 3 >= 3,count=1,max=3 → 总数 1 dfs(4, 3) → 4 >= 3,count=1,max=4 dfs(1, 4) → 1 < 4,count=0 dfs(5, 4) → 5 >= 4,count=1,max=5 → 总数 2 最终 1 + 1 + 2 = 4

注意一个细节:节点值等于路径最大值时也算好节点。LeetCode 原题的表述是“大于等于”,别记成“大于”。我第一次做的时候用了大于,结果一直差一个数,排查了半天才发现是边界条件看漏了。这个点面试时也经常被拿来考察审题是否仔细。

2.4 为什么初始值选 Integer.MIN_VALUE 而不是别的

有人可能会问,初始最大值能不能用root.val?逻辑上也可以,但需要先判断根节点是否为空,然后从根节点开始递归。代码会变成这样:

public int goodNodes(TreeNode root) { if (root == null) { return 0; } return 1 + dfs(root.left, root.val) + dfs(root.right, root.val); }

这样也能过,但入口方法里有了一个1 +的固定计数,递归方法里就不用再判断根节点了。两段代码都能跑,但从统一性和简洁性来说,Integer.MIN_VALUE的版本更干净——你不需要单独处理根节点,所有节点一律走同一套逻辑。

我实际面试时曾经写过第二个版本,面试官追问“为什么递归不用传根节点的值作为初始值”,我说“好的,那我改成用 Integer.MIN_VALUE 来统一逻辑”,面试官点头表示认可。这种小细节,体现了你对代码简洁性的追求。

3. 复杂度分析与面试进阶思路

3.1 时间与空间复杂度:O(n) 时间、O(h) 空间

时间复杂度很容易分析:每个节点恰好被访问一次,做常数次操作。树的节点数为 n,总时间就是 O(n)。这个复杂度已经是最优了,因为无论如何你都得遍历整棵树才能判断每个节点。

空间复杂度稍微需要多说一句。递归调用会占用系统栈空间,栈的深度等于树的高度 h,所以空间复杂度是 O(h)。这里的 h 是树的高度,最坏情况下(比如一棵链状的树,退化成单链表),h 等于 n,空间复杂度退化为 O(n)。最好情况下(平衡二叉树),h 等于 log₂(n),空间复杂度就是 O(log n)。

面试时我建议主动说出这一层分析:时间 O(n),空间 O(h),并说明 h 在失衡树和平衡树下的差异。这比干巴巴念出“O(n)”要有说服力得多。面试官如果追问“能不能做到 O(1) 空间”,你可以回答:如果只考虑递归,做不到,因为系统栈本身需要 O(h) 空间;如果用 Morris 遍历,可以做到 O(1) 空间,但会修改树的结构,通常不推荐。

3.2 出口参数为什么用 int 而不是全局变量

有些解法会用全局变量来计数,每遇到一个好节点就加一。比如:

class Solution { int ans = 0; public int goodNodes(TreeNode root) { dfs(root, Integer.MIN_VALUE); return ans; } }

这种写法也能过,但我个人不推荐。原因有几个:

  1. 全局变量在并发环境下有线程安全问题(虽然刷题时不会遇到,但会养成坏习惯)。
  2. 全局变量让函数的“输入-输出”关系变得隐式,可读性差。
  3. 递归过程中如果出现异常或提前返回,全局变量的状态可能没被正确重置,排查起来麻烦。

函数式返回结果的好处是:每个递归调用都是自包含的,返回值清晰地表达了“这棵子树里有多少个好节点”,测试时也更容易单独调用某个子树来验证。

面试时你甚至可以补一句:“我用返回值累加,这样每次递归的职责更单一,也避免使用可变的全局状态。”这句话在面试官听来,比你多刷一百道题都有用。

3.3 从前序遍历视角重新理解这道题

这道题本质上就是前序遍历的变体。标准的前序遍历是“根 -> 左 -> 右”,每到一个节点只是访问它的值。而“统计好节点”是在前序遍历的基础上多维护了一个“路径最大值”的变量。

如果你对前序遍历的递归模板很熟,会发现这道题的框架和它几乎一样:

// 标准前序遍历 void preorder(TreeNode node) { if (node == null) return; // 访问node preorder(node.left); preorder(node.right); }

差异就在于“访问”这一步多了判断和更新最大值。所以如果你刷题时能把一道新题映射到自己熟悉的模板上,解题速度会快很多。这也是为什么很多人强调“二叉树的遍历是基础中的基础”,你掌握了遍历,就等于掌握了这一系列题目的骨架。

3.4 类似的二叉树衍生题有哪些

这类“在遍历路径上维护一个状态”的题目其实有一个家族,我列几个,你刷完这道题可以顺带走一遍:

题目维护的路径状态核心差异
二叉树的最大深度层数归并时取 max,而不是累加计数
路径总和剩余 targetSum每次递归减去当前节点值
二叉树的所有路径路径字符串回溯时需要移除已访问节点
二叉树中第二小的节点当前最小值通常需要两层递归
统计好节点数目(本题)路径最大值判断当前值 >= 历史最大值

刷这些题最好的方式不是挨个刷,而是刷一道总结一道,找到它们之间的“变与不变”。不变的是递归遍历的骨架,变的是“维护什么状态、在哪里判断、返回值如何累加”。

4. 实操中的常见问题与排查技巧

4.1 写二叉树递归时最常见的运行时错误

网上常有人问“写二叉树程序时为什么总是报运行时错误”,我总结下来,90% 的情况是下面几个原因。

第一,没有判空。递归方法的第一步如果不是判 null,一旦访问到空节点就会抛 NullPointerException。这个错误在 LeetCode 上会直接报java.lang.NullPointerException。像这道题,dfs 方法进来第一行永远要写if (node == null) return 0,养成肌肉记忆。

第二,树的构造测试用例写错了。很多人自己写 main 方法测的时候,把左右子树挂反了,或者把子节点挂到不存在的父节点上,运行时报错甚至死循环。我建议自己构造测试树时,用一张纸先画出树形结构,再按结构逐层构造,避免凭空手写。

第三,递归出口不合理导致无限递归。在二叉树题目里,无限递归通常意味着你递归调用时没有向 base case 靠近,比如漏掉了 left 或 right 的判空,或者传参时把子节点传成了当前节点。你可以在递归方法入口打一行日志打印 node.val,观察调用顺序是否符合预期。

4.2 本地运行测试树的搭建方法

LeetCode 上刷题时,输入是层序遍历的数组表示,但本地 IDE 调试时需要自己构建 TreeNode。我分享一个常用的快速构造方法:

public class Main { public static void main(String[] args) { // 构造一棵树:3 / \ 1 4 / / \ 3 1 5 TreeNode node3 = new TreeNode(3); TreeNode node1_left = new TreeNode(1); TreeNode node4 = new TreeNode(4); TreeNode node3_left = new TreeNode(3); TreeNode node1_right = new TreeNode(1); TreeNode node5 = new TreeNode(5); node3.left = node1_left; node3.right = node4; node1_left.left = node3_left; node4.left = node1_right; node4.right = node5; Solution sol = new Solution(); System.out.println(sol.goodNodes(node3)); // 期望输出 4 } }

这种构造方式比较笨,但很直观。如果你经常做二叉树题,我建议写一个小工具方法,支持从层序数组构建二叉树:

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

注意数组里的 null 代表空节点,这个工具方法我用了很久,省了非常多手工构造的时间。有需要的可以直接复制。

4.3 递归爆栈问题与极端情况

如果树的高度很大,比如 10000 层,递归会栈溢出。Java 默认虚拟机栈深度大概在几千到一万层左右(具体取决于 JVM 配置和系统栈大小),遇到极端退化树可能StackOverflowError

面试中如果被问到这种极端情况,你可以说:递归解法适合常规树高,如果树的形态极端,可以把 DFS 改成显式栈的迭代写法。下面给一个迭代版本的参考:

public int goodNodes(TreeNode root) { if (root == null) return 0; int count = 0; Deque<Object[]> stack = new ArrayDeque<>(); stack.push(new Object[]{root, Integer.MIN_VALUE}); while (!stack.isEmpty()) { Object[] item = stack.pop(); TreeNode node = (TreeNode) item[0]; int maxSoFar = (int) item[1]; if (node.val >= maxSoFar) { count++; maxSoFar = node.val; } if (node.right != null) { stack.push(new Object[]{node.right, maxSoFar}); } if (node.left != null) { stack.push(new Object[]{node.left, maxSoFar}); } } return count; }

迭代版本的好处是显式控制栈,不依赖系统栈,理论上可以处理任意深度的树,只要堆内存足够。坏处是代码稍微长一点,而且 Object[] 数组的写法不太优雅。如果你对性能有洁癖,可以定义一个小内部类来存放节点和最大值。

4.4 排查思路:结果不对时如何快速定位

如果跑出来的结果和预期不一致,我建议按这个顺序排查:

  1. 先检查比较符号。是>=还是>?这直接影响相等值节点的判断。
  2. 再用一棵只有根节点的树测试,看输出是不是 1。这能排查初始值和边界条件。
  3. 再用一条链状树测试,比如 1 -> 2 -> 3,看输出是不是 3。这能排查递归路径是否正确。
  4. 最后再用标准用例测试,逐行打印递归进入节点的顺序,和手工推演对照。

我在本地调试时会在 dfs 方法里加临时日志:

System.out.println("进入节点: " + node.val + ", maxSoFar=" + maxSoFar);

观察每次进入节点时 maxSoFar 的变化是否符合逻辑。打完日志删除即可,但不建议在生产代码里留。

5. 这道题在Java面试中的问法

5.1 面试官可能抛出的追问

这道题在 LeetCode 上是 Medium 难度,但作为面试题,面试官不会让你写完就结束,通常会有几个追问:

  • “为什么根节点一定是好节点?”——因为初始值是 Integer.MIN_VALUE,任何整数都大于等于它。
  • “如果节点值有负数怎么办?”——Integer.MIN_VALUE 初始值依然有效。
  • “如果节点值全是负数呢?”——同样有效,因为负数 >= Integer.MIN_VALUE恒成立。
  • “递归和迭代版本你更喜欢哪个?为什么?”——递归更简洁直观,迭代更可控但代码更长。

这些追问的目的不是考你背诵,而是看你能不能从原理上解释自己的代码。所以刷题时别只看题解,要多问自己几个“为什么”。

5.2 怎么向面试官表达你的思路

面试时表达这道题,我建议用这样的逻辑链条:

  1. 先说明这是一道二叉树路径题,有个信息需要在路径上传递。
  2. 这个信息就是历史最大值,初始化为最小值。
  3. 用 DFS 遍历,每到一个节点比较当前值与历史最大值。
  4. 如果满足条件就计数,并更新历史最大值。
  5. 递归左右子树,返回左右子树结果之和。

这个顺序其实就是你思考问题的自然顺序,说清楚这五步,面试官已经能确认你理解到位了,不需要背术语。千万不要一上来就背代码,尤其是“我用了一个 dfs 函数”这种没头没尾的表现,面试官很难判断你是不是真的懂了。

5.3 写在简历项目里的正确姿势

如果你做过刷题笔记或者算法总结项目,这道题可以写成一个小的“二叉树路径问题模板”案例。不要直接写“我刷了 LeetCode 1448”,而是写“我总结了一套二叉树路径类问题的递归模板,覆盖好节点计数、路径总和、最大深度等场景,并对比了递归与迭代实现的性能差异”。

面试官看到这种描述,会觉得你不只是刷题,而是有总结归纳的能力。这也是中级工程师和初级工程师的重要区别之一。顺便说一句,Java 后端面试中二叉树题目出现频率相当高,因为二叉树递归是理解系统栈、函数调用开销、分支递归逻辑的最好载体,面试官爱问是有道理的。

5.4 延伸:从这道题理解递归的本质

很多初学者觉得递归难,其实递归就两个核心点:递推关系 + 终止条件。这道题的递推关系是dfs(node, max) = 当前节点贡献 + dfs(left, 更新后的max) + dfs(right, 更新后的max),终止条件是node == null

只要你把这两个点想清楚,递归代码自然就写出来了,根本不用背。而且我可以告诉你一个判断递归写法好不好的标准:看得懂。如果一段递归代码你需要读三遍才明白,说明写复杂了,可以尝试拆成辅助方法或者换个参数设计。

6. 补充说明与注意事项

6.1 关于 LeetCode 题号的说明

标题里写的“Lc336-1448”,我理解是某份题单里编号 336,对应 LeetCode 第 1448 题。如果你做题时发现题号对不上,别慌,以题目名称为准。

LeetCode 上有时候会出现一个问题多个变体,或者题号更新导致顺序变化。我的建议是碰到这种题,直接搜题目名字“统计二叉树中好节点的数目”或者英文“Count Good Nodes in Binary Tree”,比记题号更稳妥。毕竟刷题刷多了,你也不可能记住每一题的编号,记住思想和模板才是根本。

6.2 Java 版本的选择

我在本地用 Java 17 跑代码,Java 8 也完全兼容,因为这道题只用了基础语法,没有用到任何高版本特性。如果你在 LeetCode 上提交,默认的 Java 编译器版本也支持这段代码。

唯一要注意的是,如果你的本地环境是 17 但编译时报“源发行版 17 需要目标发行版 17”的警告,那是 IDE 里 Project Structure 的 Java 版本没配置好,和代码本身无关。把编译器级别调到一致即可。这个问题在面试机试时不太会遇到,但本地练习时很常见,这里提一句。

6.3 配合其他二叉树热词扩展学习

热搜词里出现了很多“二叉树的深度”“二叉树的遍历”“完全二叉树和满二叉树”“搜索二叉树”“线索二叉树”,这里我帮你梳理一下它们和本题的关系:

  • 二叉树的深度:DFS 递归的经典应用,和本题共用同一套递归框架。
  • 二叉树的遍历:前序、中序、后序、层序,是二叉树题的骨架。
  • 完全二叉树和满二叉树:一种二叉树形态,面试常考性质公式。
  • 搜索二叉树:节点值有序排列,常用中序遍历判断合法性。
  • 线索二叉树:通过空指针建立线索实现非递归遍历,进阶内容。

我的建议是:把“遍历”吃透,再逐步扩展到“深度”“路径”“计数”这些变体。不要平均用力,更不要一上来就啃线索二叉树这种进阶内容。像本题这样中等难度的路径计数题,是性价比很高的练习对象,既能扎实基础,又能在面试中直接展示思路。

6.4 最终经验心得

我遇到的大部分 Java 面试者,二叉树的题目往往卡在两个点上:一是递归的终止条件写错,二是状态参数没有想清楚。这道题恰好能同时锻炼这两个点。如果你把这道题独立做出来了,而且能把自己写的每个细节都解释清楚,那面试中遇到大部分二叉树路径类问题,你都有了稳定的思路框架。

我个人建议刷题时不要只追求“AC 了就下一题”,抽出时间把代码里每个变量为什么存在、每个判断为什么这么写讲清楚,收益会远超预期。我自己做这道题时,就是从“背模板”到“理解路径最大值为什么能作为参数传递”这个转变之后,二叉树系列的题目正确率才明显上来的。

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

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

立即咨询