对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎
LeetCode 114. 二叉树展开为链表:从树结构到线性结构的优雅转换
1. 题目描述
给定一个二叉树的根节点 root,请将该二叉树展开为一个单链表:
- 展开后的单链表应该同样使用
TreeNode,其中right子指针指向链表中下一个节点,而left子指针始终为null - 展开后的单链表应该与二叉树的前序遍历顺序相同
- 需要原地 展开,即
O(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. 问题分析
这是一个典型的树形结构转线性结构的问题,具有以下特点:
- 顺序要求:展开后的链表必须与前序遍历顺序一致
- 原地操作:不能创建新节点,只能修改现有节点的指针
- 空间限制:理想情况下应该使用常数级额外空间
前端视角理解:这类似于将DOM树中的节点按照特定顺序(如前序遍历)重新连接成链表。想象一下,你需要将一个嵌套的UI组件树(如React/Vue组件树)按照某种顺序扁平化,同时保持原有父子关系。
3. 解题思路
3.1 直观思路对比
| 思路 | 时间复杂度 | 空间复杂度 | 是否原地 | 实现难度 |
|---|---|---|---|---|
| 前序遍历+存储节点 | O(n) | O(n) | 否 | 简单 |
| 递归后序遍历 | O(n) | O(h) | 是 | 中等 |
| 迭代前驱节点 | O(n) | O(1) | 是 | 中等 |
| 栈辅助迭代 | O(n) | O(h) | 是 | 中等 |
最优解:迭代前驱节点法,时间复杂度 O(n),空间复杂度 O(1),完全符合题目要求。
3.2 核心算法思路详解
3.2.1 前序遍历+存储节点(基础解法)
- 对二叉树进行前序遍历,将节点按顺序存入数组
- 遍历数组,将每个节点的左指针设为null,右指针指向下一个节点
3.2.2 递归后序遍历(分治思想)
- 递归处理左子树,将其展开为链表
- 递归处理右子树,将其展开为链表
- 将左子树链表插入到根节点和右子树链表之间
3.2.3 迭代前驱节点(最优解)
核心思想:对于当前节点,找到其左子树中最右侧的节点(前驱节点),将右子树连接到该节点后,然后将左子树移到右侧。
算法步骤:
- 从根节点开始遍历
- 如果当前节点有左子树:
- 找到左子树中最右侧的节点(前驱节点)
- 将当前节点的右子树接到前驱节点的右侧
- 将左子树移到右侧,左子树置为null
- 移动到下一个节点(原左子树的根节点)
4. 各思路代码实现
4.1 前序遍历+存储节点法
javascript
/**
* 方法1:前序遍历+存储节点
* 时间复杂度:O(n),空间复杂度:O(n)
*/
var flatten = function(root) {
if (!root) return;
const nodes = [];
// 前序遍历收集节点
function preorder(node) {
if (!node) return;
nodes.push(node);
preorder(node.left);
preorder(node.right);
}
preorder(root);
// 重新连接节点
for (let i = 0; i < nodes.length - 1; i++) {
nodes[i].left = null;
nodes[i].right = nodes[i + 1];
}
// 处理最后一个节点
if (nodes.length > 0) {
nodes[nodes.length - 1].left = null;
nodes[nodes.length - 1].right = null;
}
};
4.2 递归后序遍历法
javascript
/**
* 方法2:递归后序遍历
* 时间复杂度:O(n),空间复杂度:O(h) 递归栈深度
*/
var flatten = function(root) {
// 辅助函数:展开树并返回最后一个节点
const flattenTree = function(node) {
if (!node) return null;
// 如果是叶子节点,直接返回
if (!node.left && !node.right) {
return node;
}
// 递归处理左右子树
const leftTail = flattenTree(node.left);
const rightTail = flattenTree(node.right);
// 如果存在左子树,需要将其插入到根节点和右子树之间
if (leftTail) {
leftTail.right = node.right;
node.right = node.left;
node.left = null;
}
// 返回最后一个节点
return rightTail || leftTail;
};
flattenTree(root);
};
4.3 迭代前驱节点法(最优解)
javascript
/**
* 方法3:迭代前驱节点法(最优解)
* 时间复杂度:O(n),空间复杂度:O(1)
*/
var flatten = function(root) {
let curr = root;
while (curr) {
// 如果当前节点有左子树
if (curr.left) {
// 找到左子树中最右侧的节点(前驱节点)
let predecessor = curr.left;
while (predecessor.right) {
predecessor = predecessor.right;
}
// 将当前节点的右子树接到前驱节点的右侧
predecessor.right = curr.right;
// 将左子树移到右侧
curr.right = curr.left;
curr.left = null;
}
// 处理下一个节点
curr = curr.right;
}
};
4.4 栈辅助迭代法
javascript
/**
* 方法4:栈辅助迭代
* 时间复杂度:O(n),空间复杂度:O(h)
*/
var flatten = function(root) {
if (!root) return;
const stack = [root];
let prev = null;
while (stack.length) {
const curr = stack.pop();
if (prev) {
prev.left = null;
prev.right = curr;
}
// 注意:栈是后进先出,所以先压入右子树
if (curr.right) {
stack.push(curr.right);
}
if (curr.left) {
stack.push(curr.left);
}
prev = curr;
}
};
5. 各实现思路的复杂度、优缺点对比表格
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 前序遍历+存储节点 | O(n) | O(n) | 思路简单,代码直观 | 不符合原地修改要求,空间占用大 | 学习理解、允许额外空间时 |
| 递归后序遍历 | O(n) | O(h) | 原地修改,代码简洁 | 递归栈空间可能较大(最坏O(n)) | 树深度不大时,代码简洁性优先 |
| 迭代前驱节点 | O(n) | O(1) | 完全原地,空间最优 | 逻辑稍复杂,需要理解前驱节点概念 | 严格空间限制,最优解要求 |
| 栈辅助迭代 | O(n) | O(h) | 避免递归,直观的前序遍历 | 需要栈空间,不是常数空间 | 避免递归,中等空间限制 |
6. 总结
6.1 核心要点总结
- 前序遍历是关键:所有解法都围绕前序遍历的顺序展开
- 指针操作是核心:二叉树展开本质上是重新连接节点的左右指针
- 空间复杂度是区分点:不同解法的主要区别在于空间使用
6.2 实际应用场景
6.2.1 前端开发中的应用
- DOM树扁平化处理:将嵌套的DOM结构按特定顺序转换为线性结构,便于批量操作
- 组件树遍历优化:在React/Vue等框架中,需要遍历组件树执行特定操作时
- 文件目录结构展示:将树形目录结构展开为扁平列表,支持面包屑导航
- 无限滚动列表:将树形评论结构展开为线性时间线显示