从 Null 到 Next:我如何用 O(1) 空间“点亮”了 UI 树的导航路径(117. 填充每个节点的下一个右侧节点指针 II)


从 Null 到 Next:我如何用 O(1) 空间"点亮"了 UI 树的导航路径 😎

嘿,各位码农同胞们!我是你们的老朋友,一个在代码世界里摸爬滚打了多年的开发者。今天,我想和大家聊聊一个我最近在项目中遇到的"小"挑战,以及我是如何从一个看似简单的需求,一路挖到 LeetCode 一道经典问题的核心,并最终用一种让我"恍然大悟"的方法解决它的。

我遇到了什么问题?🤔

想象一下,我正在开发一个功能非常复杂的企业级应用,其中有一个核心界面是一个动态的、可无限扩展的组织架构图。这个架构图用树形结构展示,每个节点代表一个部门或员工。

(它看起来有点像上面这棵树,但可能更不规则)

产品经理提出了一个需求:"为了提升用户体验,我们需要支持键盘无障碍导航 。用户应该能用 Tab 键,在同一层级的部门/员工之间从左到右依次移动焦点。当到达一行的末尾时,Tab 键应该能将焦点移到下一行的第一个节点上。"

听起来很简单,对吧?我一开始也这么觉得。这不就是个层序遍历嘛!但事情很快就变得棘手起来:

  1. 不完美的树:这个组织架构图不是一棵完美的二叉树。有的部门下面一个子部门都没有,有的有一个,有的有两个。这棵树的结构完全不可预测。
  2. 性能与内存:这个架构图可能会变得非常巨大,节点数可能达到数千个。我们的应用需要部署在一些性能和内存都相对受限的嵌入式设备上。一个占用大量内存的解决方案是绝对不可接受的。

我的任务是为每个 UI 节点(我们把它抽象成一个 Node 对象)填充一个 next 属性,让它指向右边的"兄弟"节点。这样,当用户按下 Tab 键时,我们只需要 currentNode = currentNode.next 就能瞬间完成焦点的切换,效率极高!

初次尝试:教科书式的 BFS 和它带来的"内存焦虑"😱

面对"层序"这个关键词,我的第一反应就是广度优先搜索(BFS)。这是处理树的层级问题的标准武器。

我的思路是:

  1. 用一个队列(Queue)来进行层序遍历。
  2. 每次处理一层时,先记录下当前层的节点数 levelSize
  3. 然后在一个循环里,把这一层的所有节点挨个出队。
  4. 对于每个出队的节点,我把它和队列里的下一个节点(也就是它的右邻居)连接起来。
java 复制代码
// 解法1:BFS层序遍历
public Node connect(Node root) {
    if (root == null) return null;
    Queue<Node> queue = new LinkedList<>();
    queue.offer(root);

    while (!queue.isEmpty()) {
        int levelSize = queue.size();
        Node prev = null; // 用于连接的指针
        for (int i = 0; i < levelSize; i++) {
            Node curr = queue.poll();
            if (prev != null) {
                prev.next = curr; // 连接前一个节点到当前节点
            }
            prev = curr;
            if (curr.left != null) queue.offer(curr.left);
            if (curr.right != null) queue.offer(curr.right);
        }
    }
    return root;
}

踩坑经验:这个方案在我的开发机上跑得飞快,完美地完成了任务。我当时还挺得意,觉得问题解决了。然而,当我们将它部署到目标设备上进行压力测试时,问题暴露了。对于一个特别"宽"的组织架构树,某一层的节点数可能非常多,导致队列占用了大量内存,应用响应变慢,甚至在极端情况下直接 OOM (Out of Memory) 崩溃!💥

我这才意识到,我忽略了那个"常量级额外空间"的隐性要求。BFS 的空间复杂度是 O(W),W 是树的最大宽度,在我们的场景里,这可不是常量!

恍然大悟:利用已有的 next 指针!✨

就在我一筹莫展的时候,我盯着屏幕上的代码,突然有了一个"Aha Moment"。我问自己:"我为什么需要一个队列来存储 下一层的节点呢?我能不能在遍历当前层的时候,就顺手把下一层的 next 指针链表给构建好?"

答案是肯定的!当前层的节点已经通过 next 指针被我连接成了一个链表,我完全可以利用这个链表来遍历当前层,而不需要任何额外的数据结构!

这就是 117. 填充每个节点的下一个右侧节点指针 II的精髓所在,一个空间复杂度为 O(1) 的完美解法。

这个方法的思想非常巧妙:

  1. 我们把每一层看作一个单向链表,这个链表的头节点是 leftmost
  2. 我们用一个 curr 指针遍历由 leftmost 开始的当前层链表。
  3. 在遍历的同时,我们构建下一层 的链表。为了方便操作,我们引入两个"工具人"指针:dummy(虚拟头节点,永远指向下一层链表的"前一个"位置)和 tail(下一层链表的尾指针,负责添加新节点)。
  4. 遍历 curr 时,检查它的 leftright 孩子,一旦发现非空的孩子,就把它接到 tail 的后面,并更新 tail
  5. 当前层遍历完后,dummy.next 就是下一层的头节点。我们更新 leftmost = dummy.next,然后重复这个过程,直到所有层都被处理完毕。
java 复制代码
// 解法2:O(1) 空间复杂度的完美解法
class Solution {
    public Node connect(Node root) {
        if (root == null) {
            return null;
        }
        Node leftmost = root; // 当前层的最左节点

        while (leftmost != null) {
            // dummy 是下一层的虚拟头节点,tail 是构建下一层链表的尾指针
            // 这个 dummy 节点是精髓,它避免了对下一层第一个节点的复杂判断
            Node dummy = new Node(0); 
            Node tail = dummy;
            Node curr = leftmost; // curr 用于遍历当前层

            while (curr != null) {
                if (curr.left != null) {
                    tail.next = curr.left; // 把左孩子接上
                    tail = tail.next;      // tail 后移
                }
                if (curr.right != null) {
                    tail.next = curr.right; // 把右孩子接上
                    tail = tail.next;       // tail 后移
                }
                curr = curr.next; // 沿着当前层链表移动到下一个节点
            }
            // 一层处理完了,下一层的链表也构建好了
            // 下一层的头节点就是 dummy 的下一个节点
            leftmost = dummy.next;
        }
        return root;
    }
}

这个解法简直是艺术品!它没有使用任何随输入规模增大的数据结构,只用了几个固定的指针。dummy 节点的使用是链表问题中的一个经典技巧,它让代码逻辑变得异常清晰,避免了各种 if (isFirstNodeInLevel) 的判断。这个方案不仅解决了我的内存问题,还让我对树的遍历和指针操作有了更深的理解。😉

换个角度:递归也能搞定!🧠

当然,作为一名有追求的开发者,我还会思考有没有其他解法。递归,作为解决树问题的另一大利器,当然也能登场。

提示解读:题目中特意提到"递归程序的隐式栈空间不计入额外空间复杂度",这简直是在明示我们可以尝试递归!

递归的核心是分治。connect(node) 函数的任务就是确保 node 的孩子节点们都被正确连接。

  1. 连接内部 :如果 node 有左右孩子,直接 node.left.next = node.right
  2. 连接外部 :最难的一步是连接 node 的孩子和 node.next 的孩子。我们需要找到 node 最右边的孩子,然后找到 node.next 链上第一个有孩子的节点的那个孩子,把它们连起来。
  3. 递归顺序 :这里有个大坑!我们必须先递归右子树,再递归左子树 。为什么?因为当我们在处理左子树时(比如 node.left),可能需要用到其右侧兄弟(node.right)的子孙节点的 next 信息。如果先递归左子树,右边还是"一盘散沙",信息就断了。先递归右子树,就能保证右边的 next 链总是准备就绪的。
java 复制代码
// 解法3:递归(注意递归顺序)
class Solution {
    public Node connect(Node root) {
        if (root == null) return null;
      
        Node rightmostChild = root.right != null ? root.right : root.left;

        if (root.left != null && root.right != null) {
            root.left.next = root.right;
        }

        if (rightmostChild != null) {
            Node p = root.next;
            while (p != null) {
                if (p.left != null) {
                    rightmostChild.next = p.left;
                    break;
                }
                if (p.right != null) {
                    rightmostChild.next = p.right;
                    break;
                }
                p = p.next;
            }
        }
      
        // 先右后左!先右后左!先右后左!重要的事情说三遍!
        connect(root.right);
        connect(root.left);
      
        return root;
    }
}

这个递归解法同样优雅,虽然最坏情况下的空间复杂度(递归栈深度)和 BFS 一样,但在很多场景下,树的高度 H 远小于节点总数 N,所以它依然是一个非常不错的选择。

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

掌握了这种"层序连接"的思想,你会发现它在很多地方都能派上用场:

  1. 并行计算 :在一些树形依赖的计算任务中,同一层的节点可能需要相互通信或交换数据。预先建立 next 链接,可以让这种横向通信的成本从 O(N) 降到 O(1)。
  2. 游戏 AI :在游戏中,AI 角色可能需要在网格或路点树中进行水平移动。next 指针可以帮助 AI 快速找到同一"战线"上的相邻位置。
  3. DOM 遍历优化 :在前端开发中,如果你需要频繁地在复杂的 DOM 结构中进行同级元素的导航,预处理 next 指针可以极大地提升性能。

更多练习,成为树的大师!

如果你对这类问题产生了兴趣,不妨挑战一下 LeetCode 上的这些相似题目:

希望我这次的分享能对大家有所启发。从一个实际问题出发,深入理解算法的精髓,再回归到解决问题,这个过程本身就充满了乐趣。

好了,不多说了,我得去给产品经理演示我那酷炫的 Tab 导航功能了!祝大家编码愉快,愿你的指针永远指向正确的地方!😉👋

相关推荐
皮卡蛋炒饭.3 分钟前
数据结构—排序
数据结构·算法·排序算法
方圆想当图灵4 分钟前
ScheduledFutureTask 踩坑实录
后端
全栈凯哥18 分钟前
16.Spring Boot 国际化完全指南
java·spring boot·后端
M1A124 分钟前
Java集合框架深度解析:LinkedList vs ArrayList 的对决
java·后端
??tobenewyorker1 小时前
力扣打卡第23天 二叉搜索树中的众数
数据结构·算法·leetcode
贝塔西塔1 小时前
一文读懂动态规划:多种经典问题和思路
算法·leetcode·动态规划
31535669131 小时前
Springboot实现一个接口加密
后端
众链网络2 小时前
AI进化论08:机器学习的崛起——数据和算法的“二人转”,AI“闷声发大财”
人工智能·算法·机器学习
2 小时前
Unity开发中常用的洗牌算法
java·算法·unity·游戏引擎·游戏开发
飒飒真编程3 小时前
C++类模板继承部分知识及测试代码
开发语言·c++·算法