1. 理解题目与解题思路
这道题目要求我们计算二叉树中最深一层叶子节点的和。乍一看似乎很简单,但实际操作中需要考虑几个关键点:
- 如何确定树的最大深度?
- 如何只对最深层的节点进行求和?
- 如何处理各种边界情况(如空树、单节点树等)?
我在最初尝试解决这个问题时,犯了一个常见错误:试图在一个递归函数中同时完成深度计算和求和。这导致代码逻辑混乱,难以维护。后来我意识到,这个问题应该分解为两个独立的子问题:
- 首先计算树的最大深度
- 然后对等于该深度的所有节点值求和
这种"分而治之"的思路在算法设计中非常常见,也是解决复杂问题的有效方法。
2. 最大深度计算实现
2.1 递归法求最大深度
java复制int maxDepth(TreeNode root) {
if (root == null) {
return 0;
}
int leftDepth = maxDepth(root.left);
int rightDepth = maxDepth(root.right);
return Math.max(leftDepth, rightDepth) + 1;
}
这个递归实现非常简洁:
- 基线条件:如果节点为空,深度为0
- 递归计算左子树和右子树的深度
- 返回左右子树深度的较大值加1(当前节点的深度)
注意:这里的深度定义是从根节点开始计数(根节点深度为1),有些教材可能从0开始计数,需要根据题目要求调整。
2.2 迭代法求最大深度
对于不喜欢递归的开发者,也可以用BFS(广度优先搜索)来实现:
java复制int maxDepth(TreeNode root) {
if (root == null) return 0;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
int depth = 0;
while (!queue.isEmpty()) {
int size = queue.size();
depth++;
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
}
return depth;
}
这种方法按层遍历树,每处理完一层,深度加1,直到处理完所有节点。
3. 最深叶子节点求和实现
3.1 两次遍历法
这是最直观的解法,分为两步:
- 先计算树的最大深度
- 再次遍历树,累加深度等于最大深度的节点值
java复制public int deepestLeavesSum(TreeNode root) {
int maxDepth = maxDepth(root);
return sumAtDepth(root, maxDepth, 1);
}
private int sumAtDepth(TreeNode node, int targetDepth, int currentDepth) {
if (node == null) return 0;
if (currentDepth == targetDepth) return node.val;
return sumAtDepth(node.left, targetDepth, currentDepth + 1)
+ sumAtDepth(node.right, targetDepth, currentDepth + 1);
}
3.2 单次遍历优化
我们可以优化为只遍历一次树,在遍历过程中维护两个变量:
- 当前找到的最大深度
- 当前最大深度节点的和
java复制int maxDepth = 0;
int sum = 0;
public int deepestLeavesSum(TreeNode root) {
dfs(root, 0);
return sum;
}
private void dfs(TreeNode node, int depth) {
if (node == null) return;
if (depth > maxDepth) {
maxDepth = depth;
sum = node.val;
} else if (depth == maxDepth) {
sum += node.val;
}
dfs(node.left, depth + 1);
dfs(node.right, depth + 1);
}
这种方法的优势是只需要一次遍历,效率更高。但需要注意递归调用的顺序不会影响结果,因为我们是根据深度来决定是否累加。
4. 常见错误与调试技巧
4.1 递归终止条件错误
我最初犯的错误是将终止条件设为node.left == null && node.right == null,这会导致:
- 提前终止遍历,可能错过更深层的节点
- 只计算了叶子节点,但题目要求的是最深层的所有节点(不一定是叶子节点)
正确的终止条件应该是node == null,这样可以确保遍历完整棵树。
4.2 深度计算不一致
另一个常见问题是深度计算不一致:
- 在计算最大深度时,有些人喜欢从0开始计数
- 而在求和时,可能又从1开始计数
这会导致逻辑错误。建议统一深度计数方式,并在代码中添加注释说明。
4.3 全局变量使用问题
在使用单次遍历优化时,需要注意:
- 全局变量需要在每次调用
deepestLeavesSum时重置 - 在多线程环境下,这种实现会有问题
更安全的做法是将全局变量作为参数传递:
java复制public int deepestLeavesSum(TreeNode root) {
int[] maxDepth = {0};
int[] sum = {0};
dfs(root, 0, maxDepth, sum);
return sum[0];
}
private void dfs(TreeNode node, int depth, int[] maxDepth, int[] sum) {
if (node == null) return;
if (depth > maxDepth[0]) {
maxDepth[0] = depth;
sum[0] = node.val;
} else if (depth == maxDepth[0]) {
sum[0] += node.val;
}
dfs(node.left, depth + 1, maxDepth, sum);
dfs(node.right, depth + 1, maxDepth, sum);
}
5. 复杂度分析与优化
5.1 时间复杂度
- 两次遍历法:O(2n) = O(n),因为每个节点被访问两次
- 单次遍历优化:O(n),每个节点只被访问一次
虽然时间复杂度相同(都是O(n)),但实际运行时间上单次遍历更优。
5.2 空间复杂度
- 递归实现:O(h),其中h是树的高度(递归调用栈的空间)
- 迭代实现(BFS):O(w),其中w是树的最大宽度
对于平衡树,空间复杂度是O(log n);对于最坏情况(链表状的树),空间复杂度是O(n)。
6. 测试用例设计
好的测试用例应该覆盖各种边界情况:
- 空树:
null→ 0 - 单节点树:
[1]→ 1 - 完全二叉树:
[1,2,3,4,5,6,7]→ 4+5+6+7=22 - 非平衡树:
[1,2,3,4,null,null,5,null,6]→ 6 - 所有叶子在同一层:
[1,2,3,null,4,null,5]→ 4+5=9
java复制@Test
public void testDeepestLeavesSum() {
Solution solution = new Solution();
// 测试空树
assertThat(solution.deepestLeavesSum(null), is(0));
// 测试单节点树
TreeNode root1 = new TreeNode(1);
assertThat(solution.deepestLeavesSum(root1), is(1));
// 测试完全二叉树
TreeNode root2 = new TreeNode(1,
new TreeNode(2, new TreeNode(4), new TreeNode(5)),
new TreeNode(3, new TreeNode(6), new TreeNode(7)));
assertThat(solution.deepestLeavesSum(root2), is(22));
// 测试非平衡树
TreeNode root3 = new TreeNode(1,
new TreeNode(2, new TreeNode(4, null, new TreeNode(6)), null),
new TreeNode(3, null, new TreeNode(5)));
assertThat(solution.deepestLeavesSum(root3), is(6));
// 测试所有叶子在同一层
TreeNode root4 = new TreeNode(1,
new TreeNode(2, null, new TreeNode(4)),
new TreeNode(3, null, new TreeNode(5)));
assertThat(solution.deepestLeavesSum(root4), is(9));
}
7. 递归思维训练建议
递归是算法学习中的难点,但也是必须掌握的核心技能。以下是我总结的提高递归能力的建议:
-
理解递归三要素:
- 基线条件(何时停止递归)
- 递归条件(如何分解问题)
- 递归调用(如何组合子问题的解)
-
画递归树:
- 对于简单问题,手动画出递归调用过程
- 标注每次调用的参数和返回值
- 观察递归是如何"递"下去又"归"回来的
-
从小问题开始:
- 先掌握阶乘、斐波那契数列等简单递归
- 再过渡到树的问题
- 最后解决回溯、分治等复杂递归
-
相信递归的正确性:
- 不要试图跟踪所有递归调用(容易混乱)
- 只需确保:
- 基线条件正确
- 递归条件正确分解问题
- 递归调用正确组合结果
-
多练习多总结:
- 每道递归题至少做三遍:
- 第一遍理解思路
- 第二遍独立实现
- 第三遍优化代码
- 记录常见递归模式和技巧
- 每道递归题至少做三遍:
8. 其他解法探讨
8.1 BFS层序遍历法
我们可以利用层序遍历的特性,在遍历时记录每一层的和,最后一层的和就是我们需要的结果:
java复制public int deepestLeavesSum(TreeNode root) {
if (root == null) return 0;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
int sum = 0;
while (!queue.isEmpty()) {
int size = queue.size();
sum = 0; // 重置为当前层的和
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
sum += node.val;
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
}
return sum; // 最后保留的就是最后一层的和
}
这种方法直观易懂,而且不需要递归,适合对递归不熟悉的开发者。
8.2 DFS迭代法
我们也可以用深度优先搜索的迭代实现,配合栈来记录节点和当前深度:
java复制public int deepestLeavesSum(TreeNode root) {
if (root == null) return 0;
Stack<Pair<TreeNode, Integer>> stack = new Stack<>();
stack.push(new Pair<>(root, 0));
int maxDepth = 0;
int sum = 0;
while (!stack.isEmpty()) {
Pair<TreeNode, Integer> pair = stack.pop();
TreeNode node = pair.getKey();
int depth = pair.getValue();
if (depth > maxDepth) {
maxDepth = depth;
sum = node.val;
} else if (depth == maxDepth) {
sum += node.val;
}
// 注意入栈顺序,先右后左
if (node.right != null) {
stack.push(new Pair<>(node.right, depth + 1));
}
if (node.left != null) {
stack.push(new Pair<>(node.left, depth + 1));
}
}
return sum;
}
这种方法虽然不如递归简洁,但展示了如何用显式栈来模拟递归过程。
9. 算法选择建议
在实际面试或编程中,如何选择最合适的解法?我的建议是:
- 优先考虑可读性:选择你最熟悉、最能清晰表达思路的方法
- 考虑问题规模:
- 对于小规模数据,简单直观的解法最好
- 对于大规模数据,可能需要优化空间复杂度
- 考虑后续维护:
- 递归代码通常更简洁但可能更难调试
- 迭代代码更长但执行过程更直观
- 考虑语言特性:
- 在Java中,递归可能有栈溢出风险
- 在Python中,递归深度限制更严格
对于这道题,我个人推荐以下选择顺序:
- 两次遍历递归法(最直观)
- BFS层序遍历法(易于理解)
- 单次遍历递归优化(效率最高)
- DFS迭代法(展示不同思路)
10. 总结与个人体会
通过这道题目,我深刻体会到分解问题的重要性。将一个复杂问题拆解为多个简单子问题,往往能更清晰地看到解决方案。这也是分治算法(Divide and Conquer)的核心思想。
在递归实现中,最重要的是明确:
- 递归终止条件(什么时候停止)
- 递归调用条件(如何分解问题)
- 结果组合方式(如何合并子问题的解)
我最初学习递归时,总是试图跟踪每一个递归调用,结果越跟越乱。后来明白,应该相信递归的正确性,只需确保上述三个要素正确即可。
另一个重要收获是关于全局变量的使用。虽然它们可以简化代码,但也会带来维护上的困难。在可能的情况下,应该尽量通过参数传递状态,而不是依赖全局变量。
最后,算法学习是一个循序渐进的过程。不要因为一时的困难而否定自己。每解决一个问题,我们的思维能力就会提升一点。坚持练习,终会豁然开朗。