LeetCode中等难度题目「114. 二叉树展开为链表」,这道题核心考察二叉树的遍历(前序遍历为主)和节点指针的原地修改,是面试中高频出现的二叉树应用题。本文会用TypeScript实现三种不同思路,从直观到优化,逐步拆解逻辑,新手也能轻松看懂。
一、题目解析(题干+核心要求)
题干回顾
给你二叉树的根结点 root ,请你将它展开为一个单链表:
-
展开后的单链表应该同样使用 TreeNode ,其中 right 子指针指向链表中下一个结点,而左子指针始终为 null 。
-
展开后的单链表应该与二叉树 先序遍历 顺序相同。
核心要点
-
原地修改:不能创建新的TreeNode链表,必须直接修改原二叉树的left和right指针;
-
顺序要求:单链表顺序 = 原二叉树前序遍历顺序(根 → 左 → 右);
-
指针规范:所有节点的left指针必须置为null,right指针串联整个链表。
示例辅助理解(简化版)
原二叉树(前序遍历:1 → 2 → 3 → 4 → 5 → 6):
plain
1
/ \
2 5
/ \ \
3 4 6
展开后单链表:
plain
1 → 2 → 3 → 4 → 5 → 6
(所有left为null,right指向后续节点)
二、前置准备(TreeNode类型定义)
题目已给出TreeNode类的定义,TypeScript版本如下(直接复用,无需修改):
typescript
class TreeNode {
val: number
left: TreeNode | null
right: TreeNode | null
constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
this.val = (val === undefined ? 0 : val)
this.left = (left === undefined ? null : left)
this.right = (right === undefined ? null : right)
}
}
三、三种实现方案(TS版,含详细注释)
核心思路:围绕「前序遍历」展开------因为单链表顺序和前序遍历一致,所以先获取前序遍历的节点序列,再调整指针;或边遍历边调整指针,优化空间复杂度。
方案一:先序遍历(递归)+ 后续指针调整(直观易懂)
思路拆解
-
用一个数组(stack)存储前序遍历的所有节点(递归实现前序遍历);
-
遍历存储节点的数组,依次调整每个节点的指针:
-
前一个节点的left置为null;
-
前一个节点的right指向当前节点;
- 优点:逻辑简单,容易上手,适合新手;缺点:需要额外数组存储节点,空间复杂度O(n)(n为节点数)。
完整代码(含注释)
typescript
/**
Do not return anything, modify root in-place instead.
*/
function flatten_1(root: TreeNode | null): void {
// 边界处理:空树直接返回
if (root === null) {
return;
}
// 用于存储前序遍历的节点序列
const stack: TreeNode[] = [];
// 执行递归版前序遍历,将节点存入stack
preOrder_1(root, stack);
// 遍历节点序列,调整指针(从第二个节点开始)
for (let i = 1; i < stack.length; i++) {
const prev = stack[i - 1]; // 前一个节点
const curr = stack[i]; // 当前节点
prev.left = null; // 前节点left置空
prev.right = curr; // 前节点right指向当前节点
}
}
// 辅助函数:前序遍历(递归实现)
const preOrder_1 = (root: TreeNode | null, list: TreeNode[]): void => {
if (root != null) {
list.push(root); // 先访问根节点,存入数组
preOrder_1(root.left, list); // 递归遍历左子树
preOrder_1(root.right, list); // 递归遍历右子树
}
}
方案二:先序遍历(栈)+ 后续指针调整(避免递归栈溢出)
思路拆解
和方案一逻辑一致,唯一区别是「前序遍历用栈实现」,替代递归实现。
为什么用栈?递归遍历的本质是利用系统调用栈,当二叉树深度极深时,会出现栈溢出问题;用手动维护的栈实现前序遍历,可避免此问题,更适合生产环境。
空间复杂度依然是O(n)(数组存储节点+栈存储遍历节点)。
完整代码(含注释)
typescript
// 复用方案一的flatten_1函数,仅修改前序遍历的实现
function flatten_1(root: TreeNode | null): void {
if (root === null) {
return;
}
const stack: TreeNode[] = [];
// 替换为栈实现的前序遍历
preOrder_2(root, stack);
for (let i = 1; i < stack.length; i++) {
const prev = stack[i - 1];
const curr = stack[i];
prev.left = null;
prev.right = curr;
}
}
// 辅助函数:前序遍历(栈实现,核心!)
const preOrder_2 = (root: TreeNode | null, list: TreeNode[]): void => {
if (!root) {
return;
}
const stack = [root]; // 栈初始化,存入根节点
while (stack.length) { // 栈不为空则继续遍历
const node: TreeNode | undefined = stack.pop(); // 弹出栈顶节点(先处理根)
if (!node) continue; // 兜底:避免节点为undefined(实际不会触发)
list.push(node); // 访问节点,存入数组
// 关键:栈是后进先出,所以先压右子树,再压左子树(保证左子树先被访问)
if (node.right) {
stack.push(node.right);
}
if (node.left) {
stack.push(node.left);
}
}
}
方案三:边遍历边展开(最优解,空间复杂度O(1))
思路拆解
这是最优方案------无需额外数组存储节点,利用栈遍历的同时,直接调整节点指针,实现原地展开,空间复杂度优化至O(1)(仅用栈存储待遍历节点,栈的最大深度为二叉树高度,最坏情况O(n),平均情况O(logn))。
核心逻辑:
-
用栈存储待遍历的节点,初始化存入根节点;
-
用prev指针记录上一个访问的节点(初始为null);
-
弹出栈顶节点(curr),调整prev和curr的指针:
-
若prev不为null,prev.left置为null,prev.right指向curr;
-
更新prev为curr;
-
栈中压入节点时,依然遵循「先右后左」(保证前序遍历顺序);
-
循环直至栈为空,此时二叉树已展开为单链表。
完整代码(含注释)
typescript
/**
Do not return anything, modify root in-place instead.
*/
function flatten_2(root: TreeNode | null): void {
// 边界处理:空树直接返回
if (root === null) {
return;
}
const stack: TreeNode[] = [root]; // 栈初始化,存入根节点
let prev: TreeNode | null = null; // 记录上一个访问的节点
while (stack.length) { // 栈不为空,继续遍历
const curr: TreeNode | undefined = stack.pop(); // 弹出栈顶节点(当前节点)
if (!curr) continue; // 兜底处理
// 调整上一个节点的指针(核心:原地修改)
if (prev !== null) {
prev.left = null; // 上一个节点left置空
prev.right = curr; // 上一个节点right指向当前节点
}
prev = curr; // 更新prev为当前节点,为下一次调整做准备
// 关键:先压右子树,再压左子树(栈后进先出,保证左子树先被访问,符合前序)
if (curr.right) {
stack.push(curr.right);
}
if (curr.left) {
stack.push(curr.left);
}
}
}
四、三种方案对比(选型建议)
| 方案 | 前序实现方式 | 空间复杂度 | 时间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 方案一 | 递归 | O(n) | O(n) | 逻辑简单,新手易上手 | 递归深度过深会栈溢出,额外占用数组空间 |
| 方案二 | 栈 | O(n) | O(n) | 避免递归栈溢出,稳定性更好 | 仍需额外数组存储节点,空间利用率不高 |
| 方案三 | 栈(边遍历边调整) | O(h)(h为树高,最优) | O(n) | 原地修改,空间利用率最高,面试首选 | 逻辑稍复杂,需注意指针更新顺序 |
五、关键注意事项(避坑指南)
-
前序遍历的顺序陷阱:栈实现时,必须「先压右子树,再压左子树」------因为栈是后进先出,这样弹出时才能先访问左子树,符合「根→左→右」的前序顺序;若顺序颠倒,会导致单链表顺序错误。
-
原地修改要求:严禁创建新的TreeNode实例,所有操作必须针对原节点的left和right指针进行修改,否则会不符合题目要求。
-
边界处理:必须先判断root是否为null,否则会触发空指针异常(比如stack.pop()后获取node.val时)。
-
指针更新顺序:方案三中,必须先调整prev的指针,再更新prev为curr,否则会出现指针混乱(比如prev和curr指向同一个节点)。
六、总结
本题的核心是「前序遍历」和「原地指针修改」,三种方案各有侧重:
-
新手入门:优先方案一,理解「前序遍历→指针调整」的核心逻辑;
-
稳定性优化:方案二,用栈替代递归,避免栈溢出;
-
面试最优:方案三,边遍历边调整,空间复杂度最优,体现对二叉树遍历和指针操作的熟练程度。
其实这道题还有更进阶的解法(比如Morris遍历,空间复杂度O(1)),但上述三种方案已经能覆盖大部分面试场景,新手先掌握这三种,再进阶学习Morris遍历即可。