bigywater.tech
算法与数据结构 · LeetCode 113

LeetCode 113. 路径总和 II

给定一棵二叉树和一个目标和,找出所有从根到叶子、路径和恰好等于目标值的路径。标准写法用一个共享的可变列表做 DFS 回溯:进入结点追加值,返回前弹出值。一个经典陷阱是:找到合法路径时,直接把这个共享列表存进结果,而不是拷贝一份——由于存的是同一个对象的引用,后续回溯的弹出操作会直接把"已经存好"的结果也跟着清空。本文用真实反例演示这个错误(两条本该有内容的路径全部变成空列表),给出正确写法、5 种语言实现,以及共享列表回溯的分步演示。

[[],[]]
陷阱写法在官方示例上,把本该是两条具体路径的结果,全部变成了空列表
4 步
分步演示:共享 path 列表一路追加到叶子,记录结果时拷贝一份,回溯时逐个弹出,另附 5 种语言实现

题意与示例

本文只用自己的话概括题意,完整约束以官方页面为准。给定一棵二叉树和目标和,找出所有从根到叶子、路径和恰好等于目标值的路径,返回这些路径本身(每条路径是一个结点值的列表)。一共 5 组用例,覆盖:官方示例(两条合法路径)、无解、和为 0 的边界、单结点、负数:

真实实测 · 本机执行输出
[5,4,8,11,null,13,4,7,2,null,null,5,1] target=22  →  [[5, 4, 11, 2], [5, 8, 4, 5]]
[1,2,3] target=5  →  []
[1,2] target=0  →  []
[1] target=1  →  [[1]]
[-2,null,-3] target=-5  →  [[-2, -3]]

陷阱:记录结果时直接存共享列表的引用,回溯会把已存的结果一起清空

标准写法用一个共享的可变列表 path 做回溯:递归进入时往里追加当前结点的值,找到一条合法路径就把 path "存"进结果列表,递归返回前再把最后一个值弹出,好让 path 复用给下一条路径。这里有一个极容易踩的坑:往结果里存的时候,如果直接写 result.append(path),存进去的并不是路径内容的快照,而是对同一个列表对象的引用——后续的 pop() 和 append() 会直接改到已经"存好"的那些结果上,因为它们全都指向同一块内存:

真实实测 · 本机执行输出
输入官方示例,target=22(正确答案有两条路径)\n正确写法(result.append(path[:]) 拷贝一份)  →  [[5, 4, 11, 2], [5, 8, 4, 5]]\n陷阱写法(result.append(path) 直接存引用)    →  [[], []]

陷阱写法的结果触目惊心:两条路径的内容全部变成了空列表。原因是:整个 DFS 遍历完所有分支之后,path 通过一路回溯的 pop() 最终会被清空回初始的空列表;而 result 里存的两个"结果"其实都是对同一个 path 对象的引用,遍历结束时它们看到的都是这同一个、已经被清空的列表,而不是记录当时那一刻的路径内容。这是 Java 的 ArrayList、JavaScript 的数组同样会踩的坑——任何用同一个可变容器做回溯、又不在"记录结果"这一步做深拷贝的写法,都会有这个问题。

DFS 回溯 + 记录时拷贝:五种语言的实现

DFS 回溯:递归进入一个结点就把它的值追加到共享的 path,剩余目标值减去当前值;如果走到叶子且剩余目标值归零,就把 path 的一份拷贝存进结果;否则继续往左右子树递归;不管走哪条分支,递归返回前都要把刚才追加的值弹出(回溯),让 path 恢复成进入这个结点之前的样子,供其他分支复用。时间 O(n²)(最坏情况每条路径都要拷贝一次,路径总长度是 O(n²)),空间 O(n)。Python、Java、Go、JavaScript、C++ 都是真实编译运行的,用同一组 5 个测试用例。

Python · 解法代码def path_sum(root, target): result = [] path = [] def dfs(node, remain): if node is None: return path.append(node.val) remain -= node.val if node.left is None and node.right is None and remain == 0: result.append(path[:]) # 关键:拷贝一份,不能直接存 path 这个引用 else: dfs(node.left, remain) dfs(node.right, remain) path.pop() # 回溯:撤销这一步,供下一条路径复用 dfs(root, target) return result
真实实测 · Python · 前几组用例的输出
$ python3 solution.py
[5,4,8,11,null,13,4,7,2,null,null,5,1] target=22  →  [[5, 4, 11, 2], [5, 8, 4, 5]]
[1,2,3] target=5  →  []
[1,2] target=0  →  []
[1] target=1  →  [[1]]
[-2,null,-3] target=-5  →  [[-2, -3]]
Java · 解法代码class Solution { private List<List<Integer>> result = new ArrayList<>(); private List<Integer> path = new ArrayList<>(); public List<List<Integer>> pathSum(TreeNode root, int targetSum) { dfs(root, targetSum); return result; } private void dfs(TreeNode node, int remain) { if (node == null) return; path.add(node.val); remain -= node.val; if (node.left == null && node.right == null && remain == 0) { result.add(new ArrayList<>(path)); // 关键:拷贝一份,不能直接存 path 这个引用 } else { dfs(node.left, remain); dfs(node.right, remain); } path.remove(path.size() - 1); // 回溯 } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java && java -cp build Main
[5,4,8,11,null,13,4,7,2,null,null,5,1] target=22  →  [[5, 4, 11, 2], [5, 8, 4, 5]]
[1,2,3] target=5  →  []
[1,2] target=0  →  []
[1] target=1  →  [[1]]
[-2,null,-3] target=-5  →  [[-2, -3]]
Go · 解法代码func pathSum(root *TreeNode, targetSum int) [][]int { result := [][]int{} path := []int{} var dfs func(node *TreeNode, remain int) dfs = func(node *TreeNode, remain int) { if node == nil { return } path = append(path, node.Val) remain -= node.Val if node.Left == nil && node.Right == nil && remain == 0 { cp := make([]int, len(path)) // 关键:拷贝一份,不能直接存 path 这个底层数组的切片 copy(cp, path) result = append(result, cp) } else { dfs(node.Left, remain) dfs(node.Right, remain) } path = path[:len(path)-1] // 回溯 } dfs(root, targetSum) return result }
真实实测 · Go · 前几组用例的输出
$ go run solution.go
[5,4,8,11,null,13,4,7,2,null,null,5,1] target=22  →  [[5, 4, 11, 2], [5, 8, 4, 5]]
[1,2,3] target=5  →  []
[1,2] target=0  →  []
[1] target=1  →  [[1]]
[-2,null,-3] target=-5  →  [[-2, -3]]
JavaScript · 解法代码function pathSum(root, targetSum) { const result = []; const path = []; function dfs(node, remain) { if (node === null) return; path.push(node.val); remain -= node.val; if (node.left === null && node.right === null && remain === 0) { result.push([...path]); // 关键:拷贝一份,不能直接存 path 这个引用 } else { dfs(node.left, remain); dfs(node.right, remain); } path.pop(); // 回溯 } dfs(root, targetSum); return result; }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js
[5,4,8,11,null,13,4,7,2,null,null,5,1] target=22  →  [[5, 4, 11, 2], [5, 8, 4, 5]]
[1,2,3] target=5  →  []
[1,2] target=0  →  []
[1] target=1  →  [[1]]
[-2,null,-3] target=-5  →  [[-2, -3]]
C++ · 解法代码class Solution { public: vector<vector<int>> pathSum(TreeNode* root, int targetSum) { vector<int> path; dfs(root, targetSum, path); return result; } private: vector<vector<int>> result; void dfs(TreeNode* node, int remain, vector<int>& path) { if (!node) return; path.push_back(node->val); remain -= node->val; if (!node->left && !node->right && remain == 0) { result.push_back(path); // vector 的拷贝赋值本身就会复制一份,不会和 path 共享 } else { dfs(node->left, remain, path); dfs(node->right, remain, path); } path.pop_back(); // 回溯 } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin
[5,4,8,11,null,13,4,7,2,null,null,5,1] target=22  →  [[5, 4, 11, 2], [5, 8, 4, 5]]
[1,2,3] target=5  →  []
[1,2] target=0  →  []
[1] target=1  →  [[1]]
[-2,null,-3] target=-5  →  [[-2, -3]]
真实实测 · 本机执行输出
共 5 组用例(含边界),以 Python 结果为基准逐组比对:\n  Python      通过 5/5\n  Java        通过 5/5\n  Go          通过 5/5\n  JavaScript  通过 5/5\n  C++         通过 5/5

随机对拍

Python 版本的"共享列表 + 回溯拷贝"实现另外和一个完全不同风格的写法做了随机对拍:函数式写法每次递归都用 path + [node.val] 新建一个列表,不共享任何可变状态,天然不会有别名问题。

真实实测 · 本机执行输出
随机对拍 20,000 组(target 特意选成某条根到结点路径的和),回溯拷贝版与每次新建列表的函数式版结果全部一致,0 处不一致。

另外四种语言只在上面 5 组固定用例上验证过,没有做大规模随机对拍。

分步演示:共享 path 列表做回溯,记录结果时必须拷贝

下面的演示用官方示例(目标和 22)的一条分支:沿着 5→4→11→2 一路把值追加进共享的 path 列表,到叶子 2 时剩余目标恰好归零,记录这条路径;随后递归返回,沿路把刚才追加的值逐个弹出(回溯),让 path 恢复原样去探索下一条分支。蓝色是刚追加进 path 的值,绿色是已经回溯弹出的值。这正是"记录结果时要不要拷贝"这个陷阱发生的地方——如果记录时没有拷贝,最后一步的回溯会直接把"已经存好"的路径也清空。

路径总和 II:共享 path 列表做回溯,记录结果时必须拷贝
刚追加进 path已回溯弹出

    参考与说明

    • 官方题目页:LeetCode 113. 路径总和 II。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚。
    • 没有覆盖:每次新建列表、不共享可变状态的函数式写法作为主解法展示(本文用它做了随机对拍验证,但会带来更多次列表拷贝的开销)。
    京ICP备2026067502号-1京公网安备11010502063487号