news 2026/10/11 7:21:56

LeetCode 543:二叉树直径的JavaScript递归解法详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 543:二叉树直径的JavaScript递归解法详解

先问一个问题:刷LeetCode的时候,你是“看题五分钟、看答案两小时”的类型,还是那种把每道题当成一次底层逻辑训练、非得把递归调用栈在脑子里跑完才肯罢休的类型?

实话讲,第543题《二叉树的直径》属于前者看起来很简单、后者越品越有味道的典型。它被收录在LeetCode热门100题里,也在很多大厂笔试的高频清单里占着位置。作为Day 14的刷题任务,这道题表面上只要求“求一棵树中任意两个节点路径长度的最大值”,但真正动手用JavaScript写的时候,很多人会卡在一个地方:为什么我递归返回的是高度,但答案却在另一个变量里累加?今天我把这道题从题意、思路、代码到踩坑,完整拆开讲一遍,希望你看完不只是会AC,而是下次遇到任何“遍历过程中收集某种全局信息”的题目,都能顺手拿捏。

这道题适合谁?适合正在按题单刷二叉树基础的人,也适合准备面试、想弄明白“树的dfs到底在每一层做了什么”的人。我会把递归的每一层展开讲清楚,代码也全部用JavaScript写,你复制到LeetCode里就能跑。

1. 题目到底在问什么——别再被“直径”两个字带偏

1.1 先看懂题面原文

LeetCode 543的原题描述不长:给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个节点路径中,路径边数的最大值。注意:这条路径可能穿过也可能不穿过根节点。

很多初学者第一步就被“直径”这个几何词汇唬住了,以为要像算圆的直径一样找到树的中心,然后量两端的距离。其实二叉树的“直径”一点都不高深,翻译成人话就是:在这棵树的所有节点对之间,找到距离最远的那一对,计算它们之间有多少条边。这里的“边数”很关键,如果你按“经过了多少个节点”去数,就会比正确答案多1。

1.2 生活类比:把树想成一张地铁图

假如你把这棵二叉树想成一张地铁线路图,每个节点是一个站,每条连接父子节点的引用是一条线路。那二叉树的直径就是这棵树里“从某一个站到另一个站,坐得最远的一条路线”要经过多少个区间。普通的地铁图线路是直的,而树的连接方式是分叉的,所以这条“最远路线”一定是由某个节点出发、往左下方走一段、再往右下方走一段组成的。换句话说,任何一条树内路径,都可以看成在以某个节点为“拐点”的地方,向下弯折而成。

这个视角非常重要。它意味着:要算全局最长的路径,不需要去比较任意两个叶子节点之间的距离,只需要遍历每一个节点,把这个节点“左侧能往下走多深”和“右侧能往下走多深”加起来,取一个最大值就行。你可能会问,为什么不考虑拐点不在当前节点的情况?因为每一条路径必然有唯一一个“最高点”,也就是这条路径上离根最近的那个节点(专业说法叫LCA,最近公共祖先),这个最高点就是拐点。所以枚举每一个节点当拐点,一定不会漏掉任何候选路径。

1.3 这题真正考的是“后序遍历的副产物”

LeetCode 543的题解区,一眼扫过去,十个里有八个是十几行的递归。但你要清楚,这道题表面上在考“树的遍历”,深层其实在考一件事:你能否设计一个递归函数,让它在返回“高度”这个主结果的同时,悄悄对外部变量产生副作用,最终由这个副作用拼出真正想要的答案。

常规的层序遍历、前序遍历解决不了这个问题。因为你在遍历到某个节点时,根本不知道它左右子树的最大深度,非要先探到底再回头算。而后续遍历天然满足这个需求:先算完左子树、右子树,回到当前节点时,左右两边的信息都已经齐了,此时立即更新答案,再把自己这层的“高度”返回给父节点。这个“边返回边更新”的模式,是这道题的灵魂,也是之后做当时最大路径和、验证平衡二叉树等一堆题目时反复出现的套路。

2. 解题思路拆解:从暴力递归到单次遍历

2.1 第一反应:遍历每个节点,分别求两侧高度

先想一个最笨但绝对正确的办法。直径等于某个节点左右子树高度之和的最大值,那我可以先写一个工具函数,输入一个节点,返回这棵子树的最大高度,这是任何学过二叉树的人都会的递归。然后,在主函数里再来一层递归,遍历每一个节点,每到一处就调用工具函数算左高度、算右高度,加加看,不断更新最大值。

这个思路逻辑完全正确,复杂度也能过,因为树总共就N个节点,每个节点调用一次工具函数,工具函数本身又要遍历以该节点为根的整棵子树,因此总时间复杂度是O(N²)。LeetCode的数据量下不算太差,有些语言能勉强AC,但这不是一个合格解法。更关键的是,它暴露一个问题:同一棵子树的高度被反复计算了无数次,底层叶子节点甚至被访问了几十遍,这种重复劳动在递归里是完全可以避免的。

2.2 双递归的致命伤:重复计算

具体想一个极端情况:一棵退化成链状的树,每个节点只有一个左孩子。用双递归的思路跑一遍,根节点算左子树高度,要一路走到底,N步;接着左孩子作为新的主遍历节点,又要从自己往下走到底,N-1步…全部加起来是O(N²)的量级。LeetCode上N达到10的4次方时,这个复杂度虽然可能勉强跑完,但肯定谈不上优雅。

而任何一个稍微有点经验的工程师都会意识到,计算某个节点的高度,本质依赖它孩子节点的高度,我完全可以在一次自底向上的遍历中,把每个节点的高度都算出来并缓存。这里的“缓存”不需要额外开Map,直接把信息存储在递归返回值里就是最自然的缓存。每个节点的高度只算一次,整体退化到O(N)。

2.3 优化思路:一次DFS,等子树信息齐了再更新答案

单次DFS的核心设计是这样的:定义一个递归函数,它返回以当前节点为根的子树的最大高度。在函数内部,先递归地拿到左孩子的高度和右孩子的高度,这两个值都拿到了,当前节点作为拐点的最长路径就是“左孩子高度 + 右孩子高度”,拿这个值去更新全局答案。最后,当前节点返回给父节点的高度是“左右孩子中更高的那一个 + 1(加上当前节点自身这一层)”。

这个设计里有一个需要反复消化的点:一个节点返回给父节点的值,和它用来更新答案的值,不是同一个东西。更新答案用的是“左高度 + 右高度”,因为路径从当前节点左子树最深处一路到右子树最深处,拐点处不需要再加当前节点本身的边;返回给父节点用的是“max(左高度, 右高度) + 1”,因为父节点未来要沿着这条侧继续往下延伸时,只能选择更高的一条分支继续走,不能左右两边都占着。理解了这个差别,代码就只是一层窗户纸的事。

为什么只选高的那一条?因为路径是线性的,一个节点向上走时不可能同时走上左子树和右子树。就像一个电梯到了某一层,你只能选择向左走还是向右走,不可能同时跨两条走廊,所以给父节点的高度只能是单侧最大深度再加当前这一层。

3. JavaScript实现:完整代码与逐行解读

3.1 完整代码先睹为快

直接看最终解法,JavaScript版本,全部代码不到20行:

var diameterOfBinaryTree = function(root) { let ans = 0; const dfs = (node) => { if (!node) return 0; const leftDepth = dfs(node.left); const rightDepth = dfs(node.right); ans = Math.max(ans, leftDepth + rightDepth); return Math.max(leftDepth, rightDepth) + 1; }; dfs(root); return ans; };

提交到LeetCode上,时间复杂度O(N),空间复杂度O(H),H是树的高度,最坏情况下(链状树)是O(N),但一般不用特别纠结。

3.2 为什么递归函数要“干两件事”

注意看dfs这个函数的职责,它其实同时干了两件事。第一件事:计算并返回以node为根的子树高,这是它对外部也不言而喻的语义;第二件事:在计算过程中顺手检查“如果以node作为拐点,直径候选值是多少”,并更新外部变量ans。这个设计有点像一个工程团队里,产品经理让程序员去统计页面访问量,程序员统计的同时,顺手把一个隐藏的bug日志也采了回来。返回值是主任务,修改ans是副产物,两者不冲突,反而共用了同一次递归遍历的全部信息。

为什么必须用外部变量?因为dfs的返回值已经用于表达“子树高度”这个语义,不能再同时表达“直径”。如果你试图让一个函数同时返回高度和答案,语法上需要另开一个对象或者数组来打包,反而啰嗦。用一个闭包变量ans记录全局最优值,是这类题目的标准做法,简洁、高效、可读性好。

3.3 边界条件与base case

递归第一步,判断节点是否为空。如果是null,直接返回0。这个0代表“空子树的高度是0”,它没有子节点,自然也不可能贡献任何深度。这里稍微注意一下:有些人喜欢用“空节点返回-1”的写法,那通常是用在计算“经过节点的数量”或者处理平衡树时,这道题计算的是边数,空节点的高度用0最贴合题意。

树只有单个节点时,左右孩子都是空,leftDepth和rightDepth都是0,ans更新为0 + 0 = 0,返回0。而single节点的直径确实就是0,因为从自己到自己不算路径,没有边。这个边界情况很容易被忽略,但LeetCode的测试用例里必然有,写代码的时候先把这层想通,后面的逻辑就顺了。

4. 这题真正的难点:思维陷阱与常见错误

4.1 陷阱一:更新答案时要不要加1

这是评论区里最常见的争论。有些人的代码是ans = Math.max(ans, left + right),有些人的是ans = Math.max(ans, left + right + 1)。为什么前一种才是对的?

因为left和right代表的是左右子树各自的最大高度,也就是从当前节点的左孩子往下走到最深处经过的边数。当路径以当前节点为拐点时,它从当前节点出发,先往左走到左子树最底部,这段的边数是left;再回到当前节点,往右走到右子树最底部,这段的边数是right。两段路径以当前节点为连接点,中间没有额外的边需要算,所以总长度就是left + right,不需要再加1。而如果你用的是“左高度 + 右高度 + 1”,那就相当于多算了一条边,本来应该是5的直径,你输出6。

什么时候需要加1?一些题解里提到的“节点数量”版本。如果题目把直径定义成“路径经过的节点数”,那确实要在边数上加1。但LeetCode 543题干白纸黑字写的是“路径边数的最大值”,所以别被其他语言的题解带偏,认准你的left和right都是高度(边数),答案就直接相加。

4.2 陷阱二:递归返回时加1忘了算当前节点

与陷阱一相反,返回给父节点的值必须在左右高度的最大值上再加1。为什么要加?因为父节点如果要经过当前节点继续往下走,当前节点本身也是路径上的一层。试想一棵只有左孩子的简单树:根节点的左子树高度是0(左孩子是空),那么根节点的高度应该是1还是0?按照二叉树的高度定义,只有一个节点的树高度为0还是1取决于约定,但在这道题的递归里,我们必须把“当前节点本身”算作这一层的贡献,否则父节点计算高度时就会漏算节点数,导致最终的直径少算。

我用一个具体例子验证:根节点A有一个左孩子B,B没有孩子。真实直径是1(A到B的一条边)。跑代码的时候,B节点的dfs返回 max(0,0)+1 = 1,这是B子树的高度。回到A节点,leftDepth = 1,rightDepth = 0,ans更新为1,符合预期。如果你在返回的时候漏写了+1,leftDepth就是0,ans算出来0,直接错。

4.3 陷阱三:把整棵树的全局变量放在递归里赋值

JavaScript的闭包特性让外部变量在递归函数里修改非常自然,但也带来一些隐性问题。如果你把ans定义在diameterOfBinaryTree内部、dfs外部,那么每次执行这个函数时ans都会重新初始化为0,没问题。但假如你不小心把ans定义在了模块的顶层、或者在类里定义成了静态属性,那LeetCode多次调用测试用例时,上一次残留的值就会污染下一次的计算,导致结果错误。

这个问题在本地调试时很难发现,因为你在同一个页面反复跑同一个用例,每次都能复现;但提交上去换了测试环境,就会偶发错误。建议养成习惯:所有需要用到的全局变量一律放在主函数内部声明,用闭包去捕获,别往外层作用域扩散。

4.4 时间复杂度与空间复杂度自查

复杂度这点必须做到脱口而出。时间上,每个节点恰好访问一次,在节点内部做两次递归调用和一次Math.max,都是常数时间,总复杂度O(N)。空间上,递归栈的深度等于树的高度,最坏情况是一棵严重偏斜的树,高度为N,所以空间O(N);平均情况一棵比较平衡的树,空间O(log N)。面试时如果被追问“能不能用迭代实现”,答案是可以用栈模拟后序遍历,但由于需要记录每个节点返回的高度,代码会明显变长,通常没有必要,递归是这道题最自然的解法。

5. 变式与延展:会这一题,等于会一大片题目

5.1 兄弟题:二叉树的最大路径和(LeetCode 124)

LeetCode 124是这道题最经典的进化版。同样是遍历每个节点作为拐点,同样在递归返回值里携带单侧最大信息,区别在于节点上带了权值(正负都可能),路径计算的是节点值的总和,而且路径可以只停在任意节点,不一定要走到叶子。

对比着看很有意思:124题中,如果子树的单侧路径和是负数,那它对上层节点来说就是累赘,不如直接截断,把当前节点的返回值变成只包含当前节点本身的值;而在543题里,高度永远是正数,不存在“某个分支我不想要了”这种情况。理解了两者的差异,你对“递归过程中怎么筛选有效信息”的理解会上升一层。

5.2 兄弟题:验证平衡二叉树(LeetCode 110)

平衡二叉树的定义是每个节点的左右子树高度差不超过1。又是一个类似的递归模式:后序遍历拿到左右子树高度,然后一比较,超过1就标记为false。很多人的第一步想法是写两个函数,一个查高度,一个遍历判断,又回到O(N²)的老路。会了543之后,你应该自然想到把“高度”和“是否平衡”两类信息在一次DFS中合并处理,可以通过返回-1标记不平衡,也可以用外部变量提前终止。

5.3 跟前端工程化的挂钩:DOM树也能这样算

如果你觉得这些二叉树题目离实际开发太远,不妨想想前端的一个常见需求:统计页面中某个组件的最大嵌套深度。DOM本身是一棵多叉树,但递归遍历的逻辑一模一样。LeetCode 543练出来的“后序遍历 + 返回值 + 外部最大值”的模式,几乎可以原封不动地用在处理React组件嵌套层级、计算CSS选择器的最大深度、甚至递归分析JSON对象的最大嵌套层数上。学好递归的后序返回模式,你在工作中处理任何“树形结构数据”都会比别人快一步。

5.4 如果再往前走一步:换语言写法差别在哪

很多朋友学的第一门语言不是JavaScript,做题时喜欢先看Python或者Java的题解,再翻译成JS。这个过程容易踩坑:Python的递归返回值可以直接用元组(depth, max_diameter)打包,而JS里这么写反而别扭,不如用外部变量干净;Java的类成员变量和实例方法也能改,但要注意清空状态。JavaScript的优势在于闭包天然配合这类问题,在函数内部声明一个变量,递归函数里随便改,不会污染外部环境,这也是我推荐JS用户直接采用“返回值 + 外部变量”方案的原因。

6. 调试小技巧:再小的题也有值得记录的经验

6.1 手动画出递归调用栈跑一遍

我刚学这题的时候,先别急着提交,找张草稿纸画一棵3层二叉树,把dfs的每一步调用栈写出来。重点看两个时刻:第一个时刻是某个节点拿到了左右子树的返回值但还没更新ans的时候,第二个时刻是它把单侧最大深度返回给父节点的时候。把这两个时刻的值盯着写清楚,整道题的逻辑就永远长在你脑子里了,比背十遍代码管用。

6.2 用一个自测用例验证边界

至少用三组数据自测:空树返回0;单节点返回0;三个节点的完全二叉树,根节点左右各一个叶子,此时越来越容易错——左右子树高度都是1,ans更新为2,正确答案就是2。再测一个链状树,比如1-2-3-4的右链,直径应该是3,看代码能不能自动算对。LeetCode支持自定义测试用例,建议把这些用例全跑一遍再提交。

6.3 警惕“看着能跑,换个用例就崩”的幻觉

JavaScript是一种宽容的语言,很多错误不会在运行时立刻暴露。比如Math.max()里有一个参数是undefined,结果不会立刻报错,只会变成NaN,然后整个ans一路NaN到底,最后输出null——这种错最难查。所以递归头部的空值判断一定要写得严谨,别用node.left这种不考虑空的写法(实际上我们是用递归函数时自动处理了null),确保每个访问属性的地方前面都有安全判断。

7. Day 14刷题计划的复盘:这道题在题单里的位置

如果你是按LeetCode热门100题的题单刷到第14天,你会发现前面的题目大多是数组、链表这些线性结构,到了第543题,难度并没有陡增,但思维上第一次要求你跳脱“单线程遍历”,开始适应“遍历过程中同时收集另一份信息”的模式。这个转折点相当关键,它既是二叉树系列的入门题之一,也是之后遇到任何“边遍历边求最值”问题的思维基石。

我个人在刷题计划里的习惯是,每道题AC之后做三件事:第一,把官方题解的思路原文读一遍,重点看它为什么不用双递归;第二,去讨论区翻一翻别人贴出的错误代码,看最容易踩的坑是否和自己想的一样;第三,在代码注释里写一句“这道题为什么返回值不等于答案的说明”。这三个动作坚持下来,十天之后你对树的递归题会有一种近乎条件反射的直觉——看到“任意路径”“最远”“最大和”这类词,脑子里会自动弹出“拐点 + 后序 + 外部变量”的框架。

这道题如果只看表面,20行代码花两分钟就能背完。但如果你把“递归返回单侧信息,同时用一个全局变量记录当前最优值”的模式彻底消化了,它帮你在后续的更多问题里省下的时间,远比这一道题本身多得多。这是经验之谈,信不信你刷到LeetCode 437或者124的时候自然会有体会。

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

企业招聘管理系统(ATS候选人追踪系统)

博主介绍: 所有项目都配有从入门到精通的安装教程,可二开,提供核心代码讲解,项目指导。 项目配有对应开发文档、解析等 项目都录了发布和功能操作演示视频;项目的界面和功能都可以定制,包安装运行&#xff…

作者头像 李华
网站建设 2026/10/11 7:21:32

第122篇 Activity 启动模式:standard 到 singleInstance

上一节讲生命周期,这一节讲"Activity 是怎么被放进栈里的"。启动模式是 Android 框架层里最容易出玄学 bug 的一块——因为它的行为由"系统、任务栈、launchMode、Intent flags 四者共同决定",只讲 launchMode 的四个枚举值,等于只讲了四分之一。标题说…

作者头像 李华
网站建设 2026/10/11 7:20:05

Windows 上获取最新 FFmpeg 完整构建与配置指南

简介:这份资源面向需要在 Windows 平台使用 FFmpeg 的开发者、运维人员与内容创作者,提供最新版 FFmpeg 的静态编译包,解决音视频转码、剪辑、流化等场景下的工具获取与环境搭建问题。压缩包共 44 个文件,以 30 个 html 文档、3 个…

作者头像 李华
网站建设 2026/10/11 7:19:15

HarmonyOS 7 Node-API:图像头解析TypedArray偏移校验【鸿蒙心迹】

把C图像解析能力封进HarmonyOS应用时,最危险的环节往往不是JPEG有多少个标记,而是ArkTS传过来的内存究竟从哪里开始、究竟到哪里结束,以及由谁在最后释放。对一个 Uint8Array,业务很容易只看 byteLength,却忘了它可能只…

作者头像 李华
网站建设 2026/10/11 7:17:07

基于gym的多智能体追逃博弈强化学习实战指南

简介:本资源是一套基于OpenAI Gym框架构建的多智能体追逃博弈强化学习平台源码,专为计算机及相关专业学生完成课程设计、期末大作业提供高分实践方案。项目经导师指导并获评98分,覆盖环境建模(2D/3D追逃场景)、智能体协…

作者头像 李华