LeetCode 117. 填充每个节点的下一个右侧节点指针 II:代码实操搞定next指针

0 阅读7分钟

在二叉树刷题场景中,「填充next指针」是层序遍历的经典应用,而LeetCode 117题作为116题(完美二叉树)的进阶版,取消了“完美二叉树”的限制,更贴合实际面试中的考察难度。本文将详细拆解两种主流解法,从基础的队列实现到O(1)空间优化,带你吃透这道题的核心逻辑,同时规避编码中常见的坑点。

一、题目核心信息

题目描述

给定一个普通二叉树(非完美二叉树),填充每个节点的next指针,使其指向同层的下一个右侧节点。若不存在下一个右侧节点,则将next指针设为null。初始状态下,所有next指针均为null。

节点结构定义(TypeScript)

题目已给出固定的节点类定义,无需修改,重点关注left、right、next三个指针的处理:

/**
 * Definition for _Node.
 * class _Node {
 *     val: number
 *     left: _Node | null
 *     right: _Node | null
 *     next: _Node | null
 * 
 *     constructor(val?: number, left?: _Node, right?: _Node, next?: _Node) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.left = (left===undefined ? null : left)
 *         this.right = (right===undefined ? null : right)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

class _Node {
  val: number
  left: _Node | null
  right: _Node | null
  next: _Node | null

  constructor(val?: number, left?: _Node, right?: _Node, next?: _Node) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
    this.next = (next === undefined ? null : next)
  }
}

核心要求

  • 遍历逻辑:按层处理二叉树,确保同层节点通过next指针串联;

  • 边界处理:非完美二叉树可能存在“左子树为空、右子树非空”或反之的情况,需避免空指针异常;

  • 效率优化:基础解法可满足需求,进阶要求实现O(1)空间复杂度(不使用额外队列/栈)。

二、解法一:队列实现层序遍历(基础版,易理解)

思路解析

层序遍历(广度优先遍历BFS)的核心是用「队列」存储每一层的节点,通过队列的先进先出特性,依次处理每一层的节点并串联next指针,步骤如下:

  1. 边界判断:若根节点为空,直接返回null;

  2. 初始化队列:将根节点入队,队列中存储的是当前层待处理的节点;

  3. 分层遍历:每次循环获取当前层的节点数量size,确保只处理当前层的节点;

  4. 串联next指针:遍历当前层节点时,当前节点的next指针指向队列头部(下一个同层节点),最后一个节点无需处理(默认null);

  5. 子节点入队:将当前节点的左、右子节点(非空)依次入队,为下一层遍历做准备。

完整代码(题目给定connect_1)

function connect_1(root: _Node | null): _Node | null {
  if (!root) {
    return root;
  }
  const queue = [root];
  while (queue.length) {
    const size = queue.length; // 当前层节点数量
    for (let i = 0; i < size; i++) {
      const node = queue.shift(); // 头部出队,获取当前处理节点
      if (!node) continue; // 防御性判断,避免空指针
      // 非当前层最后一个节点,next指向队列头部(下一个同层节点)
      if (i < size - 1) {
        node.next = queue[0];
      }
      // 左子节点非空,入队
      if (node.left) {
        queue.push(node.left);
      }
      // 右子节点非空,入队
      if (node.right) {
        queue.push(node.right);
      }
    }
  }
  return root;
};

关键细节与复杂度分析

  • queue.shift():数组的shift方法会删除头部元素并返回,这里模拟队列的“出队”操作,注意shift方法时间复杂度为O(n),但不影响整体算法的时间复杂度(整体仍为O(n));

  • i < size - 1:核心判断,确保当前层最后一个节点的next指针保持null,避免错误指向子节点;

  • 时间复杂度:O(n),每个节点仅入队、出队各一次,遍历一次;

  • 空间复杂度:O(n),最坏情况下(满二叉树),队列会存储最后一层的节点,数量为n/2,趋近于n。

三、解法二:利用已填充的next指针(空间优化版,O(1)空间)

思路解析

基础版的空间复杂度较高,面试中常要求优化到O(1)。核心思路是「复用已填充的next指针」,代替队列实现层序遍历的“分层”效果,无需额外存储空间:

  1. 定义关键变量:

    • start:当前层的起始节点,初始化为根节点;

    • last:下一层已串联的最后一个节点,用于串联下一个子节点;

    • nextStart:下一层的起始节点,用于更新start,进入下一层遍历。

  2. 辅助函数handle:统一处理子节点的串联逻辑,负责更新last和nextStart;

  3. 遍历当前层:通过current指针(从start开始),沿next指针遍历当前层所有节点,处理每个节点的左、右子节点;

  4. 更新start:将start设为nextStart,进入下一层,重复遍历,直到start为null(所有层处理完毕)。

完整代码(题目给定connect_2,补充关键注释)

function connect_2(root: _Node | null): _Node | null {
  if (root === null) {
    return null;
  }
  let start: _Node | null = root; // 当前层起始节点
  while (start !== null) {
    let last: _Node | null = null; // 下一层已串联的最后一个节点
    let nextStart: _Node | null = null; // 下一层起始节点

    // 处理子节点的串联逻辑:连接下一层节点,更新last和nextStart
    const handle = (node: _Node | null) => {
      if (node === null) return; // 空节点直接跳过,避免空指针
      if (last !== null) {
        last.next = node; // 串联下一层节点
      }
      if (nextStart === null) {
        nextStart = node; // 记录下一层第一个节点(仅第一次调用时赋值)
      }
      last = node; // 更新下一层已串联的最后一个节点
    };

    let current: _Node | null = start; // 遍历当前层的指针
    while (current) {
      if (current.left) {
        handle(current.left); // 处理左子节点
      }
      if (current.right) {
        handle(current.right); // 处理右子节点
      }
      current = current.next; // 沿next指针,遍历当前层下一个节点
    }
    start = nextStart; // 进入下一层,更新当前层起始节点
  }
  return root;
};

关键细节与复杂度分析

  • handle函数的作用:统一处理子节点,避免重复代码,同时确保nextStart只记录下一层的第一个节点(后续调用不再修改);

  • current = current.next:利用已填充的next指针,实现当前层的遍历,替代队列的作用,这是空间优化的核心;

  • 边界处理:handle函数中先判断node是否为null,避免非完美二叉树的空节点导致的异常;

  • 时间复杂度:O(n),每个节点仅被handle函数处理一次,无额外遍历;

  • 空间复杂度:O(1),仅使用4个临时变量(start、last、nextStart、current),无额外数据结构。

四、两种解法对比(清晰易懂)

解法类型时间复杂度空间复杂度核心优势适用场景
队列层序(connect_1)O(n)O(n)逻辑简单、易上手,调试方便新手入门、快速解题,不要求空间优化
next指针优化(connect_2)O(n)O(1)空间效率极高,贴合面试进阶要求面试答题、追求算法最优解

五、常见坑点避坑指南(必看)

刷题时,很多人能想到思路,但会栽在边界处理和细节上,结合本题总结4个高频坑点:

坑点1:空指针异常(最常见)

错误场景:未判断node为null时,直接访问node.left、node.right或node.next,导致「Cannot read properties of null」。

避坑方案:在处理节点前先做防御性判断,如connect_1中「if (!node) continue」,connect_2中handle函数「if (node === null) return」。

坑点2:next指针串联错误(漏判最后一个节点)

错误场景:connect_1中遗漏「i < size - 1」,导致当前层最后一个节点的next指针指向队列中的子节点(下一层节点),而非null。

避坑方案:牢记「当前层最后一个节点无需串联next」,通过size控制遍历范围,确保仅串联同层节点。

坑点3:nextStart赋值错误

错误场景:connect_2中,每次调用handle函数都更新nextStart,导致nextStart指向当前层最后一个子节点,而非下一层起始节点。

避坑方案:仅在nextStart为null时赋值(「if (nextStart === null)」),确保只记录下一层的第一个节点。

坑点4:TypeScript类型推断异常

错误场景:编码时未显式声明节点类型,导致TS将变量推断为never类型,报错「类型“never”上不存在属性“next”」。

避坑方案:严格按照题目给定的_Note类定义,显式声明变量类型(如「let start: _Node | null = root」),避免类型模糊。

六、总结与拓展

本题的核心是「层序遍历」,两种解法分别对应不同的效率需求:

  • 基础版(队列):优先掌握,是层序遍历的通用模板,可迁移到「层序打印二叉树」「找每层最大值」等同类题目;

  • 优化版(next指针):重点理解「复用已有的指针减少空间消耗」的思路,这是面试中考察算法优化能力的常见考点。

拓展建议:做完本题后,可对比LeetCode 116题(完美二叉树),体会两种二叉树在处理逻辑上的细微差异,进一步巩固层序遍历的应用。