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

资讯详情

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

路径总和 III:从二叉树DFS到前缀和哈希表优化

路径总和 III:从二叉树DFS到前缀和哈希表优化 如果你刷过LeetCode热题100大概率会有这样一种感觉很多题你能一眼看出解法但偏偏有那么几道题你明明看懂了题解下一次遇到还是会卡壳。路径总和 III 就是典型代表。它表面上是“路径总和”系列的一个成员可当你真正动手做的时候才会发现前面的 112 和 113 只是热身这一题直接把二叉树递归的层次拉高了一个档位——路径不要求从根开始也不要求在叶子结束甚至连“连续向下”这个约束都藏在题目描述里需要你自己品。我在刷这题的时候前后卡了两个晚上。第一晚写了个暴力递归跑通了但总觉得哪里不对第二晚对着前缀和的思路理清了回溯的细节后才敢说自己真的懂这题了。这篇就把我踩过的坑、梳理过的思路、以及代码实现的原原本本写出来。先说清楚这题是什么给你一棵二叉树的根节点 root 和一个整数 targetSum要求返回二叉树中节点值之和等于 targetSum 的路径数目。路径不需要从根节点开始也不需要在叶子节点结束但必须是向下走的也就是从父节点到子节点。这类题在面试中出现频率极高尤其是微软、字节、阿里这种喜欢在二叉树问题上做文章的公司往往会把这题作为动态规划、前缀和、递归回溯的复合考法。无论你是准备校招、社招还是单纯想补算法短板这题都值得仔细啃一遍。1. 题目到底在问什么先把手伸进例子里面1.1 一句话版题面给定一棵二叉树每个节点都有一个整数值可能是正数、负数或零再给一个目标值 targetSum你需要数一数这棵树里一共有多少条“从上往下的连续路径”使得路径上所有节点的值加起来等于 targetSum。这里的路径可以只包含一个节点也可以从任意节点出发一路往下走到任意节点结束。1.2 通过样例理解“路径”的限制我们看一个最简单的样例如果二叉树是[10,5,-3,3,2,null,11,3,-2,null,1]targetSum 为 8那么答案是 3。这三条路径分别是5 - 3、5 - 2 - 1、-3 - 11。注意这里有个很容易被忽略的点10 - -3 - 11这条路径虽然和也是 8但它不合法因为路径必须是从某个节点出发一路向下到某个节点而10 - -3 - 11在树的结构上并不是连续的向下路径——-3 的左孩子是 11 没错但 10 到 -3 之后-3 并不是 10 的左孩子或右孩子连接下去的它们只是祖先和后代的关系里隔着层级如果把树展开看10的左孩子才是5右孩子是-3而-3的孩子是11所以实际上从 10 出发走到 -3 再走到 11在树结构里是“10 到右孩子 -3再到 -3 的左孩子 11”这就是连续向下的。那为什么这条路径不算因为 10 加 -3 加 11 的和是 18不是 8所以不符合。我举这个例子是想说你看题的时候必须把“向下”和“连续”这两个词刻在脑子里很多错误的解法就是在这里翻车的。1.3 这题为什么属于“热题100”而不是简单题LeetCode 热题 100 里收录了路径总和系列的三道题112 判断是否存在路径、113 返回所有路径、437 统计路径数量。前两题只要一个 DFS 或者回溯就能搞定但第三题之所以是中等偏上的难度在于它把“统计数量”和“任意起点”两个条件叠在了一起。路径数量意味着你需要在递归过程中不断累加结果而任意起点意味着你不能只从根节点开始做一次 DFS。很多人第一反应是“那我让每个节点都当一次起点往下跑 DFS 不就行了”这确实是一个可行方案也就是双重递归时间复杂度 O(n^2)。但如果你遇到的是极端情况——比如一棵链状树n 达到 10^4 甚至 10^5O(n^2) 就很可能超时。所以热题100把它放在比较靠后的位置实际上是在暗示你这道题值得你用更优的解法来做。2. 解法一双重递归把“向下走”翻译成代码2.1 为什么第一个想到 DFS对于二叉树路径问题第一反应永远是 DFS。因为路径天然就是沿着树的分支走的深度优先搜索可以让我们递归地处理每一个子树。如果你做过 112 和 113你会发现这两题都是从一个起点根节点出发沿着一条路径累加然后判断是否等于目标值。而 437 的不同点在于“起点也可以是任意节点”于是很自然的思路就出来了外面套一层递归遍历每一个节点里面再套一层递归以当前节点为起点向下寻找所有可能的路径。2.2 双重递归的核心以每个节点为起点外层递归就相当于是树的遍历可以是前序、中序、后序无所谓反正每个节点都会被访问一次。内层递归则是从当前节点出发把它当成路径的起点然后往左右子树走每走一步就把当前路径的和累加起来如果累加和等于 targetSum计数器加一。这里有一个关键点即使某条路径的和已经等于 targetSum也不能停下来因为节点值可能是负数继续往下走还有可能再次等于 targetSum。举个例子targetSum 1路径是1 - -1 - 1那么1是合法的1 - -1 - 1也是合法的一条路径上可以出现多个满足条件的子路径这一点在初学的时候很容易漏掉。2.3 时间复杂度与空间复杂度分析这个解法的时间复杂度是 O(n^2)。外层递归访问 n 个节点每个节点作为起点时内层递归在最坏情况下会访问从该节点到叶子节点的所有节点。如果树是平衡的每个节点作为起点的路径长度约为 O(log n)总复杂度约为 O(n log n)但如果树退化成一个链表每个节点作为起点都要往下走 O(n) 层总复杂度就会退化到 O(n^2)。空间复杂度方面递归深度取决于树的高度最坏情况下 O(n)平衡情况下 O(log n)。2.4 直接写代码示例我用 Python 写一个清晰版本方便逐行解释class Solution: def pathSum(self, root: TreeNode, targetSum: int) - int: if not root: return 0 # 外层递归以每个节点作为路径起点 return self.dfs(root, targetSum) \ self.pathSum(root.left, targetSum) \ self.pathSum(root.right, targetSum) def dfs(self, node: TreeNode, targetSum: int) - int: if not node: return 0 # 内层递归统计从当前节点出发有多少条路径和为 targetSum count 0 if node.val targetSum: count 1 count self.dfs(node.left, targetSum - node.val) count self.dfs(node.right, targetSum - node.val) return count注意这里的技巧内层递归的targetSum参数每次传的是targetSum - node.val这样就不用额外维护一个currentSum变量了。判断条件变成node.val targetSum其实就是在检查“从起点到当前节点的路径和”是否等于原始目标值。这个写法很常见而且不容易出错。class Solution { public int pathSum(TreeNode root, int targetSum) { if (root null) return 0; return dfs(root, targetSum) pathSum(root.left, targetSum) pathSum(root.right, targetSum); } private int dfs(TreeNode node, long targetSum) { if (node null) return 0; int count 0; if (node.val targetSum) count; count dfs(node.left, targetSum - node.val); count dfs(node.right, targetSum - node.val); return count; } }上面 Java 版本我把targetSum定义成long原因后面会专门说这里先留个印象。2.5 这个解法的坑双重递归最大的坑在于重复计算。外层递归遍历每个节点时内层递归会重新计算从该节点出发的所有路径这导致大量节点被重复访问。虽然代码看起来简短但性能隐患非常大。还有一个细节内层递归里判断node.val targetSum时加的是 1而不是return 1原因就是前面说的路径可以继续往下延伸不能提前终止。另外很多初学者会在外层递归里写if root.val targetSum { res }然后在左右子树递归这个思路是错的。因为这样只统计了以根节点为起点的路径而没有统计以其他节点为起点的路径同时它也不能处理“路径从某个节点的子节点出发”的情况。正确做法是像上面一样把root的 DFS 结果和左右子树的pathSum结果加起来。3. 解法二前缀和 哈希表把复杂度降到 O(n)3.1 从“两数之和”到“路径总和 III”如果你做过 LeetCode 1 两数之和你会记得一个经典优化用哈希表记录“已经出现过的值”然后在遍历过程中查找“目标值减当前值”是否出现过。路径总和 III 的最优解思路与之类似只不过这里需要记录的不是某个值而是“从根节点到当前节点的路径前缀和”。为什么会想到前缀和因为我们要求的是“任意起点、任意终点”的路径和等价于求“两个节点之间的路径和”而任意两个节点之间的路径和恰好可以用两个前缀和相减得到。3.2 前缀和定义与前缀差定义preSum(node)表示从根节点到当前节点路径上所有节点值之和。那么对于任意两个节点 u 和 vv 是 u 的后代从 u 到 v 的路径和就等于preSum(v) - preSum(parent(u))。换句话说如果我们遍历到了节点 v想要知道有多少条以 v 结尾的合法路径只需要看前面出现过多少个前缀和等于preSum(v) - targetSum。每出现一个就说明有一条从历史某个起点到 v 的路径和为 targetSum。用生活化的类比来解释你在一家奶茶店打卡每买一杯奶茶店员会在你的会员卡上累计一个金额。今天你想知道自己从哪一次消费开始到现在累计消费刚好达到了 168 元。你只需要看会员卡上的历史累计金额记录找一找有没有“当前累计金额 - 168”这个数出现了几次就说明有几段连续消费刚好是 168 元。这里的“历史累计金额”就是前缀和而“找有没有这个差值”就是哈希表查询。3.3 哈希表为什么存的是前缀和的出现次数我们用一个字典mapkey 是前缀和的值value 是这个前缀和出现的次数。每次遍历到新节点先计算当前前缀和curSum然后查一下curSum - targetSum在字典里出现了几次累加到答案里。之后再把curSum的计数加一继续遍历左右子树。这里有个很容易想不通的点为什么 value 是“次数”而不是“是否出现过”因为二叉树中可能存在多条不同的路径拥有相同的前缀和。比如 targetSum 0 时如果两条不同的路径前缀和相等那么它们的差就是 0就对应两条不同的零和路径。如果 value 只是布尔值你会漏掉很多合法路径。所以必须记录出现次数。3.4 回溯关键是“用完即弃”前缀和解法最大的难点不是前缀和本身而是回溯。由于我们在 DFS 过程中是全局共享同一个字典的当遍历完左子树回到当前节点准备进入右子树时左子树中产生的前缀和记录不能继续保留否则会污染右子树的计算结果。也就是说每次一个节点处理完要把它的前缀和计数减一这样它就不会影响到父节点的其他分支。这个操作和回溯算法里的“撤销选择”是完全一致的。很多同学在写这道题的时候前缀和逻辑写对了但答案就是不对十有八九是忘了在递归返回后把字典还原。你可以把每个节点的前缀和看成是一张“临时通行证”进子树时带上它出子树时就必须把它销毁否则后面的人会拿着别人的通行证混进来。3.5 实现代码与逐步解释from collections import defaultdict class Solution: def pathSum(self, root: TreeNode, targetSum: int) - int: # prefix_count 记录前缀和出现的次数 prefix_count defaultdict(int) # 初始状态前缀和为0的情况出现一次 prefix_count[0] 1 self.res 0 self.targetSum targetSum def dfs(node: TreeNode, curSum: int): if not node: return curSum node.val # 查找有多少个历史前缀和能与当前前缀和形成 targetSum self.res prefix_count.get(curSum - self.targetSum, 0) # 将当前前缀和加入哈希表 prefix_count[curSum] 1 # 遍历左右子树 dfs(node.left, curSum) dfs(node.right, curSum) # 回溯撤销当前前缀和的记录 prefix_count[curSum] - 1 dfs(root, 0) return self.res这里我用了defaultdict(int)方便直接对不存在的 key 做加一操作。注意dfs(root, 0)是最开始的调用curSum初始为 0而prefix_count[0] 1是为了处理“整条路径从根节点开始”的情况。如果 targetSum 恰好等于从根到某个节点的和那么curSum - targetSum就等于 0此时字典里的 0 就能匹配上。再看一个 C 版本方便熟悉 STL 的读者class Solution { public: unordered_maplong long, int prefix; int target; int res 0; int pathSum(TreeNode* root, int targetSum) { target targetSum; prefix[0] 1; dfs(root, 0); return res; } void dfs(TreeNode* node, long long curSum) { if (!node) return; curSum node-val; if (prefix.count(curSum - target)) { res prefix[curSum - target]; } prefix[curSum]; dfs(node-left, curSum); dfs(node-right, curSum); prefix[curSum]--; } };C 里需要注意curSum - target的查找逻辑prefix.count只是判断 key 是否存在但我们需要的是出现次数所以直接res prefix[curSum - target]即可。这里如果把curSum定义成long long是因为节点值范围是[-10^9, 10^9]一棵链状树的节点数可达 10^4累加和可能超过 int 的范围。用long long是最保险的选择。4. 两种解法的对比与现场选型4.1 从面试官视角看两种解法的得分差异如果你在面试中遇到这题先写出双重递归的暴力解其实是可以拿到基础分的。这说明你具备基本的递归思维知道枚举起点能写出 DFS。但如果你能进一步说“这个解法在最坏情况下是 O(n^2)我可以用前缀和方式优化到 O(n)”然后顺利写出前缀和版本面试官对你这道题的评价会直接上一个档次。因为前缀和 回溯的组合在二叉树问题中是非常经典的考察点能够熟练写出来说明你对“树的遍历”和“哈希表优化”都有深入的理解。我个人建议面试时这样演先快速给出双重递归版本同时主动说它的时间复杂度是 O(n^2)并指出瓶颈然后说“如果数据规模很大可以用前缀和优化”再写前缀和版本。这样既展示了你的思考过程又体现了优化能力而且就算前缀和版本写崩了你还有兜底的双重递归可以救场。4.2 边界条件与特殊场景验证不管是哪种解法空树都是第一个要处理的边界情况。双重递归里if not root: return 0这句不要省前缀和版本里dfs函数的if not node: return也不能丢。还有一个容易被忽略的场景当 targetSum 为 0 时路径之间可能出现重叠计数的问题。比如树只有一个节点值是 0那么答案是 1。双重递归版本中外层pathSum会调用dfs(root, 0)dfs里判断node.val 0加 1然后左右子树递归左右子树为空返回 0所以答案是 1没问题。前缀和版本中初始prefix_count[0] 1遍历到根节点时curSum 0查curSum - targetSum 0字典里已经有一个 0所以结果加 1也只是 1不会重复计算。但如果你在前缀和版本里把prefix_count[0] 1漏了那 targetSum 0 时所有从根节点出发且路径和为 0 的路径都会被漏掉因为你会拿curSum - 0去查字典而字典里还没有 0 这个前缀和。这是一个非常隐蔽的 bug。4.3 数据规模与性能实测对比表树形态节点数双重递归约前缀和约平衡二叉树10^410^4 * log10^4 ≈ 1.3 * 10^5 次递归调用10^4 次递归调用链状树10^410^8 次递归调用超时10^4 次递归调用完全二叉树10^510^5 * 17 ≈ 1.7 * 10^6 次递归调用10^5 次递归调用极端链表10^510^10 次递归调用严重超时10^5 次递归调用从表里可以看出当树趋于不平衡时双重递归的性能会急剧恶化。实际刷题时LeetCode 的数据范围通常是节点数不超过 10^4双重递归在 Python 下勉强能过但如果你用 Java 或 C时间卡得紧一点就可能超时。所以最优解必须是前缀和版本。5. 常踩的坑与排错实录5.1 递归深度过大导致栈溢出在链状树的情况下DFS 的递归深度可能达到 10^4 甚至 10^5。Python 默认递归深度限制是 1000 左右所以如果你的树特别深直接写递归会报RecursionError。这时候有两个办法一是用sys.setrecursionlimit(1000000)临时调大递归深度二是改成迭代式的前序遍历加显式栈。面试时通常不需要处理这种极端情况但刷题时如果遇到超时或栈溢出要能想到这个原因。我在本地测试的时候就曾经因为忘记调大递归深度在一棵 2000 层的链表式树上直接崩溃。5.2 前缀和计数污染兄弟子树互相干扰这是前缀和解法中最常见的问题。假设你在节点 A 计算出前缀和 10然后进入左子树 BB 的子树中又出现了前缀和 10你在字典里把这个计数加到了 2。等你从 B 返回进入右子树 C 时C 中如果出现前缀和 10它会把 B 子树留下的计数也算进去导致结果偏大。解决办法就是在 dfs 返回前把当前节点的前缀和计数减一。这个操作必须放在左右子树递归完成之后、函数 return 之前。我用一个简单的例子验证一下根节点的左子树有一条路径前缀和为 5根节点的右子树也有一条路径但这两条路径本来没有任何交集。如果没有回溯右子树的路径会错误地把左子树的前缀和计数也算进去从而多算答案。5.3 节点值为负数时不能提前剪枝很多受 112 题影响的同学会想当然地写一句if curSum targetSum: return这在全是正数的树里是正确的优化但本题的节点值可能是负数。比如 targetSum -1当前路径和为 2继续往下走到一个值为 -3 的节点路径和变成 -1正好符合条件。如果你在路径和为 2 的时候就剪枝这条合法路径就丢了。所以无论是双重递归还是前缀和版本都不要做基于“当前和是否大于目标值”的剪枝判断。5.4 双重递归中的重复计数双重递归很容易出现的问题是外层递归和内层递归的计数逻辑混在一起导致同一个路径被统计多次。比如你把外层递归写成if root.val targetSum: res 1然后在内层递归里又对同样的路径计数就可能会发现结果刚好翻倍。为了避免这种问题我建议严格遵循“外层只负责枚举起点内层只负责统计从该起点出发的路径数”的分工不要在外面多做判断。等你把双重递归跑通了再去看前缀和版本会更容易理解两者在计数逻辑上的本质区别。5.5 哈希表使用中的类型陷阱在 C 里如果把前缀和定义为int累加过程中一旦超过 2^31 - 1就会发生整数溢出变成一个很大的负数从而导致哈希表查找结果出错。在 Java 里同样有这个问题所以我把curSum和targetSum都定义成了long。Python 没有这个烦恼因为它的 int 是无限精度的。但如果你用 C、Java 或 Go建议一律使用 64 位整数类型。这是很多人在大数测试用例上会踩的坑而且报错结果非常隐蔽因为答案不是报异常而是算错。5.6 调试技巧打印前缀和和计数如果你确认逻辑没问题但答案不对我建议在 dfs 入口处打印node.val、curSum和prefix_count的内容用一个小规模的测试用例跑一遍观察每个节点进入和退出时哈希表的变化。通常你会很快发现问题出在“某个节点退出后计数没有减回去”或者“初始的 0 前缀和没有设置”。这个方法虽然听起来笨但比盲猜要高效得多。我在写前缀和版本时就是靠打印发现左子树污染了右子树的计数才真正理解了回溯的意义。我后来在刷完这题之后又去翻了一下 LeetCode 上其他人的题解发现很多人会在双重递归和前缀和之间反复摇摆核心原因都是没有理清“任意起点”这个条件。其实只要记住一句话一条路径总有一个终点以终点为切入点前缀和之差可以描述任意起点用回溯保证每条路径只在本分支内生效。思路通了代码自然就顺了。这道题非常适合拿来训练递归到优化的思维跨度也值得你反复刷两三遍。如果你能一次 AC 前缀和版本说明你的树形 DFS 已经过了最难的坎。
返回列表