bigywater.tech
算法与数据结构 · LeetCode 114

LeetCode 114. 二叉树展开为链表

给定一棵二叉树,原地把它拉直成一条只用 right 指针连接的链表,顺序是先序遍历的顺序,且每个结点的 left 都要变成空。一个容易漏掉的陷阱是:先用先序遍历收集所有结点到数组,再重新用 right 指针串起来,但忘了把每个结点原来的 left 指针清空——right 链的值序列看起来完全正确,但树的形状并没有真正拉直,仍然残留着旧的 left 分支。本文用真实反例和 5000 组随机树验证,只要原树有左孩子,这个错误就 100% 会发生,再给出正确的原地递归写法、5 种语言实现,以及自底向上拼接的分步演示。

100%
5,000 组随机树中,只要原树含有左孩子边,陷阱写法就一定会残留没清空的 left 指针
4 步
分步演示:自底向上回溯,每一步把左子树接到 right、左链尾接上原右子树,另附 5 种语言实现

题意与示例

本文只用自己的话概括题意,完整约束以官方页面为准。给定一棵二叉树,原地把它"拉直"成一条只用 right 指针连接的链表,顺序是先序遍历的顺序,并且每个结点的 left 都要变成空。一共 5 组用例,覆盖:官方示例、空树、单结点、两结点、只有右孩子的两结点树:

真实实测 · 本机执行输出
[1,2,5,3,4,null,6]  →  right链=[1, 2, 3, 4, 5, 6]  残留left指针=false
[]  →  right链=[]  残留left指针=false
[0]  →  right链=[0]  残留left指针=false
[1,2]  →  right链=[1, 2]  残留left指针=false
[1,null,2]  →  right链=[1, 2]  残留left指针=false

陷阱:只重连 right 指针,忘了清空 left,right 链的值序列"看起来"是对的

一个直觉上很自然的写法:先做一次先序遍历,把所有结点按顺序收集到一个数组里,再遍历这个数组,把每个结点的 right 指向下一个结点。这样做出来的 right 链,顺着走确实能读出正确的先序遍历值序列——但很容易漏掉一步:每个结点原来的 left 指针并没有被清空,仍然指向原来的左孩子。right 链看起来是对的,可树的形状其实并没有真正"拉直":

真实实测 · 本机执行输出
输入 [1,2,5,3,4,null,6]\n陷阱写法(收集数组后只重连 right,不清空 left)  →  right链=[1, 2, 3, 4, 5, 6]  残留left指针=true\n正确写法(额外清空每个结点的 left)              →  right链=[1, 2, 3, 4, 5, 6]  残留left指针=false

两种写法读出来的 right 链完全一样,光看这一串数字很容易误以为陷阱写法也是对的。但题目要求的是"拉直成一条链",链上的每个结点不应该再有 left 分支——官方的正确性检查会同时验证这两件事。做了一次随机测试,统计"原本就含有左孩子"的随机树里,陷阱写法残留非空 left 指针的比例:

真实实测 · 本机执行输出
5,000 组随机树中,3,968 组原本就含有左孩子边;\n这些树里陷阱写法残留非空 left 指针的有 3,968 组,占 100.0%

只要原树里存在任何一条"左孩子"边,陷阱写法就一定会留下没清空的 left 指针——这不是偶发的边界情况,而是这种写法本身遗漏了一步操作,系统性地发生。

原地递归、自底向上:五种语言的实现

原地递归,不借助额外数组:helper(node) 把以 node 为根的子树拉直,返回拉直后链的尾结点。先递归拉直左子树、再递归拉直右子树;如果左子树不为空,就把"左链的尾巴"接到原来的右子树上,再把整个左子树平移到 right 位置,最后把 left 清空。时间 O(n),空间 O(h)(递归栈,不需要额外数组)。Python、Java、Go、JavaScript、C++ 都是真实编译运行的,用同一组 5 个测试用例。

Python · 解法代码def flatten(root): def helper(node): # 原地把 node 为根的子树拉直成一条只用 right 指针的链,返回链尾 if node is None: return None if node.left is None and node.right is None: return node left_tail = helper(node.left) right_tail = helper(node.right) if left_tail: left_tail.right = node.right # 左链的尾巴接上原来的右子树 node.right = node.left # 左子树整体搬到 right 位置 node.left = None # 必须清空,否则不是真正的"链" return right_tail or left_tail helper(root)
真实实测 · Python · 前几组用例的输出
$ python3 solution.py
[1,2,5,3,4,null,6]  →  right链=[1, 2, 3, 4, 5, 6]  残留left指针=false
[]  →  right链=[]  残留left指针=false
[0]  →  right链=[0]  残留left指针=false
[1,2]  →  right链=[1, 2]  残留left指针=false
[1,null,2]  →  right链=[1, 2]  残留left指针=false
Java · 解法代码class Solution { public void flatten(TreeNode root) { helper(root); } private TreeNode helper(TreeNode node) { if (node == null) return null; if (node.left == null && node.right == null) return node; TreeNode leftTail = helper(node.left); TreeNode rightTail = helper(node.right); if (leftTail != null) { leftTail.right = node.right; // 左链的尾巴接上原来的右子树 node.right = node.left; // 左子树整体搬到 right 位置 node.left = null; // 必须清空,否则不是真正的"链" } return rightTail != null ? rightTail : leftTail; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java && java -cp build Main
[1,2,5,3,4,null,6]  →  right链=[1, 2, 3, 4, 5, 6]  残留left指针=false
[]  →  right链=[]  残留left指针=false
[0]  →  right链=[0]  残留left指针=false
[1,2]  →  right链=[1, 2]  残留left指针=false
[1,null,2]  →  right链=[1, 2]  残留left指针=false
Go · 解法代码func flatten(root *TreeNode) { var helper func(node *TreeNode) *TreeNode helper = func(node *TreeNode) *TreeNode { if node == nil { return nil } if node.Left == nil && node.Right == nil { return node } leftTail := helper(node.Left) rightTail := helper(node.Right) if leftTail != nil { leftTail.Right = node.Right // 左链的尾巴接上原来的右子树 node.Right = node.Left // 左子树整体搬到 right 位置 node.Left = nil // 必须清空,否则不是真正的"链" } if rightTail != nil { return rightTail } return leftTail } helper(root) }
真实实测 · Go · 前几组用例的输出
$ go run solution.go
[1,2,5,3,4,null,6]  →  right链=[1, 2, 3, 4, 5, 6]  残留left指针=false
[]  →  right链=[]  残留left指针=false
[0]  →  right链=[0]  残留left指针=false
[1,2]  →  right链=[1, 2]  残留left指针=false
[1,null,2]  →  right链=[1, 2]  残留left指针=false
JavaScript · 解法代码function flatten(root) { function helper(node) { if (node === null) return null; if (node.left === null && node.right === null) return node; const leftTail = helper(node.left); const rightTail = helper(node.right); if (leftTail) { leftTail.right = node.right; // 左链的尾巴接上原来的右子树 node.right = node.left; // 左子树整体搬到 right 位置 node.left = null; // 必须清空,否则不是真正的"链" } return rightTail || leftTail; } helper(root); }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js
[1,2,5,3,4,null,6]  →  right链=[1, 2, 3, 4, 5, 6]  残留left指针=false
[]  →  right链=[]  残留left指针=false
[0]  →  right链=[0]  残留left指针=false
[1,2]  →  right链=[1, 2]  残留left指针=false
[1,null,2]  →  right链=[1, 2]  残留left指针=false
C++ · 解法代码class Solution { public: void flatten(TreeNode* root) { helper(root); } private: TreeNode* helper(TreeNode* node) { if (!node) return nullptr; if (!node->left && !node->right) return node; TreeNode* leftTail = helper(node->left); TreeNode* rightTail = helper(node->right); if (leftTail) { leftTail->right = node->right; // 左链的尾巴接上原来的右子树 node->right = node->left; // 左子树整体搬到 right 位置 node->left = nullptr; // 必须清空,否则不是真正的"链" } return rightTail ? rightTail : leftTail; } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin
[1,2,5,3,4,null,6]  →  right链=[1, 2, 3, 4, 5, 6]  残留left指针=false
[]  →  right链=[]  残留left指针=false
[0]  →  right链=[0]  残留left指针=false
[1,2]  →  right链=[1, 2]  残留left指针=false
[1,null,2]  →  right链=[1, 2]  残留left指针=false
真实实测 · 本机执行输出
共 5 组用例(含边界),以 Python 结果为基准逐组比对:\n  Python      通过 5/5\n  Java        通过 5/5\n  Go          通过 5/5\n  JavaScript  通过 5/5\n  C++         通过 5/5

随机对拍

Python 版本的原地递归实现另外和"先用数组收集、再重连并清空 left"的写法做了随机对拍,两者都同时检查 right 链的值序列和是否还有残留的 left 指针:

真实实测 · 本机执行输出
随机对拍 5,000 组(结点数 0 到 12 的随机二叉树),原地递归版与数组重建版(都清空 left)的结果全部一致,0 处不一致。

另外四种语言只在上面 5 组固定用例上验证过,没有做大规模随机对拍。

分步演示:自底向上,左链接右、右链接原右子树

下面的演示用官方示例 [1,2,5,3,4,null,6]:从叶子开始自底向上回溯,每处理完一个结点就把它的左子树(已经拉直成链)接到 right 位置、把左子树的链尾接到原来的右子树上,同时清空 left。蓝色是正在处理的结点,绿色是已经接入最终链的结点。每一帧都是把上面的解法在这组输入上真实执行记录下来的。

展开二叉树为链表:自底向上,左链接右、右链接原右子树
正在处理的结点已经接入链的结点

    参考与说明

    • 官方题目页:LeetCode 114. 二叉树展开为链表。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚。
    • 没有覆盖:利用二叉搜索树性质、不用递归栈的 Morris 遍历式 O(1) 额外空间写法(这里的原地递归用了 O(h) 的递归栈空间)。
    京ICP备2026067502号-1京公网安备11010502063487号