1. 题目解析与解题思路
LeetCode 112题"路径总和"是一道经典的二叉树遍历问题。题目要求我们判断在给定的二叉树中,是否存在一条从根节点到叶子节点的路径,使得路径上所有节点值之和等于给定的目标值。
这道题看似简单,但考察了以下几个核心知识点:
- 二叉树的遍历方式(深度优先/广度优先)
- 递归算法的设计与实现
- 边界条件的处理能力
- 树结构的基本操作
1.1 题目具体要求
给定一个二叉树的根节点root和一个表示目标和的整数targetSum,如果树中存在从根节点到叶子节点的路径,且路径上所有节点值相加等于目标和,返回true;否则返回false。
叶子节点是指没有子节点的节点。
示例:
输入:root = [5,4,8,11,null,13,4,7,2,null,null,null,1], targetSum = 22
输出:true
解释:存在路径 5→4→11→2,其和为22。
1.2 解题思路分析
解决这个问题主要有两种思路:
-
递归深度优先搜索(DFS)
- 从根节点开始递归遍历
- 每次递归时,用当前目标值减去当前节点的值
- 当到达叶子节点时,检查剩余目标值是否为0
- 时间复杂度O(n),空间复杂度O(h),h为树的高度
-
迭代广度优先搜索(BFS)
- 使用队列同时保存节点和当前剩余的目标值
- 每次处理节点时,检查是否为叶子节点且剩余值等于节点值
- 时间复杂度O(n),空间复杂度O(n)
对于面试和机考场景,递归解法通常是首选,因为它代码简洁,能很好地展示对递归的理解。下面我们将重点分析递归解法。
2. 递归解法实现详解
2.1 基础递归实现
递归解法的核心思想是:从根节点开始,每次递归处理一个节点,将目标值减去当前节点的值,然后递归处理左右子树。当到达叶子节点时,检查剩余的目标值是否为0。
python复制def hasPathSum(root, targetSum):
if not root:
return False
if not root.left and not root.right: # 当前是叶子节点
return targetSum == root.val
# 递归处理左右子树
return hasPathSum(root.left, targetSum - root.val) or hasPathSum(root.right, targetSum - root.val)
2.2 递归过程解析
让我们通过一个具体例子来理解递归过程:
code复制 5
/ \
4 8
/ / \
11 13 4
/ \ \
7 2 1
目标sum = 22
递归调用栈:
- hasPathSum(5,22)
- 不是叶子节点,递归左子树hasPathSum(4,17)
- hasPathSum(4,17)
- 不是叶子节点,递归左子树hasPathSum(11,13)
- hasPathSum(11,13)
- 不是叶子节点,递归左子树hasPathSum(7,2)
- hasPathSum(7,2)
- 是叶子节点,7==2? False
- 返回False
- 回到hasPathSum(11,13),尝试右子树hasPathSum(2,2)
- hasPathSum(2,2)
- 是叶子节点,2==2? True
- 返回True
最终结果为True,因为路径5→4→11→2的和为22。
2.3 递归终止条件
递归解法中有两个关键终止条件:
- 当前节点为空:返回False
- 当前节点是叶子节点:检查剩余sum是否等于节点值
注意:必须先检查节点是否为空,再检查是否为叶子节点,否则会引发空指针异常。
3. 迭代解法实现详解
虽然递归解法简洁,但在实际工程中,迭代解法可能更受欢迎,因为它避免了递归带来的栈溢出风险,特别是对于深度很大的树。
3.1 使用栈的DFS迭代实现
python复制def hasPathSum(root, targetSum):
if not root:
return False
stack = [(root, targetSum - root.val)]
while stack:
node, curr_sum = stack.pop()
if not node.left and not node.right and curr_sum == 0:
return True
if node.right:
stack.append((node.right, curr_sum - node.right.val))
if node.left:
stack.append((node.left, curr_sum - node.left.val))
return False
3.2 使用队列的BFS迭代实现
python复制from collections import deque
def hasPathSum(root, targetSum):
if not root:
return False
queue = deque([(root, targetSum - root.val)])
while queue:
node, curr_sum = queue.popleft()
if not node.left and not node.right and curr_sum == 0:
return True
if node.left:
queue.append((node.left, curr_sum - node.left.val))
if node.right:
queue.append((node.right, curr_sum - node.right.val))
return False
3.3 迭代与递归的选择
选择迭代还是递归取决于具体场景:
- 面试/笔试:优先考虑递归,代码简洁
- 工程实践:深度大的树考虑迭代
- 性能要求:两者时间复杂度相同,但迭代可以控制内存使用
4. 边界条件与常见错误
4.1 特殊输入处理
-
空树情况:
- 当root为None时,无论targetSum是多少都应返回False
- 这是很多同学容易遗漏的边界条件
-
单节点树:
- 树只有一个根节点时,直接比较根节点值和targetSum
-
负数节点值:
- 题目没有限制节点值的范围,可能包含负数
- 这意味着不能通过提前终止来优化(如当前sum已超过target)
4.2 常见错误示例
错误1:忘记处理空树情况
python复制def hasPathSum(root, targetSum):
if not root.left and not root.right: # 直接访问root.left可能导致异常
return targetSum == root.val
# ...
错误2:错误判断叶子节点
python复制def hasPathSum(root, targetSum):
if not root:
return targetSum == 0 # 错误!空树且targetSum=0时应返回False
# ...
错误3:修改了原始targetSum
python复制def hasPathSum(root, targetSum):
if not root:
return False
targetSum -= root.val # 修改了原始参数,可能影响后续调用
# ...
5. 算法优化与变种问题
5.1 可能的优化方向
-
提前终止:
- 如果题目保证所有节点值为正数,可以在当前sum>target时提前返回False
- 但LeetCode原题没有这个限制,所以不能使用
-
记忆化搜索:
- 对于普通二叉树意义不大
- 如果是有向无环图(DAG)可以考虑
-
并行处理:
- 对于非常大的树,可以考虑并行处理左右子树
5.2 相关变种题目
-
路径总和II(LeetCode 113):
- 找出所有满足条件的路径,而不仅仅是判断是否存在
-
路径总和III(LeetCode 437):
- 路径不需要从根节点开始,也不需要在叶子节点结束
-
二叉树的最大路径和(LeetCode 124):
- 路径可以从任意节点开始到任意节点结束
- 求路径和的最大值
-
求所有根到叶子的路径(LeetCode 257):
- 返回所有从根到叶子的路径字符串表示
6. 实战技巧与面试要点
6.1 面试中的回答策略
-
先明确问题:
- 确认输入输出要求
- 询问节点值范围、树的大小限制等
-
提出多种解法:
- 先给出递归解法
- 然后讨论迭代解法
- 比较时间/空间复杂度
-
处理边界条件:
- 主动讨论空树、单节点树等情况
- 展示全面的思考
-
代码风格:
- 使用有意义的变量名
- 添加必要的注释
- 保持代码整洁
6.2 调试技巧
-
小例子测试:
- 构造简单的二叉树手动验证
- 如单节点树、只有左子树的树等
-
打印调试:
- 在递归中打印当前节点和剩余sum
- 帮助理解递归过程
-
可视化工具:
- 使用二叉树可视化工具观察结构
- 如LeetCode的二叉树可视化功能
6.3 复杂度分析
-
时间复杂度:
- 最坏情况下需要访问所有节点:O(n)
- n为树中节点数量
-
空间复杂度:
- 递归:O(h),h为树的高度(递归栈深度)
- 迭代:O(n),最坏情况下需要存储所有节点
7. 不同语言实现示例
7.1 Java实现
java复制// 递归解法
class Solution {
public boolean hasPathSum(TreeNode root, int targetSum) {
if (root == null) return false;
if (root.left == null && root.right == null) return targetSum == root.val;
return hasPathSum(root.left, targetSum - root.val) || hasPathSum(root.right, targetSum - root.val);
}
}
// 迭代解法
class Solution {
public boolean hasPathSum(TreeNode root, int targetSum) {
if (root == null) return false;
Stack<TreeNode> nodeStack = new Stack<>();
Stack<Integer> sumStack = new Stack<>();
nodeStack.push(root);
sumStack.push(targetSum - root.val);
while (!nodeStack.isEmpty()) {
TreeNode node = nodeStack.pop();
int currSum = sumStack.pop();
if (node.left == null && node.right == null && currSum == 0) {
return true;
}
if (node.right != null) {
nodeStack.push(node.right);
sumStack.push(currSum - node.right.val);
}
if (node.left != null) {
nodeStack.push(node.left);
sumStack.push(currSum - node.left.val);
}
}
return false;
}
}
7.2 C++实现
cpp复制// 递归解法
class Solution {
public:
bool hasPathSum(TreeNode* root, int targetSum) {
if (!root) return false;
if (!root->left && !root->right) return targetSum == root->val;
return hasPathSum(root->left, targetSum - root->val) ||
hasPathSum(root->right, targetSum - root->val);
}
};
// 迭代解法
class Solution {
public:
bool hasPathSum(TreeNode* root, int targetSum) {
if (!root) return false;
stack<pair<TreeNode*, int>> stk;
stk.push({root, targetSum - root->val});
while (!stk.empty()) {
auto [node, currSum] = stk.top();
stk.pop();
if (!node->left && !node->right && currSum == 0) {
return true;
}
if (node->right) {
stk.push({node->right, currSum - node->right->val});
}
if (node->left) {
stk.push({node->left, currSum - node->left->val});
}
}
return false;
}
};
7.3 JavaScript实现
javascript复制// 递归解法
var hasPathSum = function(root, targetSum) {
if (!root) return false;
if (!root.left && !root.right) return targetSum === root.val;
return hasPathSum(root.left, targetSum - root.val) ||
hasPathSum(root.right, targetSum - root.val);
};
// 迭代解法
var hasPathSum = function(root, targetSum) {
if (!root) return false;
const stack = [[root, targetSum - root.val]];
while (stack.length) {
const [node, currSum] = stack.pop();
if (!node.left && !node.right && currSum === 0) {
return true;
}
if (node.right) {
stack.push([node.right, currSum - node.right.val]);
}
if (node.left) {
stack.push([node.left, currSum - node.left.val]);
}
}
return false;
};
8. 单元测试与验证
8.1 测试用例设计
良好的测试用例应覆盖以下场景:
- 空树
- 单节点树(满足和不满足条件)
- 只有左子树的树
- 只有右子树的树
- 完整二叉树
- 包含负数的树
- 深度很大的树
示例测试用例:
python复制import unittest
class TestPathSum(unittest.TestCase):
def test_empty_tree(self):
self.assertFalse(hasPathSum(None, 0))
def test_single_node(self):
root = TreeNode(5)
self.assertTrue(hasPathSum(root, 5))
self.assertFalse(hasPathSum(root, 1))
def test_sample_tree(self):
# 构建示例中的树
root = TreeNode(5)
root.left = TreeNode(4)
root.right = TreeNode(8)
root.left.left = TreeNode(11)
root.right.left = TreeNode(13)
root.right.right = TreeNode(4)
root.left.left.left = TreeNode(7)
root.left.left.right = TreeNode(2)
root.right.right.right = TreeNode(1)
self.assertTrue(hasPathSum(root, 22))
self.assertFalse(hasPathSum(root, 26))
def test_negative_values(self):
root = TreeNode(-2)
root.right = TreeNode(-3)
self.assertTrue(hasPathSum(root, -5))
8.2 测试的重要性
在机考和面试中,测试环节同样重要:
- 展示全面的思考能力
- 验证代码的正确性
- 发现潜在的边界问题
- 体现工程实践能力
建议在写完代码后,至少手动验证3-5个测试用例,包括常规情况和边界情况。
9. 总结与个人心得
通过这道题目,我们可以深入理解二叉树遍历的多种方式以及递归算法的应用。在实际编码过程中,我有以下几点体会:
-
递归思维需要训练:刚开始接触树问题时,递归思路可能不太直观,但通过大量练习可以培养这种思维方式。
-
边界条件至关重要:树问题中空节点的处理、叶子节点的判断等边界条件很容易出错,需要特别注意。
-
从简单例子入手:当无法理解算法时,用一个小例子手动模拟执行过程往往能帮助理解。
-
多种解法比较:即使题目很简单,思考多种解法也能加深对问题的理解,提升解决问题的能力。
-
测试驱动开发:先写测试用例再写实现代码,可以更全面地考虑各种情况,减少错误。
这道题虽然标为简单,但很好地考察了基础算法能力和编程细致程度,是准备技术面试的必练题目。