题目描述
给定二叉树的根结点 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 1
/ \ \
2 5 → 2
/ \ \ \
3 4 6 4
\
5
\
6
\
6
最终链表:1 → 2 → 3 → 4 → 5 → 6
示例 2:
输入:root = []
输出:[]
示例 3:
输入:root = [0]
输出:[0]
提示:
- 树中结点数在范围 [0, 2000] 内
- -100 <= Node.val <= 100
进阶: 你可以使用原地算法(O(1) 额外空间)展开这棵树吗?
解题思路总览
| 方法 | 思路 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 方法一:后序遍历 + 倒置链表 | 逆后序遍历(右-左-根),倒着构建链表 | O(n) | O(h) | 面试首选,代码简洁 |
| 方法二:前序遍历 + 栈 | 先序遍历,使用栈存储右子树 | O(n) | O(n) | 容易理解 |
| 方法三:莫里斯遍历 | 线索二叉树,O(1) 空间原地算法 | O(n) | O(1) | 进阶挑战 |
核心原理: 利用后序遍历的逆序恰好是先序遍历顺序的特性,倒着构建链表
方法一:后序遍历 + 倒置链表(推荐)
思路
- 先递归遍历右子树
- 再递归遍历左子树
- 最后处理根节点
处理根节点时,将 root->right 指向当前链表的头节点(倒着连),然后更新链表头为 root。这样每次都把当前节点接到链表最前面,最后就得到正向后序遍历(实际上是先序遍历)顺序的链表。
完整代码
cpp
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
TreeNode* head = NULL; // 链表头指针
void flatten(TreeNode* root) {
if (root == NULL) return;
// 逆后序遍历:右 → 左 → 根
flatten(root->right); // 先递归右子树
flatten(root->left); // 再递归左子树
// 处理根节点,倒着构建链表
root->left = NULL; // 左子指针始终为 null
root->right = head; // 右子指针指向当前链表头
head = root; // 更新链表头为当前节点
}
};
算法流程图
以 root = [1,2,5,3,4,null,6] 为例:
原始二叉树:
1
/ \
2 5
/ \ \
3 4 6
逆后序遍历顺序:右 → 左 → 根
Step 1: flatten(6)
root->left = NULL, root->right = NULL
head = 6
链表:6
Step 2: flatten(4)
root->left = NULL, root->right = 6
head = 4
链表:4 → 6
Step 3: flatten(3)
root->left = NULL, root->right = 4
head = 3
链表:3 → 4 → 6
Step 4: flatten(5)
root->left = NULL, root->right = 3
head = 5
链表:5 → 3 → 4 → 6
Step 5: flatten(2)
root->left = NULL, root->right = 5
head = 2
链表:2 → 5 → 3 → 4 → 6
Step 6: flatten(1)
root->left = NULL, root->right = 2
head = 1
链表:1 → 2 → 5 → 3 → 4 → 6
最终结果:[1,null,2,null,5,null,3,null,4,null,6]
逐行解析
cpp
TreeNode* head = NULL; // 链表头指针
- 定义一个成员变量
head,指向当前构建好的链表头。 - 初始为
NULL,表示空链表。
cpp
void flatten(TreeNode* root) {
if (root == NULL) return;
// 逆后序遍历:右 → 左 → 根
flatten(root->right); // 先递归右子树
flatten(root->left); // 再递归左子树
- 逆后序遍历:先右后左最后根,这与标准后序遍历相反。
- 选择逆后序是因为:倒着构建链表时,先处理右子树再处理左子树,最后根节点时正好把根接到最前面。
- 递归顺序:6 → 4 → 3 → 5 → 2 → 1
cpp
root->left = NULL; // 左子指针始终为 null
- 题目要求左子指针始终为
null,直接置空。
cpp
root->right = head; // 右子指针指向当前链表头
head = root; // 更新链表头为当前节点
- 倒着构建链表的核心 :
root->right = head:将当前节点的右指针指向已构建好的链表头head = root:将当前节点更新为新的链表头
- 这样每次都把当前节点接到链表最前面,最终得到的就是正序链表
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | 每个节点访问一次 |
| 空间 | O(h) | 递归栈深度,h 为树高 |
优点: 代码极其简洁,空间效率高
缺点: 需要理解逆后序遍历和倒置链表的关联
方法二:前序遍历 + 栈
思路
标准的先序遍历(根-左-右)顺序正好是目标链表的顺序。使用栈保存右子树,先遍历左子树,再处理保存的右子树。
完整代码
cpp
class Solution {
public:
void flatten(TreeNode* root) {
if (!root) return;
stack<TreeNode*> st;
TreeNode* cur = root;
while (cur) {
// 如果有左子树,先处理左子树
if (cur->left) {
st.push(cur->right); // 右子树入栈暂存
// 将左子树移到右子树位置
cur->right = cur->left;
cur->left = NULL;
cur = cur->right;
} else {
// 没有左子树,直接向右走或从栈中取右子树
if (!st.empty()) {
cur->right = st.top();
st.pop();
cur->left = NULL;
cur = cur->right;
} else {
break; // 栈空且无左子树,遍历完成
}
}
}
}
};
算法流程图
原始二叉树:
1
/ \
2 5
/ \ \
3 4 6
Step 1: cur = 1,有左子树
st.push(5)
1.right = 2, 1.left = NULL
cur = 2
Step 2: cur = 2,有左子树
st.push(4)
2.right = 3, 2.left = NULL
cur = 3
Step 3: cur = 3,无左子树
st.empty()? 否 → 3.right = 4(出栈), 3.left = NULL
cur = 4
Step 4: cur = 4,无左子树
st.empty()? 否 → 4.right = 6(出栈), 4.left = NULL
cur = 6
Step 5: cur = 6,无左右子树
st.empty()? 是 → break
最终链表:1 → 2 → 3 → 4 → 5 → 6
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | 每个节点最多入栈出栈各一次 |
| 空间 | O(n) | 栈最坏存储所有右子树节点 |
方法三:莫里斯遍历(进阶 O(1) 空间)
思路
利用二叉树中闲置的右子指针,将其改为指向前驱节点,形成线索二叉树,从而在遍历过程中不用栈也不需要递归。
完整代码
cpp
class Solution {
public:
void flatten(TreeNode* root) {
TreeNode* cur = root;
while (cur) {
if (cur->left) {
// 找到左子树中的最右节点(当前节点的前驱)
TreeNode* pre = cur->left;
while (pre->right) pre = pre->right;
// 将前驱的右指针指向当前节点的右子树
pre->right = cur->right;
// 将左子树移到右子树位置
cur->right = cur->left;
cur->left = NULL;
}
cur = cur->right;
}
}
};
复杂度分析
| 复杂度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | 每个节点最多访问常数次 |
| 空间 | O(1) | 原地算法,无额外空间 |
三种方法对比
| 维度 | 方法一逆后序 | 方法二前序+栈 | 方法三莫里斯遍历 |
|---|---|---|---|
| 代码复杂度 | 极简 | 中等 | 复杂 |
| 时间复杂度 | O(n) | O(n) | O(n) |
| 空间复杂度 | O(h) | O(n) | O(1) |
| 面试推荐度 | 首选 | 容易想到 | 进阶加分 |
| 进阶要求 | 一般 | 一般 | 进阶挑战 |
面试追问 FAQ
| 问题 | 解答 |
|---|---|
| Q1:为什么逆后序遍历(右-左-根)能得到正确的链表? | 逆后序遍历的顺序是 6→4→3→5→2→1,这是原始二叉树节点值的倒序 。当我们每次把当前节点接到链表头(root->right = head; head = root)时,相当于在前面插入节点。倒序插入的结果就是正序:1→2→5→3→4→6,正好是先序遍历的顺序。 |
Q2:head 为什么要用成员变量而不是局部变量? |
需要在递归的每一层共享和更新同一个链表头。如果用局部变量并在返回值中传递,每次递归返回后无法正确更新上层节点的 right 指针。使用成员变量确保所有递归层级访问同一个 head。 |
| Q3:如何理解倒着构建链表的过程? | 想象链表初始为空 [6](处理节点6后),处理节点4时执行 4->right = 6,链表变为 [4→6],再处理节点3时执行 3->right = 4,链表变为 [3→4→6]。每次都在头部插入,所以最后得到正序。 |
| Q4:进阶的 O(1) 空间原地算法怎么实现? | 使用莫里斯遍历,利用左子树最右节点的空闲 right 指针指向当前节点的下一个节点,形成临时线索。这样在遍历完左子树后,可以通过这个临时线索回到右子树继续遍历,避免使用栈或递归。 |
| Q5:莫里斯遍历的核心思想是什么? | 对于每个有左子树的节点,找到左子树中的最右节点(前驱),将其 right 指针指向当前节点。这样在遍历完左子树后,可以通过这个临时线索回到右子树继续遍历,避免使用栈或递归。 |
| Q6:如果要按标准前序遍历顺序展开,能否不用递归? | 可以用栈模拟,参考方法二。核心思想是:先处理左子树,将左子树移到右子树位置,同时把原右子树入栈暂存。遍历完左子树后再从栈中取出原右子树继续处理。 |
相关题目
| 题目 | 难度 | 关键点 |
|---|---|---|
| 114. 二叉树展开为链表 | 中等 | 后序遍历,链表倒置 |
| 116. 填充每个节点的下一个右侧节点指针 | 中等 | 层序遍历,链表构建 |
| 144. 二叉树的前序遍历 | 简单 | 前序遍历模板 |
| 145. 二叉树的后序遍历 | 简单 | 后序遍历模板 |
| 剑指 Offer 36. 二叉搜索树与双向链表 | 中等 | 中序遍历,链表构建 |
总结
| 要点 | 说明 |
|---|---|
| 核心原理 | 逆后序遍历(6→4→3→5→2→1)倒着构建链表,得到先序遍历顺序(1→2→3→4→5→6) |
| 关键技巧 | root->right = head; head = root 在链表头部插入节点 |
| 时间复杂度 | O(n),每个节点访问一次 |
| 空间复杂度 | O(h),递归栈深度 |
| 进阶优化 | 莫里斯遍历实现 O(1) 空间原地算法 |