【LeetCode热题100】Java详解:二叉树展开为链表(含O(1)空间原地解法与工程实践)
面向人群
- 正在准备技术面试(尤其是大厂后端、算法岗)的开发者
- 已掌握二叉树基本操作,希望深入理解原地算法与指针操作技巧的学习者
- 刷 LeetCode「热题100」或「二叉树专题」的中级程序员
- 对空间复杂度优化、Morris遍历思想感兴趣的工程人员
📌 本文适合已能手写二叉树前序遍历的读者。若对递归、栈、指针操作不熟悉,建议先完成 LeetCode 第144题(二叉树的前序遍历)。
关键词
LeetCode 114、二叉树展开为链表、前序遍历、原地算法、O(1)空间、前驱节点、Morris遍历、链表重构、指针操作、面试高频题、空间优化、迭代、递归
阅读前必须掌握的基础
在深入阅读本文前,请确保你已熟练掌握以下知识点:
二叉树的基本结构
TreeNode定义(val,left,right)- 树的前序遍历(根→左→右)
三种遍历实现方式
- 递归实现前序遍历
- 迭代+栈实现前序遍历
- 理解遍历顺序与访问时机
Java 基础
- 引用类型赋值(浅拷贝)
null值处理Deque/Stack的使用
链表基础
- 单链表结构(
next指针) - 链表的构建与连接
- 单链表结构(
复杂度分析
- 能区分 O(n) 时间与 O(1)/O(n) 空间的含义
- 理解“原地算法”的定义
✅ 推荐前置练习:
- LeetCode 144:二叉树的前序遍历
- LeetCode 226:翻转二叉树
- LeetCode 112:路径总和
一、原题回顾
题目链接:114. 二叉树展开为链表
题目描述
给你二叉树的根结点root,请你将它展开为一个单链表:
- 展开后的单链表应该同样使用
TreeNode,其中right子指针指向链表中下一个结点,而left子指针始终为null。 - 展开后的单链表应该与二叉树先序遍历顺序相同。
示例 1:
输入:root = [1,2,5,3,4,null,6] 输出:[1,null,2,null,3,null,4,null,5,null,6]可视化过程:
原始树: 1 / \ 2 5 / \ \ 3 4 6 展开后: 1 \ 2 \ 3 \ 4 \ 5 \ 6示例 2:
输入:root = [] 输出:[]示例 3:
输入:root = [0] 输出:[0]提示:
- 树中结点数在范围
[0, 2000]内 -100 <= Node.val <= 100
进阶要求:
你可以使用原地算法(O(1) 额外空间)展开这棵树吗?
二、原题分析
2.1 核心要求解读
- 结构转换:从树 → 链表(所有
left = null,right形成链) - 顺序保证:链表顺序 = 前序遍历顺序(根 → 左子树 → 右子树)
- 原地操作:不能创建新节点,只能重排指针(进阶要求 O(1) 空间)
2.2 关键挑战
- 信息丢失问题:在修改
left或right指针时,可能丢失子树引用 - 顺序维护:如何在不破坏结构的前提下按前序顺序连接节点
- 空间限制:递归隐式栈、显式栈都会占用 O(h) 或 O(n) 空间
✅ 举例说明信息丢失:
若直接将root.right = root.left,则原右子树将无法访问!
三、答案构思:三种主流解法
本题有三种经典解法,按空间复杂度从高到低排列:
| 方法 | 核心思想 | 空间复杂度 | 是否满足进阶 |
|---|---|---|---|
| 方法一:分离式前序遍历 | 先遍历存节点,再重构链表 | O(n) | ❌ |
| 方法二:同步遍历与展开 | 边遍历边连接,用栈保存子节点 | O(n) | ❌ |
| 方法三:寻找前驱节点(原地) | 利用前驱节点重连,无需额外空间 | O(1) | ✅ |
💡 三种方法时间复杂度均为 O(n),但空间使用差异巨大。
四、完整答案与代码实现(Java)
方法一:分离式前序遍历(最直观)
思路
- 对树进行前序遍历,将所有节点按顺序存入
List<TreeNode> - 遍历列表,将每个节点的
left设为null,right指向下一个节点
✅ 优点:逻辑清晰,不易出错
❌ 缺点:需要 O(n) 额外空间,不满足进阶要求
Java 代码(递归版)
classSolution{publicvoidflatten(TreeNoderoot){if(root==null)return;List<TreeNode>nodes=newArrayList<>();preorder(root,nodes);// 重构链表for(inti=0;i<nodes.size()-1;i++){nodes.get(i).left=null;nodes.get(i).right=nodes.get(i+1);}// 最后一个节点的 left 和 right 已为 null(或保持原样)}privatevoidpreorder(TreeNodenode,List<TreeNode>list){if(node==null)return;list.add(node);preorder(node.left,list);preorder(node.right,list);}}Java 代码(迭代版)
classSolution{publicvoidflatten(TreeNoderoot){if(root==null)return;List<TreeNode>nodes=newArrayList<>();Deque<TreeNode>stack=newLinkedList<>();TreeNodecurr=root;// 迭代前序遍历while(curr!=null||!stack.isEmpty()){while(curr!=null){nodes.add(curr);stack.push(curr);curr=curr.left;}curr=stack.pop();curr=curr.right;}// 重构链表for(inti=0;i<nodes.size()-1;i++){nodes.get(i).left=null;nodes.get(i).right=nodes.get(i+1);}}}方法二:同步遍历与展开(边遍历边连接)
思路
- 使用栈模拟前序遍历
- 关键技巧:在访问当前节点前,先将其左右子节点压入栈(先右后左,因为栈是 LIFO)
- 维护
prev指针,将prev.right = curr,prev.left = null
✅ 优点:避免两次遍历
❌ 缺点:仍需 O(n) 栈空间
Java 代码
classSolution{publicvoidflatten(TreeNoderoot){if(root==null)return;Deque<TreeNode>stack=newLinkedList<>();stack.push(root);TreeNodeprev=null;while(!stack.isEmpty()){TreeNodecurr=stack.pop();// 连接前一个节点到当前节点if(prev!=null){prev.left=null;prev.right=curr;}// 先压右子树,再压左子树(保证左子树先被弹出)if(curr.right!=null){stack.push(curr.right);}if(curr.left!=null){stack.push(curr.left);}prev=curr;}}}🔍 注意:压栈顺序是右 → 左,这样弹出时才是左 → 右,符合前序遍历要求。
方法三:寻找前驱节点(原地 O(1) 空间)
思路(核心!)
这是本题的最优解,灵感来源于Morris 遍历。
关键观察:
- 前序遍历顺序:
根 → 左子树 → 右子树 - 若当前节点有左子树,则左子树的最后一个节点(最右节点)应该连接到当前节点的右子树
操作步骤(对每个节点curr):
- 如果
curr.left == null,直接curr = curr.right - 否则:
- 找到
curr.left的最右节点(即前驱节点predecessor) - 将
predecessor.right = curr.right(把右子树接到前驱后面) - 将
curr.right = curr.left(左子树变成新的右子树) - 将
curr.left = null
- 找到
- 继续处理
curr.right
✅ 优点:真正 O(1) 空间,原地修改
⚠️ 缺点:逻辑较复杂,需理解前驱概念
Java 代码
classSolution{publicvoidflatten(TreeNoderoot){TreeNodecurr=root;while(curr!=null){if(curr.left!=null){// 找到左子树的最右节点(前驱)TreeNodepredecessor=curr.left;while(predecessor.right!=null){predecessor=predecessor.right;}// 将右子树接到前驱节点后面predecessor.right=curr.right;// 左子树变为右子树curr.right=curr.left;curr.left=null;}// 移动到下一个节点(现在右子树包含了原左子树)curr=curr.right;}}}执行过程图解(以示例1为例)
初始:
1 / \ 2 5 / \ \ 3 4 6Step 1:curr = 1,有左子树
predecessor = 4(2的最右节点)4.right = 5(原右子树)1.right = 2,1.left = null
结果:
1 \ 2 / \ 3 4 \ 5 \ 6Step 2:curr = 2,有左子树
predecessor = 33.right = 42.right = 3,2.left = null
结果:
1 \ 2 \ 3 \ 4 \ 5 \ 6后续节点无左子树,直接右移,完成!
五、代码分析与对比
| 维度 | 方法一(分离遍历) | 方法二(同步遍历) | 方法三(前驱节点) |
|---|---|---|---|
| 时间复杂度 | O(n) | O(n) | O(n) |
| 空间复杂度 | O(n) | O(n) | O(1) |
| 是否原地 | ❌ | ❌ | ✅ |
| 代码复杂度 | 低 | 中 | 高 |
| 可读性 | 高 | 中 | 低 |
| 面试推荐度 | 初级 | 中级 | 高级(展示深度) |
💡 面试策略:
- 先给出方法一(快速实现)
- 优化到方法二(减少遍历次数)
- 最终提出方法三(满足进阶,展示算法功底)
六、时间复杂度与空间复杂度深度分析
时间复杂度:O(n)
- 方法一:两次遍历,各 O(n)
- 方法二:一次遍历,每个节点入栈出栈一次
- 方法三:每个节点被访问一次,找前驱时每个边最多被遍历两次(一次向下,一次通过
predecessor.right),故仍是 O(n)
📌 方法三的时间复杂度证明:
- 每条树边最多被访问两次:
- 一次在主循环中(
curr = curr.right)- 一次在找前驱时(
while (predecessor.right != null))- 总边数 = n - 1,故总操作 ≤ 2(n-1) = O(n)
空间复杂度
| 方法 | 空间来源 | 大小 |
|---|---|---|
| 方法一 | List+ 递归栈/显式栈 | O(n) |
| 方法二 | 显式栈 | 最坏 O(n)(链状树) |
| 方法三 | 仅几个指针变量 | O(1) |
✅ 方法三是唯一满足进阶要求的解法
七、常见问题解答(FAQ)
Q1:方法三中,为什么找前驱时不会无限循环?
A:因为在连接predecessor.right = curr.right之前,predecessor.right一定是null(它是左子树的最右节点)。连接后形成的新链会在后续被正确遍历,不会回环。
Q2:能否用后序遍历来解决?
A:不能。题目明确要求前序遍历顺序。后序遍历顺序是左→右→根,与要求不符。
Q3:方法三修改了树结构,会影响其他操作吗?
A:本题要求就是原地修改,所以这是预期行为。在实际系统中,若需保留原树,应先深拷贝。
Q4:如果要求展开为左链表(left指针连接),怎么办?
A:类似思路,但需找右子树的最左节点作为前驱,并调整指针方向。
Q5:方法三能否用于中序或后序展开?
A:可以,但逻辑更复杂。Morris 遍历有中序、前序版本,但后序较难。
八、优化思路总结
8.1 空间优化(核心)
- 消除辅助存储:方法三通过重用树本身的
right指针临时存储信息 - 避免递归:消除函数调用栈开销
8.2 代码健壮性优化
- 空指针检查:所有方法开头加
if (root == null) return; - 边界测试:单节点、空树、只有左/右子树
8.3 可扩展性优化
- 泛化为任意遍历顺序:通过修改前驱查找逻辑
- 支持反向展开:如从右到左的前序
九、数据结构与算法基础知识点回顾
9.1 前序遍历的本质
前序遍历顺序:根 → 左子树全部节点 → 右子树全部节点
这意味着:
- 根节点是第一个
- 左子树的最后一个节点紧接着是右子树的第一个节点
✅ 这正是方法三利用的关键性质!
9.2 Morris 遍历思想
Morris 遍历是一种O(1) 空间的树遍历方法,核心思想:
- 利用叶子节点的空指针(通常是
right)建立临时线索(thread) - 遍历完成后恢复原树结构(本题不需要恢复,因为就是要修改)
本题的方法三正是 Morris 前序遍历的变种。
9.3 指针操作安全准则
在修改树指针时,务必遵循:
- 先保存引用:在覆盖
node.right前,先保存原值 - 避免悬空指针:确保所有节点仍可通过某条路径访问
- 逐步验证:每步操作后,画图确认结构正确
十、面试官提问环节(模拟)
Q:方法三的时间复杂度真的是 O(n) 吗?找前驱不是嵌套循环吗?
A:是的,确实是 O(n)。虽然有嵌套while,但每个树边最多被访问两次:
- 一次在主循环的
curr = curr.right - 一次在找前驱的
predecessor = predecessor.right
由于树有 n-1 条边,总操作数 ≤ 2(n-1),故为 O(n)。
Q:如果要求不修改原树,还能 O(1) 空间吗?
A:不能。如果不允许修改原树,又要求 O(1) 空间,就无法存储前序序列。至少需要 O(h) 空间(递归栈)来遍历。
Q:方法三中,predecessor.right = curr.right会不会覆盖有用信息?
A:不会。因为predecessor是左子树的最右节点,其right必为null(否则就不是最右)。所以这是安全的“空位”利用。
Q:如何测试你的代码?
A:设计以下测试用例:
- 空树 → 不崩溃
- 单节点 → 正确
- 只有左子树 → 展开为右链
- 只有右子树 → 保持不变
- 完全二叉树 → 验证前序顺序
十一、实际开发中的应用场景
11.1 内存受限环境
- 在嵌入式系统或 IoT 设备中,内存极其有限
- O(1) 空间算法可避免栈溢出或内存分配失败
11.2 数据库索引优化
- B+ 树等索引结构有时需要线性化遍历
- 原地转换可减少 I/O 操作(无需额外存储中间结果)
11.3 序列化与传输
- 将树结构转换为线性格式便于网络传输
- 接收方可根据前序序列重建树(需配合空节点标记)
11.4 编译器优化
- AST(抽象语法树)的线性化有助于指令调度
- 减少递归调用,提升执行效率
11.5 游戏开发(技能树、任务树)
- 将复杂的技能依赖树展开为线性解锁顺序
- 便于 UI 展示或进度跟踪
十二、相关题目推荐
| 题号 | 题目 | 关联点 |
|---|---|---|
| 144 | 二叉树的前序遍历 | 基础遍历 |
| 94 | 二叉树的中序遍历 | Morris 遍历经典题 |
| 145 | 二叉树的后序遍历 | 更复杂的 Morris |
| 226 | 翻转二叉树 | 指针操作练习 |
| 116 | 填充每个节点的下一个右侧节点指针 | 层序+指针连接 |
| 117 | 填充每个节点的下一个右侧节点指针 II | 非完美二叉树 |
| 114 | 本题 | 前序+原地转换 |
🔗 建议学习路径:144 → 94(Morris) → 114 → 116
十三、总结与延伸
13.1 核心思想提炼
- 前序遍历顺序 = 根 → 左子树全部 → 右子树全部
- 左子树的最后一个节点应连接右子树的第一个节点
- 原地算法的关键是重用空指针建立临时连接
13.2 延伸思考
- Morris 遍历通用框架:
while(curr!=null){if(curr.left==null){visit(curr);curr=curr.right;}else{// 找前驱TreeNodepred=findPredecessor(curr);if(pred.right==null){pred.right=curr;// 建立线索curr=curr.left;}else{pred.right=null;// 恢复树visit(curr);curr=curr.right;}}} - 其他遍历的原地展开:中序、后序也可类似处理
- N 叉树的线性化:需记录子节点列表,逻辑更复杂
13.3 面试答题建议
分步展示思考过程:
- 理解题意:“要按前序遍历顺序展开成右链表,对吗?”
- 暴力解法:先写方法一,说明其 O(n) 空间
- 优化思路:“能否边遍历边连接?” → 方法二
- 终极优化:“能否不用栈?” → 引出前驱节点思想
- 手动画图:用小例子演示方法三的执行过程
- 讨论权衡:可读性 vs 空间效率
算法之美,在于用最少的资源解决最复杂的问题。
掌握 Morris 思想,你便拥有了打开 O(1) 空间树遍历大门的钥匙!