别怕树!一层一层剥开它的心:用BFS/DFS优雅计算层平均值(637. 二叉树的层平均值)

204 阅读8分钟

❤️ 别怕树!一层一层剥开它的心:用BFS/DFS优雅计算层平均值

大家好,我是你们的老朋友,一名热爱分享的开发者。今天,我们不聊高深的架构,也不谈炫酷的前端,就来聊聊一个我们每天都可能间接接触到的数据结构——,以及一个我在实际项目中遇到的,和树有关的有趣问题。

我遇到了什么问题?

想象一下,我接到了一个来自 HR 部门的需求:为我们的内部管理系统开发一个“团队健康度”分析仪表盘。公司的人员组织架构,天然就是一棵树:CEO 是根节点,下面是各个副总裁(VP),再往下是总监、经理、员工……

HR 希望能看到每一个管理层级下,员工的平均绩效得分。比如,所有 VP 这一层的平均分是多少?所有总监这一层的平均分又是多少?

这个需求翻译成技术语言就是:给定一个代表公司组织架构的二叉树(为简化问题,我们假设每个管理者最多有两个直接下属),计算出每一层节点的平均值。

这不就是 LeetCode 上的经典题目 637. 二叉树的层平均值 嘛!问题找到了,接下来就是如何优雅地解决它。

恍然大悟:用“排队”搞定层级难题

一开始,我想到的最朴素的方法就是:

  1. 找到第一层(只有 CEO),计算平均分。
  2. 找到第二层(所有 VP),计算平均分。
  3. 找到第三层(所有总监),计算平均分。 ...

但问题是,我怎么才能“找到某一整层的所有人”呢?这让我陷入了沉思。🤔

“恍然大悟”的瞬间来了! 我想起了去食堂打饭排队的场景。第一批进去的人打完饭,他们的“下一批”(朋友们)才接着排队进去。这不就是广度优先搜索(BFS) 的核心思想吗?用一个队列(Queue),完美模拟“逐层处理”的过程!

解法1:广度优先搜索(BFS)—— 最直观的“排队法”

BFS 的思路和咱们的需求简直是天作之合。

<思路> 我们用一个队列来存放需要访问的节点。每一轮循环,我们都只处理当前队列里“存着”的所有节点,这些人就构成了“一层”。我们把他们的绩效分加起来,然后除以人数,就得到了这一层的平均分。在处理他们的时候,再把他们的直接下属(子节点)放到队列里,为下一轮循环做准备。 </思路>

/*
 * 思路:BFS层序遍历。使用队列模拟逐层访问,每一轮循环处理一整层。
 * 时间复杂度:O(N),每个员工(节点)都只入队和出队一次。
 * 空间复杂度:O(W),W是公司最“胖”的那一层的人数(树的最大宽度)。
 */
import java.util.*;

class Solution {
    public List<Double> averageOfLevels(TreeNode root) {
        List<Double> averages = new ArrayList<>();
        if (root == null) return averages;

        // 为何用 Queue?因为它“先进先出”的特性,完美匹配我们一层一层处理的需求。
        // LinkedList 是实现 Queue 接口的常用类,提供了 offer(入队) 和 poll(出队) 方法。
        Queue<TreeNode> queue = new LinkedList<>();
        queue.offer(root); // CEO 先入队

        while (!queue.isEmpty()) {
            // 关键点1:在循环开始前,记下当前层的人数!
            int levelSize = queue.size();
            // 关键点2:“踩坑”经验!员工绩效分加起来可能很大,超过int范围。
            // 必须用 long 来存总和,否则计算结果可能溢出变成负数!我真的错过一次!😱
            long levelSum = 0L;

            for (int i = 0; i < levelSize; i++) {
                TreeNode current = queue.poll(); // 让当前层的员工出队
                levelSum += current.val;

                // 把他的下属(子节点)加入队列,让他们在下一轮排队
                if (current.left != null) queue.offer(current.left);
                if (current.right != null) queue.offer(current.right);
            }

            // 计算平均分,记得转成 double 做浮点数除法
            averages.add((double) levelSum / levelSize);
        }
        return averages;
    }
}
解法2:深度优先搜索(DFS)—— “递归”的别样风情

我把 BFS 的方案给同事看,他是个递归的忠实粉丝,他问:“用递归能搞定吗?” 当然可以!这就是深度优先搜索(DFS)

<思路> DFS 的思路是“一条路走到黑”。为了统计每一层的数据,我们需要在递归函数里多传递一个参数 level,告诉当前节点它在第几层。同时,我们需要两个全局的列表,一个 sums 存每层的总分,一个 counts 存每层的人数。列表的索引就天然对应了层级。 </思路>

/*
 * 思路:DFS递归。通过递归函数传递层级level,将每层的数据聚合到外部的列表中。
 * 时间复杂度:O(N),每个员工还是只访问一次。
 * 空间复杂度:O(H),H是公司的管理层级深度(树的高度)。
 */
class Solution {
    public List<Double> averageOfLevels(TreeNode root) {
        List<Long> sums = new ArrayList<>();   // 索引i存放第i层的总分
        List<Integer> counts = new ArrayList<>(); // 索引i存放第i层的人数
        dfs(root, 0, sums, counts);

        List<Double> averages = new ArrayList<>();
        for (int i = 0; i < sums.size(); i++) {
            averages.add((double) sums.get(i) / counts.get(i));
        }
        return averages;
    }

    private void dfs(TreeNode node, int level, List<Long> sums, List<Integer> counts) {
        if (node == null) return;

        // 如果是第一次到达这一层
        if (level == sums.size()) {
            // 就给这一层新增一个“账本”
            sums.add((long) node.val);
            counts.add(1);
        } else {
            // 如果这一层的账本已存在,就在上面更新
            sums.set(level, sums.get(level) + node.val);
            counts.set(level, counts.get(level) + 1);
        }

        // 递归地去访问下属
        dfs(node.left, level + 1, sums, counts);
        dfs(node.right, level + 1, sums, counts);
    }
}

解读一下题目的“提示”

  • 树中节点数量在 [1, 10^4] 范围内:这告诉我们,一个 O(N) 的算法是绰绰有余的,我们不需要去想什么花里胡哨的骚操作。BFS 和 DFS 都是 O(N),完美!
  • -2^31 <= Node.val <= 2^31 - 1:这是最关键的提示,也是最容易掉进去的坑!它暗示了单层节点值的总和有可能会超出 int 的表示范围。如果我们用 int 来累加,当一层有几千个节点,且节点值都很大时,就会发生整数溢出,得到一个错误的结果。所以,必须使用 long 来作为累加器!

Java 数值类型核心特性总结表

特性intlongfloatdoubleBigDecimal (类)
类型32位整数64位整数32位单精度浮点数64位双精度浮点数任意精度十进制数
大小4字节8字节4字节8字节可变
范围/精度-2^312^31 - 1
(约 ±2.1 x 10^9)
-2^632^63 - 1
(约 ±9.2 x 10^18)
范围: 约 ±3.4 x 10^38
精度: 6-7位有效数字
范围: 约 ±1.8 x 10^308
精度: 15-17位有效数字
精度和范围理论上无限,仅受限于内存
默认值00L0.0f0.0dnull
字面量后缀L (推荐)fF (必须)dD (可选)无 (通过构造函数)
核心用途整数首选。循环、计数、ID等int 不够用时(时间戳、大ID)内存敏感且精度要求不高的场景(如图形学)小数首选。科学计算、工程计算必须精确计算的场景(金融、商业)
关键注意可能会溢出内存占用是int两倍有精度误差,不用于精确计算有精度误差,不用于精确计算性能开销大,使用方法运算
选择原则“够用就行”“int不够我再上”“除非没内存,否则别用我”“小数就用我”“钱的事,交给我”

举一反三:这个思想还能用在哪?

学会了层序遍历,你会发现它在很多地方都大有可为:

  1. 社交网络:分析你好友的“好友圈”。你是一级,你的直接好友是二级,好友的好友是三级... 我们可以计算出每一级好友圈的平均年龄、共同兴趣数量等。
  2. 游戏开发:在一个游戏中,AI 需要判断攻击的优先级。离玩家最近(第一层)的敌人威胁最大,远一点(第二层)的次之。BFS 可以帮助 AI 逐层分析战场。
  3. 文件系统:你想统计电脑里每个文件夹层级的平均文件大小吗?从根目录出发,用层序遍历,轻松搞定!

拓展阅读:LeetCode 上的“亲戚们”

如果你对这类问题意犹未尽,强烈推荐去挑战一下这几道题,它们的核心思想都和层序遍历息息相关:


好了,今天的分享就到这里。希望通过这个从实际需求出发的案例,能让你不再畏惧树形结构,并能体会到算法在解决实际问题中的乐趣和威力。下次见!👋