)
LeetCode 437. 路径总和 III 是我刷题笔记里单独占了一页的一道题。原因很简单二叉树路径求和这个系列里前两题都是根到叶子的固定玩法到了第三题起点和终点全都不固定网上不少题解一上来就甩前缀和加哈希表代码就十来行但没看懂的人照抄一遍下次还是不会。这篇笔记我用自己的完整思考链来写——从暴力 DFS 怎么想、为什么慢到前缀和怎么把问题变成一次查表再到实际提交时踩过的坑最后把它和同一家族的题目串起来。如果你最近也在刷 leetcode 热门 100 题或者想系统补一下前缀和这个套路这篇应该能帮上忙。1. 先把题目读透437 和 112、113 不是同一道题1.1 题目到底在问什么用大白话翻译一遍题目给一棵二叉树和一个整数 targetSum数一数树里一共有多少条从任意节点出发、沿着父到子的方向、长度任意的路径让路径节点值之和等于 targetSum。路径不用从根开始也不用在叶子结束。官方示例长这样10 / \ 5 -3 / \ \ 3 2 11 / \ \ 3 -2 1targetSum 8答案是 3三条路径分别是5 → 3、5 → 2 → 1、-3 → 11。注意第一条路径的起点是 5 而不是根终点是 3 而 3 下面还有孩子第三条路径的起点是负值节点。-3 加 11 正好等于 8这种负数开头的路径在前两题里根本不会出现因为前两题的起点锁死在根上。数据范围也要先记清楚节点数最多 1000节点值在 [-1000, 1000]targetSum 也在 [-1000, 1000]。这意味着路径上可能出现负数前缀和不是单调递增的后面讲优化的时候会反复用到这一点。1.2 三兄弟放一起对比差异一目了然LeetCode 里路径总和有三道题很多刷题新手容易混先放个对比表题号起点终点要什么结果返回值112根节点叶子节点是否存在一条boolean113根节点叶子节点把所有路径列出来List437任意节点任意子孙节点一共有多少条int112 和 113 之所以简单是因为起点锁死在根你只要在递归时把 target 减掉当前节点值往下传走到叶子判断剩没剩 0 就行。437 把起点和终点都放开了一套根到叶子的递归直接失效——你不能假设每条路径都从当前递归入口开始也不能假设到达叶子才能结算。这个任意到任意是质变不是量变。还有一个高频思维陷阱以为当前和一旦超过 targetSum 就可以剪枝。因为有负数路径和可以先到 9 再跌回 8剪枝会误杀正确答案。这个陷阱在暴力解法里最明显所以下面先上暴力。2. 暴力解法先上手每个节点都当一次路径起点2.1 双层递归的思路拆解暴力思路其实很直白路径必须向下走那我就枚举起点。树里每个节点都有资格当起点固定起点之后问题退化成从这个节点出发往下走能走出几条和为 targetSum 的下降路径——这不就是熟悉的递归吗所以代码是两层递归嵌套class Solution: def pathSum(self, root: Optional[TreeNode], targetSum: int) - int: if not root: return 0 # 以 root 为起点能凑出的路径数 不以 root 为起点的路径数 return ( self._count_from(root, targetSum) self.pathSum(root.left, targetSum) self.pathSum(root.right, targetSum) ) def _count_from(self, node: Optional[TreeNode], remain: int) - int: if not node: return 0 # 当前节点自己就是一条合法路径的终点 cnt 1 if node.val remain else 0 # 路径还可以继续往下延伸 cnt self._count_from(node.left, remain - node.val) cnt self._count_from(node.right, remain - node.val) return cnt注意 _count_from 里用了一个剩余值参数每次往下走就把当前节点值减掉。这样写的好处是语义清楚remain 表示从这一个起点走到当前节点还差多少和。当 remain 恰好等于当前节点值时说明当前节点可以作为一条路径的终点计 1 条同时你还要继续递归左右孩子因为路径可以继续延伸。外层 pathSum 负责让每个节点都当一次起点里层 _count_from 负责从固定起点向下统计两层各司其职边界情况也不会漏。2.2 复杂度分析和为什么能 AC 但别止步外层每个节点都要执行一次以自己为起点的向下遍历。最坏情况是树退化成一条链第一个节点要往下扫 n 个节点第二个扫 n-1 个……总操作量是 12...nO(n²) 没跑。如果是平衡二叉树每一层节点往下扫的深度递减总操作量接近 n log n但题目给的测试里链状树并不少见所以必须按最坏情况算。空间上递归栈最深是 O(n)。LeetCode 原题节点数上限是 1000所以暴力其实能通过很多题解区评论也会说直接过。这没问题但我建议把它当成理解的起点而不是交上去的终点理由有两个第一面试官几乎必然会追问数据量放大到 10^5 怎么办O(n²) 在这种追问下直接被抬走第二暴力的双层递归是前缀和解法的天然对照实验——你先看到暴力在哪个环节重复计算才能理解前缀和解法到底优化掉了什么。重复计算的正是每条路径的起点都要被扫一遍这件事下面就来解决它。3. 前缀和 哈希表把逐对枚举换成一次查表3.1 关键公式任意一条下降路径的和 两个前缀和之差定义 prefix(node) 为从根走到 node 时路径上节点值累加得到的总和。那么对于任意一条从 a 到 b 的下降路径b 在 a 的子树里它的路径和可以写成路径和 prefix(b) - 路径起点之前停住的那个前缀和换句话说路径和等于当前节点的前缀和减去路径起点之前的前缀和。打个比方前缀和就像每走一步记一次账本累计值你要查某一段花了多少钱只要拿当前累计值减去那一小段开始之前的累计值就行中间不用重新数。如果路径起点就是根节点那起点之前的前缀和就是初始的 0对应后面要讲的 {0: 1}。于是核心问题变成DFS 走到节点 b、手里拿着当前前缀和 cur 时在这条从根到 b 的路径上历史上出现过几次前缀和等于 cur - targetSum出现一次就说明有一个起点能和 b 组成一条合法路径出现 k 次就有 k 条。所以我们要的是一个前缀和 - 出现次数的计数哈希表而不是简单的存在性集合。3.2 为什么要先放一个 {0: 1}初始化放进 {0: 1}表示前缀和为 0 的情况出现过一次。这个 0 代表路径从根节点开始的选项——根节点之前的空前缀和就是 0。如果没有它所有起点在根的路径比如根到某个子孙节点刚好等于 targetSum 的情况都会被漏掉。后面第 5 节我会用具体例子演示漏掉它的后果这里先记住这一行是必写的。3.3 不画图用官方示例走一遍关键步骤还是上面那棵树targetSum 8我挑有信息量的关键节点说根节点 10cur 10查 10-82map 里没有ans 还是 0。把 10 放进 map。节点 5cur 15查 15-87没有。map 里 15 入表。节点 35 的左孩子cur 18查 18-810map 里有 10根节点的前缀命中 1 次。ans 变 1。这次命中对应路径 5 → 3从根节点之后截断起点起点是 5和正好 538。继续走到 3 的左右孩子、回溯回到节点 5 后去右子树节点 2 的 cur 17查 17-89没有节点 1 的 cur 18查 18-810map 里还是有 10再命中 1 次。ans 变 2对应路径 5 → 2 → 1。回到根进右子树节点 -3 的 cur 7查 7-8-1没有节点 11 的 cur 18又查 18-810map 里还是有根的前缀 10再命中 1 次。ans 变 3对应路径 -3 → 11。三次命中查的是同一个前缀和 10对应的起点分别是 5、5、-3共同点是从根之后截断剩下那段刚好凑出 8。整个过程每个节点只访问一次每次查表是 O(1)这就是 O(n) 的由来。3.4 回溯的本质map 只保留当前这条根到节点的路径这里有个最容易写错也最关键的动作每个节点递归完左右子树之后必须把自己这个前缀和的计数减回去prefix[cur] - 1。原因是 map 描述的是当前正在走的这条根到节点路径上前缀和的分布不是整棵树的前缀和分布。节点一旦回溯它就不再位于当前路径上它的前缀计数必须消失否则左右子树之间会串线。串线的后果具体表现为把左子树某个节点当前缀起点、右子树某个节点当终点的伪路径也算进去。真实路径只能一路向下不能从左边上去再拐到右边但残留的前缀和会让这种非法组合在数值上恰好等于 targetSum从而混进答案。我在第 5 节会给一个能跑出来的错误例子。4. 最终代码与回溯细节就那几行但每一行都有讲究4.1 Python 参考实现class Solution: def pathSum(self, root: Optional[TreeNode], targetSum: int) - int: self.ans 0 prefix {0: 1} # 前缀和 - 出现次数{0:1} 代表路径从根开始 def dfs(node: Optional[TreeNode], cur: int) - None: if not node: return cur node.val # 当前节点作为路径终点前缀和 cur-targetSum 出现几次就有几条 self.ans prefix.get(cur - targetSum, 0) # 前缀和入表再去遍历子树 prefix[cur] prefix.get(cur, 0) 1 dfs(node.left, cur) dfs(node.right, cur) # 回溯当前节点即将离开这条根路径计数还原 prefix[cur] - 1 dfs(root, 0) return self.ans如果你用的是 Javakey 用 long 会更稳妥因为虽然这题节点值范围小、int 够用但很多改版题会把范围放大提前用 long 能省一次返工。Python 没有这个问题整数随便造。4.2 三处容易被忽略的细节第一查表和入表的顺序不能反过来。必须先查 cur - targetSum再把 cur 入表。如果先入表再查当 targetSum 为 0 时cur - 0 恰好等于 cur而当前节点的前缀刚刚入表你会把一条根本不存在的空路径额外算进去导致答案偏大。就算 targetSum 不为 0先入表也会让 map 里混入不属于当前路径前缀的信息只是恰好查不到而已。所以这个顺序是硬性要求不是习惯问题。第二用 self.ans 累加而不是让 dfs 返回计数值纯属 Python 写法偏好不是必须。用闭包变量的好处是每个节点只做查一次表、加一次表、减一次表三个动作返回值随便写坏处是面试时你得能解释清楚状态从哪来。如果你更习惯返回值写法可以让 dfs 返回当前子树内新增的命中数两种都行但别在同一份代码里混着写。第三递归函数里 cur 是值传递但 prefix 是引用传递。这恰恰是回溯能成立的前提cur 跟着递归栈自动恢复prefix 必须手动恢复。理解这一点你就明白为什么回溯只动 prefix完全不用管 cur。4.3 时间与空间复杂度时间上每个节点恰好访问一次每次操作是哈希表的 O(1) 读写总复杂度 O(n)。空间上哈希表最多存 n 个不同前缀和加上递归栈深度最坏 O(n)总体 O(n)。和暴力解放到一起对比更直观方案时间空间适用数据规模暴力双层递归O(n²)O(n)栈n ≤ 1000 可过前缀和 哈希表O(n)O(n)n 到 10^5 也稳5. 实测踩坑记录漏答案和错答案都是怎么来的5.1 漏掉 {0: 1}所有以根为起点的路径直接消失拿个最朴素的例子根节点 5左孩子 3targetSum 8。正确路径只有一条5 → 3。跑前缀和解法时走到根cur 5查 5-8-3没有走到节点 3cur 8查 8-80。如果 map 里没有 {0: 1}这次查表结果是 0ans 一直是 0——明明正确路径就在眼前却一条都数不出来。加上 {0: 1} 之后这里一查就是 1。我一开始写的时候甚至把 {0: 1} 写成了 {0: 0}提交到第 40 多个用例才暴露。这种错误不是逻辑错误是初始化错误极其隐蔽因为大部分测试用例里根起点路径和其他路径混在一起少一条很难用肉眼发现。所以我的经验是写完先跑几个根直接通向目标的小用例专门验证这一行。5.2 忘了回溯左右子树互相串线数出根本不存在的路径构造一棵小树根 0左孩子 5左孩子的左孩子 7右孩子 10右孩子的右孩子 8。targetSum 6。手动数一遍这棵树里没有任何一条下降路径的和等于 6正确答案是 0。错误写法是访问完左子树后不执行 prefix[cur] - 1。那么走到右孩子 10 时 cur 10查 10-64 没有继续走到右下节点 8cur 18查 18-612——坏事了map 里还留着左子树节点 7 的前缀 12根 0 左 5 左左 7于是错误地命中 1 次ans 变成 1。这条路径如果强行解释得从 7 跳到 8中间还要经过不存在的边实际在树里根本不存在。这个例子我一直留着因为它是回溯必要性的完美演示不是多算了一两条而是算出了结构上不存在的路径。每次重刷 437我都会先跑一遍这个错误用例确认回溯逻辑没写丢。5.3 用集合代替计数哈希表重复前缀被吞答案题目没有规定节点值不能为 0负数也会让同一条根路径上出现重复前缀和。用 set 存前缀和会丢掉出现次数只保留是否出现一旦某个前缀和在同一条路径上出现多次就少算。例子根 0右孩子 0右孩子的右孩子 5targetSum 5。正确路径其实有 3 条根 → 右 → 右右005、右 → 右右05、右右单独5。前缀和 0 在这条路上出现了 3 次初始、根、右孩子所以走到右右节点时 map[0] 3这才有 3 条。用 set 就只剩 1。计数哈希表把出现次数作为核心信息这个信息在负数多的树上尤其重要。5.4 负数与 targetSum 0别在递归里提前 return暴力写法里最常见的 bug 是_count_from 里一旦发现 remain node.val 就返回以为路径到底了。错。看一条链5 → -2 → 2targetSum 5。合法路径有两条单独一个 5以及 5 → -2 → 25-22 还是 5。如果你在节点 5 处发现 5 5 就 return第二条路径就丢了因为负数把和拉低之后又能拉回来。前缀和解法天然免疫这个坑它从来不看当前和是否已经达到目标只关心 cur - targetSum 在 map 里有没有。这就是前缀和把路径长度不确定这个麻烦直接吸收掉的原因——你根本不需要知道路径在哪里结束只需要在当前节点结算以我为终点能凑几条。6. 顺着 437 把路径类题目串起来复习6.1 数组版同款560. 和为 K 的子数组437 和 560 是同一个套路在树和数组上的两种形态。560 的代码甚至短得离谱class Solution: def subarraySum(self, nums: List[int], k: int) - int: prefix {0: 1} ans cur 0 for x in nums: cur x ans prefix.get(cur - k, 0) prefix[cur] prefix.get(cur, 0) 1 return ans区别只有一个数组是顺序遍历不需要回溯树是 DFS必须在递归返回时把前缀计数减掉。如果你 560 刷通了再来看 437会发现 437 无非是给 DFS 加了个回溯而已反过来先刷 437 再刷 560数组版会觉得异常简单。我建议这两题放到同一天做前后对照效果最好。另外前缀和这个套路在周赛里出现频率很高不管套在数组、二叉树还是字典树上核心都是 cur - target 查表熟悉了之后一眼就能认出来。6.2 面试作答顺序先暴力讲清思路再前缀和讲清优化如果面试考到 437我建议的作答节奏是先花两分钟说暴力怎么做——外层枚举起点、内层向下 DFSO(n²)然后指出问题在于每条路径的起点终点被重复枚举顺势引出前缀和。这样不仅展示了你对题目深度的理解还给了面试官一个清晰的复杂度演进。直接把最优解拍上去当然也行但少了对照说服力会弱一截。至于 124. 二叉树中的最大路径和它虽然也是任意起点、任意终点但问的是最大值而不是计数解法变成了后序遍历返回子树贡献值跟前缀和不是一个套路。别把所有任意路径题目都按前缀和硬套先看清楚是计数、存在性还是最值。6.3 复习顺序建议我给自己定的复习路线是112 → 113 → 437 → 560 → 124。前两题练根到叶子的递归基本功437 引入前缀和和回溯560 把同一套路切到数组验证理解124 再升级到任意路径最值的树形 DP。这条线走完二叉树路径类的核心考法基本全覆盖了。最后说一个我自己的小习惯每刷完这类前缀和 树的题我会顺手把 map 初始化和回溯这两行单独抄进错题本旁边标注它们的报错症状——一个是答案整体偏小根起点路径全部丢失一个是答案莫名偏大出现结构上不存在的路径。下次再遇到任何树上前缀和的变体题先检查这两行能省下大量调试时间。