在二叉树刷题场景中,「填充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指针,步骤如下:
-
边界判断:若根节点为空,直接返回null;
-
初始化队列:将根节点入队,队列中存储的是当前层待处理的节点;
-
分层遍历:每次循环获取当前层的节点数量size,确保只处理当前层的节点;
-
串联next指针:遍历当前层节点时,当前节点的next指针指向队列头部(下一个同层节点),最后一个节点无需处理(默认null);
-
子节点入队:将当前节点的左、右子节点(非空)依次入队,为下一层遍历做准备。
完整代码(题目给定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指针」,代替队列实现层序遍历的“分层”效果,无需额外存储空间:
-
定义关键变量:
-
start:当前层的起始节点,初始化为根节点;
-
last:下一层已串联的最后一个节点,用于串联下一个子节点;
-
nextStart:下一层的起始节点,用于更新start,进入下一层遍历。
-
-
辅助函数handle:统一处理子节点的串联逻辑,负责更新last和nextStart;
-
遍历当前层:通过current指针(从start开始),沿next指针遍历当前层所有节点,处理每个节点的左、右子节点;
-
更新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题(完美二叉树),体会两种二叉树在处理逻辑上的细微差异,进一步巩固层序遍历的应用。