拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LeetCode 404 左叶子之和:二叉树遍历中的递归与迭代全解析

LeetCode 404 左叶子之和:二叉树遍历中的递归与迭代全解析

前几天在刷题群里看见有人发求助:“Leetcode 404 左叶子之和,明明标着简单,我愣是提交错了三遍。”第一反应我以为他漏了判空,点开代码才发现,问题出在他把“左叶子”当成了“左孩子”来算。这不是个例,评论区里踩同一个坑的人不少。

LeetCode 404 是一道非常经典的二叉树遍历入门题,也是LeetCode热门100题里的常客,周赛前的热身练习经常能看到它。题目本身不复杂,但能把“左叶子”三个字真正吃透的人其实不多。今天我就借这个机会,把这道题从定义到递归、从迭代到变形题全部聊透,尤其是那些编辑器不报错、逻辑上却会算错的细节,保证你看完能直接照着写,也能用在其他二叉树题目上。

1. 题目到底在问什么:从“左孩子”到“左叶子”的语义转变

1.1 左叶子的精确定义

很多第一眼看到“左叶子之和”的人,会下意识理解成“把所有左孩子的值加起来”。这是最大的陷阱。

左叶子必须同时满足两个条件:

  • 它是某个节点的左孩子,也就是说,在父节点那一层,它位于左侧。
  • 它自身没有任何孩子,即left和right都为空,是一个真正的叶子节点。

换句话说,光看节点本身不够,还要看它和父节点的相对关系。一个节点哪怕没有孩子,如果它是父节点的右孩子,那也不叫左叶子;反过来,一个节点哪怕有孩子,只要它是左孩子,也不能算左叶子。

我平时给朋友讲这个概念时喜欢打个比方:想象一支足球队,左叶子就像“左边锋且这轮没上场”——位置必须是左边,而且这轮比赛完全没出场记录,缺一不可。

1.2 样例逐层拆解

题目给了两个很典型的例子。第一个是二叉树[3,9,20,null,null,15,7],结构如下:

3 / \ 9 20 / \ 15 7

在这个树里:

  • 节点 9 是 3 的左孩子,并且 9 的左右孩子都是空,所以 9 是左叶子。
  • 节点 15 是 20 的左孩子,但是 15 不是叶子,它有孩子吗?没有,等等,这里要注意,15 没有孩子,所以 15 其实也是叶子,同时它是 20 的左孩子,因此 15 也是左叶子!

不对,让我重新看题目样例。LeetCode 404 的示例 1 是[3,9,20,null,null,15,7],答案应该是 24?我印象中答案是 24。等等,我是不是记混了?让我仔细回忆。

实际上 LeetCode 404 的示例: 输入:root = [3,9,20,null,null,15,7]输出:24

解释:在这个二叉树中,有两个左叶子,分别是 9 和 15,所以返回 9 + 15 = 24。

没错,示例 2 是root = [1],输出 0。我前面在思考时把 15 和 7 搞错了,15 是叶子节点,7 也是叶子节点,但只有 15 是左叶子,7 是右叶子,所以答案是 9+15=24。这个例子正好完美展示了“左叶子”的两个条件的必要性。

示例 2:root = [1],只有一个根节点,没有左孩子,左叶子之和为 0。

这里补充一个我实际踩过的坑:测试用例给的是层序遍历序列,但你要分析树结构时必须还原成树,不能直接对着数组里的位置判断“哎,下标 2 是下标 1 的左孩子,所以它是左叶子”。数组里下标 2 确实是下标 1 的左孩子,但下标 2 是数组里的“相对位置”,不代表它在树里一定是“左叶子”,因为下标 2 可能还有孩子。判断叶子必须看还原后的真实节点是否有左右子树。

1.3 边界条件清单

做二叉树题,先列边界条件是个好习惯。这道题的边界有四个:

  1. 根节点为空root = None,左叶子之和是 0。
  2. 只有一个根节点,没有左孩子也没有右孩子,结果是 0。
  3. 根节点只有左孩子,且左孩子是叶子,那么结果等于该左孩子的值。
  4. 根节点有左子树,但左子树的根不是叶子,这时要递归到左子树内部去找更深的左叶子。

把这些边界想清楚,比急着写代码更重要。很多时候提交报错不是算法思想错了,而是边界条件少考虑了一种。

2. 递归解法:站在子树的肩膀上做判断

2.1 递归的思考方式:不要一上来就想全局

二叉树类的题,最忌讳一上来就想“我要怎么遍历完整棵树再统计”。正确的递归思考方式应该是:只关心当前节点能决定什么,剩下的事情交给递归。

当前节点能决定什么?答案很明确:当前节点能决定“我的左孩子是不是左叶子”。如果root.left存在,且root.left.left和root.left.right都不存在,那root.left就是一个左叶子,值应该被计入。至于左子树里更深的左叶子,以及右子树里的左叶子,我管不着,让递归去处理。

这个思路翻译成代码,就是非常经典的“根节点只负责判断一层,子树结果向上汇总”。

2.2 完整代码与逐行解释

Python 版本如下:

class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right class Solution: def sumOfLeftLeaves(self, root: TreeNode) -> int: if not root: return 0 ans = 0 # 判断当前节点的左孩子是不是左叶子 if root.left and not root.left.left and not root.left.right: ans += root.left.val # 递归统计左子树和右子树中的左叶子 ans += self.sumOfLeftLeaves(root.left) ans += self.sumOfLeftLeaves(root.right) return ans

逐行解释一下:

  • if not root:空节点返回 0。这是递归的终止条件之一。
  • ans = 0:当前这一层累计的答案。
  • if root.left and not root.left.left and not root.left.right:这个条件是整道题的核心。root.left存在,并且它没有左孩子、没有右孩子,那它就是一个左叶子。
  • ans += root.left.val:把左叶子的值加进去。
  • ans += self.sumOfLeftLeaves(root.left):递归处理左子树。这里要注意,如果root.left本身已经是左叶子,那它的左右孩子都是空,递归进去会返回 0,不会造成重复计算。
  • ans += self.sumOfLeftLeaves(root.right):递归处理右子树,因为右子树里也可能存在“它自己的左孩子”,那些孩子从全局来看依然是整棵树的左叶子。

这里有一个我遇到的易混点:很多人会问,如果root.left是左叶子,递归它的时候返回 0,我能不能直接不递归?可以,但没意义,因为root.left的两个孩子都是空,递归进去立刻返回 0。保持代码的一致性更好,统一递归左右子树,不容易漏逻辑。

2.3 为什么递归前要先做一次左叶子判断

如果不先判断,直接递归,会发生什么?看这段错误写法:

# 错误的写法 def sumOfLeftLeaves(self, root): if not root: return 0 ans = 0 if root.left: ans += self.sumOfLeftLeaves(root.left) ans += self.sumOfLeftLeaves(root.right) return ans

这段代码在递归过程中,完全没有“叶子判断”。递归到节点 9 时,9 没有孩子,返回 0,但 9 作为一个左叶子,它的值根本没有被加进去。所以最终结果永远是 0。

结论是:递归过程中必须在“父节点”这一层判断左孩子是不是叶子,不能把判断扔给子递归。因为子递归只知道“我是谁”,不知道“我在父节点眼里是左孩子还是右孩子”。这种信息差是这道题最核心的考点。

3. 迭代解法:用显式栈消除递归的隐式开销

3.1 递归的隐式栈与迭代的必要性

递归版本虽然简洁,但底层依赖系统调用栈。如果二叉树退化成一条链,递归深度可能达到节点数 N,在某些环境中会触发栈溢出。所以面试时如果被追问“能不能不用递归”,你需要能写出迭代版本。

迭代的本质是用自己的栈模拟系统栈。每弹出一个节点,就检查它的左孩子是不是左叶子,然后把它的左右孩子都压入栈,继续遍历。这个流程和前序遍历几乎一模一样,只是多了“判断左孩子是否为叶子”的一步。

3.2 迭代版本代码实现

class Solution: def sumOfLeftLeaves(self, root: TreeNode) -> int: if not root: return 0 stack = [root] ans = 0 while stack: node = stack.pop() # 检查当前节点的左孩子是否为左叶子 if node.left and not node.left.left and not node.left.right: ans += node.left.val if node.right: stack.append(node.right) if node.left: stack.append(node.left) return ans

这段代码的思路非常直白:

  • 初始化栈,根节点入栈。
  • 只要栈非空,弹出节点处理。
  • 判断node.left是否为左叶子,是则累加。
  • 将node.left和node.right入栈,继续遍历。

这里入栈顺序其实无所谓,因为我们是把所有节点都检查一遍,先处理哪个都不影响结果。不过习惯上先压右再压左,这样弹出时先处理左子树,和递归的顺序保持一致。

3.3 递归与迭代的复杂度对比

对比维度递归解法迭代解法
时间复杂度O(n),每个节点访问一次O(n),每个节点访问一次
空间复杂度O(h),h 为树高,最坏 O(n)O(n),栈中最多保存一层节点
代码可读性高,逻辑集中中,需要自己管理栈
栈溢出风险有,树深时可能触发无,使用堆内存

从实际刷题角度,递归写法足够通过 LeetCode 的所有测试用例。但迭代解法能帮你深入理解“遍历时如何携带额外信息”这件事,很多后续题目比如二叉树的所有路径、求根到叶子的数字和,都会用到这种“栈 + 判断”的组合套路。

4. 从提交到AC:真正会卡你一下的边界与易错点

4.1 错误1:把叶子节点和空节点混为一谈

二叉树题里,None和“叶子节点”是两回事。叶子节点是left和right都为空的节点,空节点是None。

常见的错误写法是:

if not root.left: return 0

这行代码只判断了“左孩子不存在”,完全没有判断“左孩子是否为叶子”。如果左孩子存在但不是叶子,比如示例 1 中的节点 20,它的左孩子 15 存在且为叶子,此时 15 应该是答案的一部分,但上面的错误写法会把 20 的左孩子直接忽略。

正确的判断逻辑必须是:

if root.left and not root.left.left and not root.left.right:

先确保存在,再确保没有孩子,两个条件缺一不可。

4.2 错误2:只递归左子树,忘记右子树里也有左叶子

这种错误很有意思,因为它不是语法错误,而是理解层面漏了一块,编辑器完全不会提示。

错误写法:

class Solution: def sumOfLeftLeaves(self, root): if not root: return 0 ans = 0 if root.left and not root.left.left and not root.left.right: ans += root.left.val ans += self.sumOfLeftLeaves(root.left) return ans

这版把右子树的递归给删了。测试树[3,9,20,null,null,15,7]会得到 9,而不是 24。因为 15 这个左叶子在 20 的右子树里,不递归右子树,永远统计不到它。

从二叉树的遍历角度想,你只有把整棵树走完,才能确定哪些节点是左叶子。任何“只走半棵树”的思路都是错的。

4.3 错误3:重复累计左叶子的值

还有一种写法能通过示例,但在某些边界用例上会重复计算:

# 有问题的写法 def sumOfLeftLeaves(self, root): if not root: return 0 ans = 0 if root.left and not root.left.left and not root.left.right: ans += root.left.val ans += self.sumOfLeftLeaves(root.left) ans += self.sumOfLeftLeaves(root.right) return ans

乍一看好像没错,但仔细想:如果root.left是左叶子,那么root.left.left和root.left.right都是 None,递归下去会返回 0。所以在这道题里,这样写其实不会重复计算。真正会重复计算的是另一种写法,比如把左叶子的值加到返回结果里,同时又在递归左子树时把左子树根节点的值再加一次。

例如:

if root.left and not root.left.left and not root.left.right: return root.left.val + self.sumOfLeftLeaves(root.left) + self.sumOfLeftLeaves(root.right)

这种写法把root.left.val加了一次,然后递归左子树时,如果递归函数里又判断root.left的左孩子,就会在更深一层把另一个值加进来,从而导致计数混乱。所以建议代码保持“当前层累加 + 递归调用 + 返回累加值”的清晰结构,不要提前 return。

4.4 用测试用例验证你的代码

我每次写完都会在本地跑这组用例,确保万无一失:

输入: [] 输出: 0 输入: [1] 输出: 0 输入: [3,9,20,null,null,15,7] 输出: 24 输入: [1,2,2,3] 输出: 3

逐个解释:

  • 空树没有节点,返回 0。
  • 单节点根不是任何人的左叶子,返回 0。
  • 9 和 15 是左叶子,和为 24。
  • [1,2,2,3]的树结构是:1 的左孩子 2(不是叶子,因为它有左孩子 3),2 的左孩子 3 是叶子,3 是左叶子,所以返回 3。注意这里 1 的右孩子 2 虽然有两个孩子?等等,[1,2,2,3]是层序遍历,还原后:根 1,左孩子 2,右孩子 2,然后 3 是左孩子 2 的左孩子。所以右孩子 2 没有孩子,它也不是左叶子。答案是 3。

如果这组用例都能通过,代码基本没有问题。

5. 左叶子之和的变体与延伸思考

5.1 改一个字母:右叶子之和

学会了左叶子,右叶子几乎不用动脑。只需要把判断条件里的root.left全部换成root.right,然后递归方向对称一下。但这里有一个隐藏考点:右叶子要求是“父节点的右孩子”且为叶子,这和左叶子的定义在本质上完全对称。

我把这个变体题当成面试时的加分题,用来考察候选人是否真的理解“父节点视角下的叶子判断”,而不是死背代码。

5.2 更进一步:求所有叶子之和

所有叶子之和比左叶子更简单,它不需要关心“左还是右”,只需要判断一个节点是不是叶子。此时递归写法可以收敛为:

def sumOfLeaves(root): if not root: return 0 if not root.left and not root.right: return root.val return sumOfLeaves(root.left) + sumOfLeaves(root.right)

这里的关键变化是:叶子判断从“父节点判断左孩子”变成了“当前节点判断自己”。这也反向说明了为什么左叶子题更难一点点,因为它给叶子加上了“相对位置”维度,每一个节点都必须先知道自己是左孩子还是右孩子,才能决定要不要累加。

5.3 拔高变体:求最深左叶子的深度与值

这是一个进阶版,经常出现在周赛的签到题附近。要求不仅能找到左叶子,还要找到深度最大的那个左叶子,甚至输出它的值。

推荐做法是带层级的 DFS 或 BFS。用递归的话,需要额外传一个depth参数:

def deepest_left_leaves(root): if not root: return 0, -1 # (值, 深度) max_depth = -1 result = 0 def dfs(node, depth, is_left): nonlocal max_depth, result if not node: return if is_left and not node.left and not node.right: if depth > max_depth: max_depth = depth result = node.val return dfs(node.left, depth + 1, True) dfs(node.right, depth + 1, False) dfs(root, 0, False) return result

这段代码里的is_left参数很巧妙,它记录当前节点在父节点眼里是不是左孩子。初始根节点传False,因为根不是任何人的左孩子。左叶子必须满足is_left == True且自身是叶子。

看到这你会发现,左叶子题的核心其实是一个“参数携带”技巧:二叉树遍历时,如何把“我在哪个方向”的信息向下传递。这个技巧在二叉树的所有路径、二叉树最大宽度等题目里都会被反复使用。

5.4 从这道题想到的刷题策略

最后聊聊这道题在 LeetCode 热门 100 题中的定位。它被归为简单题,但面试中出现的频率并不低。原因在于“左叶子”这个概念很容易被模糊处理,而面试官恰好想看看候选人能不能把需求精确翻译成代码。

我自己的刷题经验是:遇到二叉树题,先不要急着写循环或递归,先明确两件事——第一,用哪种遍历方式(前序、中序、后序、层序);第二,在每个节点上需要做什么判断。左叶子之和这道题,本质是“前序遍历 + 父节点对左孩子的叶子判断”,想通这一点,代码自然就出来了。

另外,不要觉得简单题没用。我经常在刷题群里看到有人死磕难题,反而忽略了对这类简单题的精读。但简单题里往往藏着最基础的概念,比如“什么是叶子节点”“递归返回值到底该怎么逐层上传”。把这些概念练成肌肉记忆,才能在做复杂题时不用分心。

我个人特别喜欢这道题的另一个原因是,它很适合用来练习“把题意转化为条件”的能力。看到“左叶子”,立刻拆解为“是左孩子”和“是叶子”两个布尔条件,这种拆解思维,对后续做动态规划、图论算法同样管用。你可以试着用同样的方式去拆解题目里的其他名词,比如“右路径”“根节点到叶子节点”,一旦你习惯了这种拆词,刷题效率会明显提升。

返回列表