从顶到底还是从底到顶?一次UI组件重构引发的“反转”思考(107. 二叉树的层序遍历 II)


从顶到底还是从底到顶?一次UI组件重构引发的“反转”思考 🤔

嘿,各位奋斗在一线的码农朋友们!我是你们的老伙计,一个总在代码里寻找乐趣的开发者。今天想和大家聊聊一个话题:树形结构。别一听就觉得是枯燥的理论,它可实实在在地藏在我们日常开发的每个角落。

故事,得从我最近重构一个内部组件库的“文档浏览器”功能说起。

我遇到了什么问题?😵

我们有一个庞大的前端组件库,文件结构像这样:

- project-root/
  - src/
    - components/
      - layout/
        - Grid.tsx
        - Flex.tsx
      - forms/
        - Button.tsx
        - Input.tsx
    - utils/
      - ...

产品经理提出了一个需求:当用户在文档中查看某个具体组件时,比如 Button.tsx,我们要在侧边栏清晰地展示出它的“层级路径”,但为了突出当前组件,希望把最深层的路径放在最上面。

理想的效果是这样的:

[forms]          <-- 最接近组件的目录
[components]
[src]
[project-root]   <-- 最顶层的目录

这和我们常见的文件浏览器那种“从根目录展开”的视图正好相反。我当时的第一反应是:“这不就是个树形结构嘛,遍历一遍,然后把结果反转一下不就行了?”

这个问题的本质,其实和力扣上的一道经典中等题异曲同工:

LeetCode 107. 二叉树的层序遍历 II

给你二叉树的根节点 root ,返回其节点值 自底向上的层序遍历。 (即按从叶子节点所在层到根节点所在的层,逐层从左向右遍历)

输入root = [3,9,20,null,null,15,7]
输出[[15,7],[9,20],[3]]

提示解读时间 🧐:

  • 树中节点数目在范围 [0, 2000] 内: 节点数量可控,告诉我们一个标准的 O(N) 遍历算法是完全没问题的。
  • -1000 <= Node.val <= 1000: 节点值的范围,对我们的结构性算法没啥影响,不用特殊处理。
  • 输入: root = [] 的情况需要考虑,也就是要优雅地处理空树。

你看,这不就和我遇到的“反向展示目录层级”问题一模一样吗?都是对一个层级结构进行“自底向上”的呈现。

我是如何用算法思维解决的!💡

面对这个问题,我探索了三种不同的实现路径,从“先跑通再说”到“这也太秀了吧”,每一步都加深了我对数据结构的理解。

解法一:先正后反,大力出奇迹 (BFS + Reverse)

这是最符合直觉的解法。管他什么正序倒序,我先用最熟悉的方式把层序遍历搞定,最后再把结果整个儿颠倒一下不就完事了?

思路

  1. 用一个队列(Queue)来实现标准的广度优先搜索(BFS),一层一层地把节点值存下来。
  2. 把每一层的结果 currentLevel (一个列表) 存入一个总的结果列表 result
  3. 等所有层都遍历完,result 里就是自顶向下的结果了。
  4. 最后,调用 Collections.reverse(result),一键反转,收工!😎

<代码>

class Solution {
    /*
     * 思路:最直观的方法,先进行标准的自顶向下层序遍历,然后对结果列表进行一次性反转。
     * 简单直接,逻辑分为清晰的两步。
     */
    public List<List<Integer>> levelOrderBottom(TreeNode root) {
        List<List<Integer>> result = new ArrayList<>();
        if (root == null) return result;

        // 为啥用 ArrayDeque? 因为它作为队列(Queue)使用时,性能通常优于 LinkedList。
        // 它是一个基于数组的双端队列,addLast(offer) 和 removeFirst(poll) 都是 O(1) 操作。
        Queue<TreeNode> queue = new ArrayDeque<>();
        queue.offer(root);

        while (!queue.isEmpty()) {
            int levelSize = queue.size();
            List<Integer> currentLevel = new ArrayList<>();
            for (int i = 0; i < levelSize; i++) {
                TreeNode node = queue.poll();
                currentLevel.add(node.val);
                if (node.left != null) queue.offer(node.left);
                if (node.right != null) queue.offer(node.right);
            }
            result.add(currentLevel);
        }

        // 为啥用 Collections.reverse? 这是 Java 集合框架提供的标准、高效的反转方法。
        // 自己写循环反转容易出错且不一定有它优化得好。
        Collections.reverse(result);
        return result;
    }
}

</代码>

  • 恍然大悟:有时候最简单的解决方案就是最好的。把一个复杂问题拆解成“遍历”和“反转”两个独立的小问题,代码清晰,不容易出错。
解法二:一步到位,在遍历中构建逆序 (BFS + 头部插入)

写完解法一,我的“代码洁癖”又犯了。能不能在遍历的时候就直接构建出最终的逆序结果,省掉最后那一步 reverse 操作呢?

思路
在BFS遍历每一层时,我们不把新得到的 currentLevel 追加到 result 列表的末尾 (add(e)),而是插入到开头 (add(0, e))。这样一来,最先处理的根节点层会被不断“挤”到列表的最后,而最后处理的叶子节点层自然就在列表的最前面了。

<代码>

class Solution {
    /*
     * 思路:在BFS遍历过程中,巧妙地利用数据结构的特性,每次都将新层插入到结果列表的头部。
     * 这样遍历完成时,结果自然就是自底向上的。
     */
    public List<List<Integer>> levelOrderBottom(TreeNode root) {
        // 关键!为啥用 LinkedList? 因为我们需要频繁地在列表头部插入元素 (add(0, ...))。
        // LinkedList 在头部插入是 O(1) 操作,因为它只需要修改头指针。
        // 如果用 ArrayList,每次头部插入都是 O(N) 操作,因为需要移动所有后续元素,性能会很差!
        List<List<Integer>> result = new LinkedList<>();
        if (root == null) return result;

        Queue<TreeNode> queue = new ArrayDeque<>();
        queue.offer(root);

        while (!queue.isEmpty()) {
            int levelSize = queue.size();
            List<Integer> currentLevel = new ArrayList<>();
            for (int i = 0; i < levelSize; i++) {
                TreeNode node = queue.poll();
                currentLevel.add(node.val);
                if (node.left != null) queue.offer(node.left);
                if (node.right != null) queue.offer(node.right);
            }
            // 这就是精髓所在!
            result.add(0, currentLevel);
        }
        return result;
    }
}

</代码>

  • 踩坑经验:我一开始没多想,直接在 ArrayList 上用 add(0, ...)。在小数据集上跑没问题,但一旦数据量上来,性能急剧下降。这让我深刻体会到:选择正确的数据结构和使用它最高效的方法,是优化代码的关键! 这次“踩坑”让我对 ArrayListLinkedList 在不同场景下的性能差异有了刻骨铭心的认识。
解法三:换个角度看问题 (DFS + Reverse)

谁说层序遍历只能用BFS?我们同样可以用深度优先搜索(DFS)来解决,这能展示我们对树遍历更全面的理解。

思路
通过递归进行DFS,并带上一个 depth 参数来记录当前节点的层级。

  1. 我们仍然需要一个 result 列表来存放各层结果。
  2. 当递归到一个新的深度 depth 时,如果 result 的大小还到不了 depth,说明这是我们第一次访问这一层,就在 result 中创建一个新的空列表。
  3. 然后,把当前节点的值加到 result 中对应 depth 的那个列表里。
  4. 递归结束后,我们同样得到了一个自顶向下的结果,最后再反转一下。

<代码>

class Solution {
    /*
     * 思路:利用DFS(递归)和深度参数,将节点值存入对应层级的列表。
     * 这种方法展示了用不同遍历策略解决同一问题的灵活性。
     */
    public List<List<Integer>> levelOrderBottom(TreeNode root) {
        List<List<Integer>> result = new ArrayList<>();
        dfs(root, 0, result);
        Collections.reverse(result);
        return result;
    }

    private void dfs(TreeNode node, int depth, List<List<Integer>> result) {
        if (node == null) return;

        // 这是DFS解层序问题的核心技巧!
        // 如果当前深度等于result列表的大小,说明我们第一次到达这一层,需要为它创建一个子列表。
        if (depth == result.size()) {
            result.add(new ArrayList<>());
        }

        // 将节点值添加到对应深度的列表中。
        result.get(depth).add(node.val);

        dfs(node.left, depth + 1, result);
        dfs(node.right, depth + 1, result);
    }
}

</代码>

  • 恍然大悟:原来DFS也能如此优雅地处理层序问题!关键就在于那个 depth 参数和 if (depth == result.size()) 的判断。它就像一个“探路者”,每当DFS深入到一个新的未知层级,就先为这一层“开辟”一个空间。
举一反三:这些技能还能用在哪?

掌握了树的层序遍历及其变种,可不仅仅是为了刷题。在实际开发中,它大有可为:

  1. 依赖解析:在打包工具(如Webpack)或构建系统(如Maven)中,需要先处理没有依赖的模块(叶子节点),再处理依赖它们的模块。这是一个拓扑排序过程,与自底向上的遍历思想相通。
  2. 组织架构计算:计算公司里每个部门的薪资总和。必须先计算出最基层员工的薪资,然后汇总到小组,再汇总到部门,最后到整个公司。这是一个典型的自底向上信息聚合过程。
  3. 游戏技能树:在游戏中,要解锁一个高级技能,必须先点亮它的所有前置技能。检查一个技能是否可解锁,就需要从它开始向上追溯,看路径是否都已点亮。
更多练习,成为树的“驯兽师”!

如果你对树的遍历产生了浓厚的兴趣,不妨挑战一下这些“亲戚”题目:

希望这次的分享,能让你看到算法与实际开发之间那座有趣的桥梁。下次再遇到看似棘手的问题时,不妨退一步,看看它背后是不是隐藏着某个经典的数据结构或算法模型。我们下期再会!👋

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值