二叉树展开为链表:从先序遍历到原地指针重排

一、题目描述

LeetCode 114「二叉树展开为链表」要求我们将一棵二叉树展开成一个单链表。

题目给定二叉树的根节点 root,要求将其原地展开为一个链表,展开后的链表仍然使用 TreeNode 结构。

展开后的链表需要满足以下条件:

  1. 链表顺序与二叉树的先序遍历顺序一致;

  2. 每个节点的 right 指针指向链表中的下一个节点;

  3. 每个节点的 left 指针都必须为 nullptr

  4. 尽量使用原地算法完成,进阶要求是 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 置空

三、最直观思路:先序遍历保存节点

最容易想到的做法是:

  1. 先对二叉树做一次先序遍历;

  2. 将遍历到的节点依次存入数组;

  3. 再根据数组顺序重新连接节点。

这种方法非常直观。

代码实现

复制代码
#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,表示当前节点展开后应该连接的下一个节点。

处理顺序如下:

  1. 先展开右子树;

  2. 再展开左子树;

  3. 最后处理当前节点;

  4. 当前节点的 right 指向 prev

  5. 当前节点的 left 置空;

  6. 更新 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

然后继续对 234 等节点做同样处理,最终得到完整链表。


八、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 是二叉树节点数量。


空间复杂度

该方法没有使用数组、栈、队列,也没有使用递归。

只使用了 currpredecessor 两个指针变量。

因此空间复杂度为:

复制代码
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)
符合题目进阶要求
代码简洁,思路清晰

这道题非常适合理解二叉树中的先序遍历结构转换,也是学习树形结构原地修改的重要题目。

相关推荐
PHP隔壁老王邻居7 分钟前
windows菜单搜索栏无法显示历史记录或者无法使用修复方法
windows
手写码匠23 分钟前
从零实现 Prompt 工程引擎:结构化提示、自动优化与多轮自省体系
人工智能·深度学习·算法·aigc
道一2332 分钟前
Windows系统查看端口占用进程的3种实用方法
windows·笔记
半条-咸鱼37 分钟前
【INACCESSIBLE_BOOT_DEVICE】安装 Config Tool 后 Windows 蓝屏,最终通过 VMware 虚拟机解决
windows·stm32·vmware·芯片
无限码力1 小时前
阿里算法岗 0530笔试真题 - 多约束条件下的元素匹配统计
算法·阿里笔试真题·阿里机试真题·阿里算法岗笔试
lqqjuly1 小时前
MLA — 多头潜在注意力深度解析
深度学习·神经网络·算法
吴可可1231 小时前
SolidWorks草图转三维DWG技巧
算法
凡人叶枫1 小时前
Effective C++ 条款04:确定对象被使用前已先被初始化
java·linux·开发语言·c++·嵌入式开发
不想写代码的星星2 小时前
std::move 根本不移动,就像老婆饼里没有老婆
c++
redaijufeng2 小时前
C++雾中风景7:闭包
c++·算法·风景