一、题目描述
LeetCode 114「二叉树展开为链表」要求我们将一棵二叉树展开成一个单链表。
题目给定二叉树的根节点 root,要求将其原地展开为一个链表,展开后的链表仍然使用 TreeNode 结构。
展开后的链表需要满足以下条件:
-
链表顺序与二叉树的先序遍历顺序一致;
-
每个节点的
right指针指向链表中的下一个节点; -
每个节点的
left指针都必须为nullptr; -
尽量使用原地算法完成,进阶要求是
O(1)额外空间。
例如:
输入:root = [1,2,5,3,4,null,6]
原始二叉树结构如下:
1
/ \
2 5
/ \ \
3 4 6
它的先序遍历结果是:
1 -> 2 -> 3 -> 4 -> 5 -> 6
所以展开后的结构应该是:
1
\
2
\
3
\
4
\
5
\
6
所有节点的 left 指针都应该为空。
二、问题本质
这道题的本质是:
将二叉树按照先序遍历顺序,原地改造成一条只使用
right指针连接的链表。
先序遍历的顺序是:
根节点 -> 左子树 -> 右子树
所以对于任意一个节点 root,展开后的顺序应该是:
root -> root.left 展开后的链表 -> root.right 展开后的链表
难点在于:
原来的右子树不能丢失,同时左子树需要移动到右边。
因此核心操作是:
将左子树接到右指针上
再把原来的右子树接到左子树展开链表的末尾
最后将 left 置空
三、最直观思路:先序遍历保存节点
最容易想到的做法是:
-
先对二叉树做一次先序遍历;
-
将遍历到的节点依次存入数组;
-
再根据数组顺序重新连接节点。
这种方法非常直观。
代码实现
#include <vector>
using namespace std;
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:
void flatten(TreeNode* root) {
vector<TreeNode*> nodes;
preorder(root, nodes);
for (int i = 1; i < nodes.size(); i++) {
TreeNode* prev = nodes[i - 1];
TreeNode* curr = nodes[i];
prev->left = nullptr;
prev->right = curr;
}
}
private:
void preorder(TreeNode* root, vector<TreeNode*>& nodes) {
if (root == nullptr) {
return;
}
nodes.push_back(root);
preorder(root->left, nodes);
preorder(root->right, nodes);
}
};
方法分析
这种方法逻辑清晰,适合初学者理解。
但是它使用了一个额外数组 nodes 保存所有节点,因此空间复杂度是:
O(n)
不满足题目进阶要求的 O(1) 额外空间。
四、递归优化:反向先序遍历
除了正向先序遍历,还可以从反方向思考。
先序遍历是:
根 -> 左 -> 右
如果我们反过来处理,就是:
右 -> 左 -> 根
我们可以维护一个指针 prev,表示当前节点展开后应该连接的下一个节点。
处理顺序如下:
-
先展开右子树;
-
再展开左子树;
-
最后处理当前节点;
-
当前节点的
right指向prev; -
当前节点的
left置空; -
更新
prev = 当前节点。
这样可以从后往前构造链表。
代码实现
class Solution {
private:
TreeNode* prev = nullptr;
public:
void flatten(TreeNode* root) {
if (root == nullptr) {
return;
}
flatten(root->right);
flatten(root->left);
root->right = prev;
root->left = nullptr;
prev = root;
}
};
执行过程理解
对于这棵树:
1
/ \
2 5
/ \ \
3 4 6
反向处理顺序为:
6 -> 5 -> 4 -> 3 -> 2 -> 1
每处理一个节点,就把它接到当前已经构造好的链表头部。
最终形成:
1 -> 2 -> 3 -> 4 -> 5 -> 6
五、递归方法的优点与限制
递归方法代码非常短,也很优雅。
但是它仍然存在一个问题:
虽然没有使用显式数组,但是递归调用栈会消耗空间。
在最坏情况下,如果二叉树退化成链表,递归深度可能达到 n。
因此递归方法的空间复杂度是:
O(h)
其中 h 是二叉树高度。
最坏情况下:
O(n)
所以它还不是真正意义上的 O(1) 额外空间。
六、原地算法:寻找左子树最右节点
题目进阶要求:
可以使用原地算法,即 O(1) 额外空间展开这棵树吗?
要做到 O(1) 额外空间,就不能使用数组,也不能依赖递归栈。
我们需要直接在原树上修改指针。
对于当前节点 curr,如果它有左子树:
curr.left != nullptr
那么展开后的顺序应该是:
curr -> curr.left -> ... -> curr.right
也就是说,需要把左子树移动到右边。
但是原来的右子树不能丢失,所以要先找到左子树中最靠右的节点 predecessor。
这个节点就是左子树按照先序展开后,最后可以接上原右子树的位置。
操作步骤如下:
1. 找到 curr 左子树中的最右节点 predecessor
2. predecessor->right = curr->right
3. curr->right = curr->left
4. curr->left = nullptr
5. curr = curr->right
七、为什么要找左子树最右节点?
假设当前节点是 1:
1
/ \
2 5
/ \ \
3 4 6
当前节点 1 的左子树是:
2
/ \
3 4
右子树是:
5
\
6
按照先序遍历,正确顺序应该是:
1 -> 2 -> 3 -> 4 -> 5 -> 6
所以我们应该把 1 的左子树整体移动到右边:
1
\
2
/ \
3 4
但是原来的右子树 5 -> 6 不能丢,需要接到左子树的最右节点 4 后面。
连接后变成:
1
\
2
/ \
3 4
\
5
\
6
然后继续对 2、3、4 等节点做同样处理,最终得到完整链表。
八、O(1) 原地算法代码实现
class Solution {
public:
void flatten(TreeNode* root) {
TreeNode* curr = root;
while (curr != nullptr) {
if (curr->left != nullptr) {
TreeNode* predecessor = curr->left;
while (predecessor->right != nullptr) {
predecessor = predecessor->right;
}
predecessor->right = curr->right;
curr->right = curr->left;
curr->left = nullptr;
}
curr = curr->right;
}
}
};
九、核心代码逐句解析
1. 使用 curr 遍历整棵树
TreeNode* curr = root;
while (curr != nullptr) {
...
}
curr 表示当前正在处理的节点。
每次处理完当前节点后,继续向右移动:
curr = curr->right;
因为最终展开后的链表只通过 right 指针连接。
2. 判断当前节点是否存在左子树
if (curr->left != nullptr) {
...
}
如果当前节点没有左子树,说明它已经符合链表方向,可以直接处理下一个节点。
如果当前节点有左子树,则需要进行指针重排。
3. 找到左子树的最右节点
TreeNode* predecessor = curr->left;
while (predecessor->right != nullptr) {
predecessor = predecessor->right;
}
这里的 predecessor 表示当前节点左子树中最靠右的节点。
它的作用是承接当前节点原来的右子树。
4. 将原右子树接到 predecessor 后面
predecessor->right = curr->right;
这一步非常关键。
如果不先保存原来的右子树,它会在后续指针修改中丢失。
5. 将左子树移动到右边
curr->right = curr->left;
因为展开后的链表只能使用 right 指针,所以需要将左子树整体移动到右指针上。
6. 清空 left 指针
curr->left = nullptr;
题目明确要求展开后的链表中,所有节点的 left 指针必须为 nullptr。
十、完整推荐代码
下面是推荐提交版本,满足题目进阶要求,额外空间为 O(1)。
#include <iostream>
using namespace std;
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:
void flatten(TreeNode* root) {
TreeNode* curr = root;
while (curr != nullptr) {
if (curr->left != nullptr) {
TreeNode* predecessor = curr->left;
while (predecessor->right != nullptr) {
predecessor = predecessor->right;
}
predecessor->right = curr->right;
curr->right = curr->left;
curr->left = nullptr;
}
curr = curr->right;
}
}
};
十一、算法流程总结
对于每一个节点 curr:
如果 curr 没有左子树:
直接处理 curr.right
如果 curr 有左子树:
找到 curr.left 中最右侧的节点 predecessor
将 curr 原来的右子树接到 predecessor.right
将 curr.left 移动到 curr.right
将 curr.left 置空
继续处理 curr.right
可以概括为:
左子树搬到右边
原右子树接到左子树最右端
当前节点左指针置空
继续向右处理
十二、复杂度分析
时间复杂度
对于每个节点,我们最多会访问若干次。
从整体来看,算法在原树结构上不断向右推进,并进行局部指针调整。
时间复杂度为:
O(n)
其中 n 是二叉树节点数量。
空间复杂度
该方法没有使用数组、栈、队列,也没有使用递归。
只使用了 curr 和 predecessor 两个指针变量。
因此空间复杂度为:
O(1)
这也正是题目进阶要求的解法。
十三、递归法与原地法对比
| 方法 | 思路 | 时间复杂度 | 空间复杂度 | 是否满足进阶 |
|---|---|---|---|---|
| 先序遍历保存节点 | 先遍历,再重连 | O(n) | O(n) | 否 |
| 递归反向构造 | 右、左、根反向连接 | O(n) | O(h) | 严格来说否 |
| 原地指针重排 | 找左子树最右节点并接上右子树 | O(n) | O(1) | 是 |
在实际刷题中,最推荐掌握的是第三种方法,也就是原地指针重排法。
十四、常见错误总结
错误一:直接覆盖 right,导致右子树丢失
错误写法:
curr->right = curr->left;
curr->left = nullptr;
这样会导致当前节点原来的右子树丢失。
正确做法是先找到左子树最右节点,并把原右子树接上去:
predecessor->right = curr->right;
curr->right = curr->left;
curr->left = nullptr;
错误二:忘记将 left 置空
展开后的链表要求所有节点的 left 指针都为 nullptr。
如果只移动右指针,而不清空左指针,结果不符合题目要求。
错误三:递归法误以为是 O(1) 空间
递归写法虽然没有显式使用额外数组,但是递归调用栈仍然占用空间。
因此递归法空间复杂度是:
O(h)
只有迭代指针重排法才是真正的 O(1) 额外空间。
十五、为什么该算法符合先序遍历顺序?
对于任意节点 curr,先序遍历顺序是:
curr -> 左子树 -> 右子树
原地算法做的事情正是:
curr.right = curr.left
左子树最右节点.right = 原 curr.right
curr.left = nullptr
也就是说,它把结构改造成:
curr -> 左子树 -> 原右子树
这与先序遍历顺序完全一致。
然后继续沿着 right 指针处理下一个节点。
所以最终整棵树会被展开成一条符合先序遍历顺序的链表。
十六、总结
LeetCode 114「二叉树展开为链表」是一道典型的二叉树结构改造题。
它考查的重点不是普通遍历,而是:
如何在不创建新节点的情况下,重新组织原有节点之间的指针关系。
本题最核心的思想是:
对于每个节点:
如果存在左子树,
就将左子树移动到右边,
再把原来的右子树接到左子树的最右节点后面。
最终推荐使用:
原地指针重排法
它的优势是:
时间复杂度:O(n)
空间复杂度:O(1)
符合题目进阶要求
代码简洁,思路清晰
这道题非常适合理解二叉树中的先序遍历结构转换,也是学习树形结构原地修改的重要题目。