引言
在编程学习中,有些概念初看抽象难懂,但一旦理解,便如打开新世界的大门。今天,我们将用最通俗易懂的方式,深入讲解三个经典主题:
- 树与二叉树 ------ 层级数据的王者;
- 列表转树 ------ 扁平数据如何变成立体结构;
- 爬楼梯问题 ------ 递归、记忆化与迭代的实战演练。
我们将根据实际代码,并逐行解释其原理。无论你是刚入门的新手,还是正在准备面试的开发者,这篇文章都将为你提供清晰、系统、完整的知识图谱。
第一部分:树与二叉树 ------ 数据的层级之美 🌳
1.1 什么是"树"?
自然界中的树有根、干、枝、叶。在计算机科学中,"树"是一种非线性 的数据结构,用于表示具有层级关系的数据。
"一棵树只有一个树根,向上伸展出无数的树支(非线性)。树枝上会有树叶。数据结构中,树是对自然界树的一种抽象。"
树的核心概念:
| 术语 | 含义 |
|---|---|
| 根节点(Root) | 整棵树的起点,没有父节点。 |
| 节点(Node) | 树的基本单元,可以包含数据和指向子节点的引用。 |
| 叶子节点(Leaf) | 没有子节点的节点。 |
| 边(Branch/Edge) | 连接两个节点的线。 |
| 层次(Level) | 根为第1层,往下依次+1。 |
| 高度(Height) | 叶子节点高度为0,往上依次+1。 |
| 深度(Depth) | 节点到根的距离(根深度为0或1,依定义而定)。 |
| 度(Degree) | 一个节点拥有的子节点数量。叶子节点度为0。 |
✅ 树的本质:一对多的关系结构。
1.2 什么是二叉树?
二叉树是树的一种特殊形式,具有严格规则:
"如果它不是空树,那么必须有根节点、左子树、右子树组成。且左右子树都是二叉树。左右子树有左右之分,是有顺序的。每个节点最多只有两个子节点。"
关键点:
- 最多两个孩子:左子树 + 右子树。
- 顺序不可交换:左 ≠ 右。
- 可以为空树 (即
null)。 - 不是所有节点度都为2!有些节点可能只有左孩子,或只有右孩子,甚至没有孩子。
❌ 常见误解:二叉树 ≠ 度为2的树。
✅ 正确认知:二叉树强调结构顺序,而非度数。
1.3 二叉树的节点定义
javascript
function TreeNode(val) {
this.val = val // 从右到左的执行顺序
this.left = this.right = null
}
或者用对象字面量(更直观):
javascript
const tree = {
val: 1,
left: {
val: 2,
left: { val: 4, left: null, right: null },
right: { val: 5, left: null, right: null }
},
right: {
val: 3,
left: null,
right: null
}
}
💡 在 JavaScript 中,树通常用嵌套对象 表示,每个节点有
val、left、right三个属性。
1.4 二叉树的遍历方式
遍历 = 访问树中每一个节点。根据访问根节点的时机不同 ,分为三种深度优先遍历(DFS):
(1)前序遍历(Preorder):根 → 左 → 右
javascript
function preorder(root) {
if (!root) { return };
console.log(root.val);
preorder(root.left);
preorder(root.right);
}
执行逻辑:
- 如果当前节点为空,直接返回(递归终止条件)。
- 先打印当前节点值(访问根)。
- 递归遍历左子树。
- 递归遍历右子树。
✅ 用途:复制树、序列化树、打印目录结构(先显示父目录)。
(2)中序遍历(Inorder):左 → 根 → 右
javascript
function inorder(root) {
if (!root) { return }
inorder(root.left);
console.log(root.val);
inorder(root.right);
}
执行逻辑:
- 先递归遍历左子树。
- 再访问当前节点。
- 最后遍历右子树。
✅ 特别重要:对二叉搜索树(BST) ,中序遍历结果是升序排列!
(3)后序遍历(Postorder):左 → 右 → 根
javascript
function postorder(root) {
if (!root) { return }
postorder(root.left);
postorder(root.right);
console.log(root.val);
}
执行逻辑:
- 先处理左子树。
- 再处理右子树。
- 最后处理当前节点。
✅ 用途:删除树(先删孩子再删自己)、计算目录总大小(先算子目录再汇总)。
三者共同点与区别
| 遍历方式 | 根访问顺序 | 左右顺序 | 典型用途 |
|---|---|---|---|
| 前序 | 最先 | 左→右 | 复制、打印 |
| 中序 | 中间 | 左→右 | 排序(BST) |
| 后序 | 最后 | 左→右 | 删除、汇总 |
⚠️ 注意:左右子树的访问顺序永远是"先左后右",这是约定俗成的。
1.5 层序遍历(BFS)------ 用队列实现
不同于上述"深度优先",层序遍历(Level Order) 是"广度优先":从上到下、从左到右一层层访问。
这需要借助队列(Queue) ------ 先进先出(FIFO)的数据结构。
javascript
// 层序遍历 level order traversal 树的层级从上到下
// 每一层从左到右遍历 迭代 借助队列
class TreeNode {
constructor(val) {
this.val = val
this.left = this.right = null
}
}
function levelOrder(root) {
if (!root) { return [] }
const queue = [root]
const res = []
while (queue.length) {
// 迭代 每一次循环都是一层
const node = queue.shift() // shift 出队 先进先出
res.push(node.val) // 每一层的节点值 放到 res 中
if (node.left) { queue.push(node.left) }
if (node.right) { queue.push(node.right) }
}
return res
}
逐行解析:
- 若根为空,返回空数组。
- 初始化队列,将根节点入队。
- 循环直到队列为空:
- 出队一个节点(
shift())。 - 将其值加入结果数组。
- 将其非空的左右孩子依次入队。
- 出队一个节点(
- 返回结果。
示例:对如下树
A
/ \
B C
/ \ \
D E F
层序结果为:['A', 'B', 'C', 'D', 'E', 'F']
💡 层序遍历常用于:按层级展示数据、求树的宽度、序列化/反序列化树。
第二部分:列表转树 ------ 扁平数据的立体重生
2.1 问题背景
数据库或 API 经常返回这样的扁平列表:
[
{ id: 1, name: '中国', parentId: 0 },
{ id: 2, name: '北京', parentId: 1 },
{ id: 3, name: '上海', parentId: 1 },
{ id: 4, name: '东城区', parentId: 2 }
]
其中:
parentId === 0表示根节点。- 其他节点通过
parentId指向父节点。
我们的目标:将其转换为树形结构 ,每个节点包含 children 数组。
2.2 方法一:递归法
javascript
// 扁平列表
// parentId 树状结构
// 树根 parentId 为 0
// 树结构 -> 递归实现
const list = [
{ id: 1, name: 'A', parentId: 0 },
{ id: 2, name: 'B', parentId: 1 },
{ id: 3, name: 'C', parentId: 2 },
{ id: 4, name: 'D', parentId: 3 },
]
// 递归
// 递归公式 分析重复的事
// 退出条件
function list2tree(list, parentId = 0) {
const result = []
list.forEach(item => {
// 外层循环
if (item.parentId === parentId) {
const children = list2tree(list, item.id)
if (children.length) {
item.children = children
}
result.push(item)
}
})
return result
}
// 优化版 es6语法
// 时间复杂度 O(n)
// 空间复杂度 O(n)
function list2tree(list, parentId = 0) {
return list
.filter(item => item.parentId === parentId)
.map(item => {
return {
...item,
children: list2tree(list, item.id)
}
})
}
console.log(list2tree(list))
原理:
- 对每个
parentId,找出所有直接子节点。 - 对每个子节点,递归构建它的子树。
- 使用
filter + map实现函数式风格,代码简洁。
⚠️ 缺点 :每次递归都要遍历整个列表,时间复杂度为 O(n²)(n 为节点数)。
2.3 方法二:Map 优化法
javascript
function listToTree(list) {
const nodeMap = new Map();
const tree = [];
list.forEach(item => {
nodeMap.set(item.id, { ...item, children: [] })
})
list.forEach(item => {
if (item.parentId === 0) {
tree.push(item)
} else {
const parentNode = nodeMap.get(item.parentId)
if (parentNode) {
parentNode.children.push(item)
}
}
})
return tree;
}
步骤详解:
- 第一遍遍历 :将所有节点存入
Map,以id为键,值为{...item, children: []}。- 这样后续可以通过
idO(1) 查找任意节点。
- 这样后续可以通过
- 第二遍遍历 :
- 若
parentId === 0,说明是根节点,加入tree。 - 否则,从
Map中取出父节点,将当前节点push到其children中。
- 若
✅ 优点:
- 时间复杂度:O(n)
- 空间复杂度:O(n)
- 无递归,不会栈溢出。
💡 这是工程中最推荐的做法!
2.4 实际应用场景
- 省市区三级联动选择器
- 后台管理系统的菜单树
- 文件资源管理器的目录结构
- 评论系统的嵌套回复(如 Reddit、知乎)
- 组织架构图
面试高频题!务必掌握两种解法。
第三部分:爬楼梯 ------ 递归的艺术与优化
3.1 问题描述(LeetCode #70)
力扣题链接:70. 爬楼梯 - 力扣(LeetCode)
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。
问:有多少种不同的方法可以爬到楼顶?
分析:
- 到第
n阶,只能从n-1(走1步)或n-2(走2步)来。 - 所以:
f(n) = f(n-1) + f(n-2) - 边界条件:
f(1)=1,f(2)=2
这正是斐波那契数列!
3.2 方法一:朴素递归
javascript
function climbStairs(n) {
if (n === 1) return 1;
if (n === 2) return 2;
return climbStairs(n-1) + climbStairs(n-2);
}
问题:
- 大量重复计算 !例如
f(5)会多次计算f(3)。 - 时间复杂度:O(2ⁿ) ------ 指数爆炸!
- 调用栈过深可能导致爆栈(Stack Overflow)。
❌ 不可用于实际项目。
3.3 方法二:记忆化递归(Memoization)
javascript
// 太多的重复计算 调用栈的内存的爆栈
// 空间换时间
const memo = {};
function climbStairs(n) {
if (n === 1) return 1;
if (n === 2) return 2;
if (memo[n]) return memo[n];
memo[n] = climbStairs(n-1) + climbStairs(n-2);
return memo[n];
}
改进:
- 用
memo缓存已计算结果。 - 每个
f(n)只计算一次。 - 时间复杂度:O(n) ,空间:O(n)。
⚠️ 但 memo 是全局变量,多次调用会污染(比如先算 f(10),再算 f(5),缓存还在)。
3.4 方法三:闭包私有化缓存(优雅解法)
javascript
// 外层函数创建闭包环境,私有化memo
const climbStairs = (function() {
// 缓存对象,被闭包持有,多次调用不会重置
const memo = {};
// 内层函数实现核心逻辑,访问外层的memo
return function climb(n) {
// 边界条件
if (n === 1) return 1;
if (n === 2) return 2;
// 优先读取缓存,避免重复递归计算
if (memo[n]) return memo[n];
// 递归计算并缓存结果
memo[n] = climb(n - 1) + climb(n - 2);
return memo[n];
};
})();
// 测试示例
console.log(climbStairs(3)); // 输出 3
console.log(climbStairs(5)); // 输出 8
console.log(climbStairs(10)); // 输出 55
优点:
memo被闭包保护,私有且持久。- 多次调用
climbStairs会复用缓存,效率高。 - 代码模块化,无全局污染。
✅ 适合需要多次调用的场景。
3.5 方法四:自底向上迭代
javascript
// 自底向上思考
// 迭代实现
// f(1) = 1;
// f(2) = 2;
// f(3) = f(2) + f(1);
// f(4) = f(3) + f(2);
// f(5) = f(4) + f(3);
function climbStairs(n) {
if (n === 1) return 1;
if (n === 2) return 2;
let f1 = 1;
let f2 = 2;
let f3 = 0;
for (let i = 3; i <= n; i++) {
f3 = f2 + f1;
f1 = f2;
f2 = f3;
}
return f3;
}
思路:
- 从
f(1)和f(2)开始,逐步计算到f(n)。 - 只需保存前两个值,无需数组或递归。
✅ 时间复杂度:O(n),空间复杂度:O(1)
✅ 无递归,无爆栈风险
✅ 最适合生产环境
总结:思维的升华
| 主题 | 核心思想 | 关键技巧 |
|---|---|---|
| 树与遍历 | 递归分解问题 | 前/中/后序(DFS),层序(BFS + 队列) |
| 列表转树 | 构建层级关系 | 递归(O(n²)) vs Map 优化(O(n)) |
| 爬楼梯 | 动态规划雏形 | 朴素递归 → 记忆化 → 迭代优化 |
这些题目看似独立,实则共享同一套思维模式:
- 递归:将大问题拆为相似的小问题。
- 优化:用空间换时间(缓存、Map)。
- 迭代:自底向上,避免递归开销。
编程不仅是写代码,更是建模现实、优化思维的过程。
希望这篇超详细指南,能让你彻底掌握这些经典问题。下次遇到树、列表转树、动态规划题时,你将胸有成竹,从容应对!