☰
二叉树刷题进阶:左叶子之和与路径总和的递归与DFS详解
2026/10/10 4:05:52 网站建设 项目流程

二叉树刷题刷到一个阶段,你会卡在一个很微妙的坎上:前序、中序、后序遍历都写得挺熟了,可真到了“求所有左叶子之和”“判断是否存在某条根节点到叶子节点的路径,其总和等于目标值”这类题目,突然就不知道该从哪个角度切入。这一篇,是二叉树理论系列的第三篇,专门聊两件事:左叶子之和,以及路径总和。这两个问题,一个考的是你对叶子节点条件判断的细腻程度,一个考的是递归终止条件和 DFS 状态维护的熟练度,都是从“遍历框架”过渡到“应用型问题”的标准跳板。适合正在刷二叉树、准备面试,或者想系统巩固递归思想的人看,我会把解法背后的“为什么”一起讲清楚。

1. 整体系列的设计逻辑:从遍历到应用的跨越

1.1 为什么把这两个问题放在一起

很多刷题资料喜欢把二叉树题目按“遍历”“属性”“路径”来分类,但我更推荐按“底层模型”来归类。左叶子之和与路径总和看起来不搭边,实际上底层是同一个模型:在深度优先遍历的过程中,对满足特定条件的节点做统计或判断。

左叶子之和的本质,是在遍历中锁定“既是叶子节点、又是父节点的左孩子”的节点,把它的值累加。这不是一个纯粹的遍历问题,而是一个带条件过滤的统计问题,它考验你能不能把“当前节点是什么”和“当前节点在树中的位置”这两个信息正确组合起来。

路径总和的本质,是在前序遍历的框架下,维护从根节点到当前节点的一条累计路径,直到抵达叶子时判断累计值是否等于目标值。它比左叶子之和多了一层东西:状态的持续传递与回溯。每个节点都会继承一条路径,路径上积累了之前所有节点的值,到了叶子处再做最终判断。

把这两个问题放在同一篇,正是因为它们在难度上有一个自然的递进:左叶子之和只要求你在父节点视角做一次条件判断,路径总和则要求你持续维护累计状态,并且理解什么时候需要回溯、什么时候不需要。先搞定前者,再去啃后者,节奏会顺很多。

1.2 两个问题的共同底层模型

我把它们都归结为“遍历过程中的条件动作”。模板其实很简单:

def dfs(node, state): if not node: return # 处理当前节点,或者更新状态 dfs(node.left, new_state_left) dfs(node.right, new_state_right)

左叶子之和的“条件动作”发生在进入左子树之前,由当前节点的视角判断左孩子是不是“左叶子”;路径总和的“条件动作”发生在递归进入子节点之前更新剩余和,并且在叶子节点处做终止判断。

理解这个统一模型之后,你会发现这两个题很容易迁移。以后遇到“二叉树中所有单支节点的个数”“统计完全二叉树中满足某种性质的路径数量”这类问题,本质上都是在这个模板上加自己的条件逻辑。这也是我把这一篇定位成“理论基础”的原因:表面上只是两个具体题目,实际上是在练遍历状态机的思维模式。

2. 左叶子之和:叶子判断必须放在父节点上

2.1 叶子、左叶子、边界概念的区分

先花一分钟把概念彻底钉死。

叶子节点,指的是没有左孩子也没有右孩子的节点。用代码写就是node.left is None and node.right is None。

左叶子,不是“位于左边的叶子”,而是“作为父节点左孩子的叶子”。注意这两个描述的区别:一个叶子哪怕位于整棵树的左侧,但如果它是父节点的右孩子,那它也不能叫左叶子。

这里非常容易混淆。有个典型例子:

3 / \ 9 20 / \ 15 7

在这棵树里,9 是根节点 3 的左孩子,同时它没有自己的左孩子和右孩子,所以 9 是左叶子。7 是 20 的右孩子,即使它在整棵树的最右侧,它也不是左叶子;15 是 20 的左孩子,同时它也是叶子,所以 15 是左叶子。整棵树的左叶子之和就是 9 + 15 = 24。

另外一个边界情况:根节点。如果一棵树只有一个根节点,没有左右孩子,那这个节点是叶子没错,但它没有父节点,不可能成为任何一个节点的“左孩子”,所以它不能贡献到左叶子之和里。这是新手非常容易踩的空,后面排查部分我们再细说。

2.2 递归解法拆解:父节点视角是关键

先给出最直观的递归写法,基于 Python:

class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right def sum_of_left_leaves(root): if root is None: return 0 total = 0 # 核心判断:当前节点的左孩子是不是左叶子 if root.left is not None and root.left.left is None and root.left.right is None: total += root.left.val # 继续遍历左右子树 total += sum_of_left_leaves(root.left) total += sum_of_left_leaves(root.right) return total

这个解法的精妙之处在于,判断“左叶子”这个身份,不是由叶子自己来声明的,而是由它的父节点来声明的。为什么?

因为二叉树节点本身不带“父节点指针”,当递归进入某个节点时,你不知道这个节点是从父节点的左边还是右边下来的。你可以在递归函数里加一个is_left参数来传递方位信息,但这会让接口复杂很多。更简洁的思路是:把判断时机放在父节点这一层。父节点天然知道自己正在访问哪个孩子,也知道这个孩子长什么样。

具体来说,root.left这个表达式的含义就是“当前节点的左孩子”。只要满足:

  1. root.left不为空;
  2. root.left.left为空;
  3. root.left.right为空。

那root.left就是一个左叶子,它的值就该被加进总和。

这个逻辑对任何一层的节点都成立,所以递归函数可以统一处理。你可能会问:如果root.left不是叶子,那左子树里的左叶子怎么办?没关系,代码最后一行依然会对root.left递归调用sum_of_left_leaves。就算当前的“左孩子”不是叶子,它内部的左右子树中也可能存在符合条件的左叶子,递归会一条条帮你找出来。

2.3 三种常见误解与迭代实现

我在实际带人和面试模拟里,见过下面三种高频误解,这里集中排掉。

第一种误解:用“当前节点是不是父节点的左孩子”来判断。这需要额外参数,而且很容易记混。除非你已经熟练掌握了带参数传递的递归模式,否则不推荐作为入门解法。

第二种误解:先把所有叶子节点找出来,再判断它们是不是在左边。方向反了。你先找叶子,再判断“左边”,你得知道叶子的父节点是谁,而二叉树遍历中我们通常不会反向去找父节点,这样做很别扭。

第三种误解:写了个 BFS 层序遍历,想把每个节点按层次处理。层序遍历不是不能用,但你依然要在父节点出队时判断其左孩子是否叶子,本质上和递归一样,只是换了遍历框架。很多人在 BFS 版本里判断的是“当前出队节点是不是左孩子”,这就错了,因为队列里根本没有记录“你是从哪边过来的”这个信息。

迭代实现也不复杂,用栈模拟系统的递归调用即可:

def sum_of_left_leaves_iter(root): if root is None: return 0 stack = [root] total = 0 while stack: node = stack.pop() if node.left is not None: if node.left.left is None and node.left.right is None: total += node.left.val else: stack.append(node.left) if node.right is not None: stack.append(node.right) return total

注意这里有个小优化:如果发现node.left本身就是左叶子,那就没有必要再把它压进栈里继续搜了,因为叶子的左子树和右子树都为空,进去了也只会多做两次空判断。压栈的只有非叶子节点。这个细节不影响正确性,但能省一点无意义的遍历操作。

3. 路径总和:DFS 与减法技巧的结合

3.1 问题转化:把累加变成减法

路径总和的题目描述通常是这样的:给定一棵二叉树和一个目标值,判断是否存在一条从根节点到叶子节点的路径,路径上所有节点值之和等于目标值。

如果用最直觉的写法,你会定义一个变量current_sum,每经过一个节点就加上它的值,等到了叶子节点判断current_sum == target_sum。这完全正确,但很多后续变体会让你在这里吃一点亏。

更常用的做法是把问题反过来:每到一个节点,就把它从目标值里减掉。这样你只需要维护一个“剩余目标值”。如果走到某个叶子时剩余值恰好等于当前叶子节点的值,说明这条路径的累加和等于目标值。等一下,这句话需要一个更准确的表达,因为我们通常是在进入叶子前就把它的值减掉,所以叶子处的判断条件应该是“剩余值减完当前叶子值后等于 0”。

直接看一个版本:

def has_path_sum(root, target_sum): if root is None: return False # 当前节点是叶子 if root.left is None and root.right is None: return target_sum == root.val # 不是叶子,先把当前节点的值从剩余目标里减掉 remaining = target_sum - root.val # 然后去左右子树里找 return has_path_sum(root.left, remaining) or has_path_sum(root.right, remaining)

为什么减法比累加舒服?因为累加法意味着你要在每个递归调用里多携带一个“当前和”,然后到叶子时比较目标值;减法则是把比较变成了“归零判断”。逻辑上更干净,也为后续“路径总和 II 要收集整条路径”的版本留好了接口。

3.2 递归实现细节:空节点、叶子节点与边界

这里面有几个细节特别容易出错,我逐个说。

第一个细节:空树直接返回 False。因为不存在任何根到叶子的路径,哪怕目标值是 0,也不能说存在。

第二个细节:叶子判断必须优先于递归。如果当前节点是叶子,就直接比较target_sum == root.val,返回结果,不要再往下递归。否则你会把叶子当成父节点继续找左子树和右子树,那两边都是空,最终拿到两个 False,把正确结果给“或”没了。

第三个细节:中间节点要先把当前值减掉,再传给左右子树。很多人会把这个减法写错位置,比如先递归到叶子再减。那种写法不是不行,但接口会别扭,而且容易重复减。我建议固定这套默认流程:进函数先处理空、再判断叶子、最后减当前值并递归。

第四个细节:“或”运算存在短路。左子树一旦找到符合条件的结果,右子树就不会再执行,这在我们这里刚好是期望的。你要知道这个机制存在,不要在递归里放什么带副作用的代码,否则短路会让副作用漏执行。

用一个具体例子演示执行过程。沿用上面那棵树:

3 / \ 9 20 / \ 15 7

假设目标值target_sum = 38。

调用has_path_sum(root, 38)。根节点 3 不是叶子,remaining = 38 - 3 = 35。去左子树找,传入(9, 35);9 是叶子,判断35 == 9,为 False,于是这一步返回 False。

再去右子树找,传入(20, 35);20 不是叶子,remaining = 35 - 20 = 15。对 20 的左子树找,传入(15, 15);15 是叶子,判断15 == 15,为 True。最终结果返回 True。这条路对应的节点值序列是 3 -> 20 -> 15,和为 38。

反过来,如果目标值是 30,那根节点减完 3 后得 27,走到 20 时减完得 7,再到 7 这个叶子判断7 == 7,也是 True。这条路径是 3 -> 20 -> 7,总和 30。所以同一棵树可以命中多个目标值,这很正常。

3.3 迭代实现:显式维护剩余和

递归会占用系统栈,如果二叉树深度特别大,例如退化成了一条链,可能出现栈溢出。这时候用显式的栈来模拟 DFS 更稳妥。

迭代版本的核心思路是:栈里存的不只是节点,还要存一个“到达这个节点时的剩余目标值”。每次弹出节点,先用当前节点的值更新剩余值,再判断是否叶子、剩余值是否归零;如果不是叶子,就把左右孩子和新剩余值一起入栈。

def has_path_sum_iter(root, target_sum): if root is None: return False stack = [(root, target_sum)] while stack: node, remaining = stack.pop() remaining -= node.val if node.left is None and node.right is None: if remaining == 0: return True else: # 注意入栈顺序:栈是后进先出,先压右孩子,左孩子会先被访问 if node.right is not None: stack.append((node.right, remaining)) if node.left is not None: stack.append((node.left, remaining)) return False

这个版本的时间复杂度是 O(n),每个节点最多入栈一次。空间复杂度取决于栈的最大长度,最坏情况下也是 O(n)。相比递归版本,它的好处是性能可控,不会因为树太深而触发递归深度限制。

一个值得注意的细节:因为栈是后进先出,代码里先压右孩子再压左孩子,这样左孩子放在栈顶、会被先弹出。这正好模拟了前序遍历左子树优先的效果。虽然路径总和这道题不要求遍历顺序,但养成这种写栈的习惯,换到别的题里不容易乱。

我把两种实现放在同一张表里对比一下:

实现方式时间复杂度空间复杂度主要优势主要风险
递归O(n)O(h)逻辑直观,代码量少树深过大时可能爆栈
迭代O(n)O(n)不依赖系统递归深度状态管理稍复杂,容易漏压栈

这里的 h 是树的高度,平衡树里约等于 log n,退化链状树里等于 n。

4. 变体扩展与调试排查手册

4.1 从路径总和到路径总和 II:收集所有路径

笔试和面试里,单独考路径总和的概率不如带一个变体高。最常见的变体是:不只判断是否存在,而是把满足条件的完整路径全部收集起来,返回一个二维数组。

这个变体之所以难,是因为它同时要求你具备“路径记录”能力。在递归过程中,你得把当前经过的节点值保存到一个列表里,而且当递归回退的时候要清理掉当前节点的痕迹,否则路径会越积越长。

一个标准写法是这样的:

def path_sum_all(root, target_sum): result = [] def dfs(node, remaining, path): if node is None: return remaining -= node.val path.append(node.val) if node.left is None and node.right is None and remaining == 0: result.append(path[:]) # 记得拷贝,不要直接添加 path 本体 dfs(node.left, remaining, path) dfs(node.right, remaining, path) # 回溯:把当前节点从路径里移除 path.pop() dfs(root, target_sum, []) return result

这里最关键的是那两个被反复强调的细节。

第一个:result.append(path[:])而不是result.append(path)。path 是一个列表对象,在整个递归过程中被反复修改,如果直接把 path 本体放进去,后续回溯会把它越改越短,最后结果里所有路径都变成空列表。切片拷贝path[:]是在当下把这份快照保存下来。

第二个:path.pop()必须在递归返回之后执行。只要 a 节点往下探索左子树和右子树,都会先把 b 的值追加进 path,全部结束之后再回到 a 节点,把 a 从 path 的末尾弹出。这个“先追加、后回溯、再返回上一级”的顺序,是回溯算法的通用节奏,以后做全排列、组合题也一样。

左叶子之和同样可以扩展出变体,比如“求所有左叶子中的最大值”“统计左叶子的个数”“求二叉树中所有右叶子的和用来和左叶子做对比”。思路没变,只是把total += node.left.val换成比较大小或计数。真正需要变通的是“路径总和”这一类,因为它的状态是累计的,回溯点更多。

4.2 高频易错点速查与调试心得

我把自己刷题和帮人改代码时撞见最多的错误整理成了一张速查表,方便你写完代码后对照自查:

症状可能原因正确的处理思路
左叶子结果偏大把右叶子也加进去了检查判断条件,必须同时满足:是左孩子、是叶子
左叶子结果偏小根节点被误排除,或者只判断了当前节点本身根节点确实不参与;叶子判断放在父节点层,不是叶子自己层
单节点树路径总和返回错把空树和单节点的情况混在一起空树直接 False;单节点树里如果值等于目标,返回 True
路径总和递归结果自相矛盾减法的位置放错,重复减了当前节点固定流程:判断叶子时直接比 target 和 val;非叶子一律减当前节点值再向下传
路径收集结果全是空列表没做 path[:] 拷贝回溯列表必须拷贝快照,不能直接传引用
迭代写法超时把已经判断过的叶子反复压栈叶子节点不要继续入栈,只压非叶子节点的孩子

最后再分享一个我自己调试二叉树问题时非常受用的小习惯:不要一上来就拿三五个节点的复杂树去跑代码,先画一棵两层的树,手动写出理想的路径值或叶子值,再心算一遍递归过程。比如根节点带一个左叶子,这种极简场景能逼你思考“我的条件判断放在哪一层、什么时候触发”。我见过不少人在几行的例子里能跑对,复杂度一上来就暴露问题,根本原因就是概念上的边界没分清。

路径总和这种题,还有一层值得记住的思考方式:不要总想着“把路径存下来”。单一目标的路径总和只需要维护一个剩余值,存路径反而会让你陷入回溯的泥潭。等到真的需要路径时,再引入 path 列表和回溯操作。这也解释了为什么这一篇要把左叶子之和放在前面:左叶子之和给你练的就是“在正确位置做判断”的基本功,而路径总和练的是“在正确位置做状态更新和回溯”。两件事分开练熟,写复杂二叉树问题的时候,你才有底气去组合它们。

按我这几年的经验,二叉树系列的刷题节奏,最忌讳一上来就背代码。把右叶子误判成左叶子、把空树当成合法路径、把叶子当父节点继续递归,这些错误其实是概念没落地的信号。这篇给你的,正是把概念落到具体的判断位置和递归边界上。以后的进阶题,比如“把二叉树展开为链表”“二叉树的最大路径和”,你会发现逃不出这一篇里反复强调的几件事:递归出口写清楚,叶子判断写精确,状态该回退时绝不偷懒。

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

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

立即咨询