二叉树与递归算法实战:从树结构到 LeetCode 爬楼梯,一文吃透前端数据结构与递归思维
🚀 树结构是前端面试的必考点,递归是处理树问题的最佳武器! 本文从二叉树的定义、关键概念、JS 表达方式出发,深入前序/中序/后序/层序四种遍历算法,并结合 LeetCode 经典"爬楼梯"问题,带你彻底掌握递归思维与树结构的核心知识。
📖 前言
在计算机科学中,树(Tree) 是一种非常重要的非线性数据结构。它模拟了现实世界中树的形态------只不过在计算机中,我们通常把它"倒过来"画:
css
现实中的树: 计算机中的树:
🌳 A
/ | \ / \
枝 枝 枝 B C
/ | \ / \ / \
叶 叶 叶 D E F G
树结构在前端开发中无处不在:
- DOM 树(HTML 文档结构)
- 组件树(React/Vue 组件嵌套)
- 文件目录树
- 路由配置树
理解树结构和递归算法,是成为优秀前端工程师的必修课。
🧠 知识图谱
css
二叉树与递归算法
├── 🌳 一、树的基本概念
│ ├── 根节点、边、节点、叶子节点
│ ├── 层次、高度、度
│ └── 二叉树的严格定义
│
├── 🏗️ 二、JS 中如何表达二叉树
│ ├── 构造函数方式
│ └── 对象字面量方式
│
├── 🔄 三、二叉树的四种遍历
│ ├── 前序遍历(Preorder)
│ ├── 中序遍历(Inorder)
│ ├── 后序遍历(Postorder)
│ └── 层序遍历(Level Order)
│
├── 🧮 四、递归思维:以爬楼梯为例
│ ├── 自顶向下分解问题
│ ├── 递归公式
│ └── 退出条件
│
└── ⚠️ 五、递归的陷阱与优化
├── 栈溢出风险
└── 记忆化优化
🌳 一、树的基本概念
1.1 从现实世界到计算机世界
数据结构中的树,是对现实世界中树的简化抽象:
| 现实世界 | 计算机中的树 |
|---|---|
| 树根 | 根节点(Root) |
| 树枝 | 边(Edge) |
| 树枝的两端 | 节点(Node) |
| 树叶 | 叶子节点(Leaf) |
css
倒过来的树结构:
A ←── 根节点(Root)
/ \
/ \
B C ←── 子节点(Children)
/ \ / \
D E F G ←── 叶子节点(Leaf)
边:A-B, A-C, B-D, B-E, C-F, C-G
1.2 二叉树的严格定义
💡 二叉树不可以被简单定义为"每个节点的度为 2 的树" 。二叉树的左右子树位置是严格约定、不能交换的。
二叉树的递归定义:
- 它可以没有根节点,作为一棵空树存在
- 如果不是空树,那么必须由根节点 、左子树 、右子树组成,且左右子树都是二叉树
css
二叉树的递归定义:
二叉树 = null(空树)
或
二叉树 = {
根节点,
左子树: 二叉树,
右子树: 二叉树
}
示例:
A
/ \
B C
/ \ / \
D E F G
A 的左子树: A 的右子树:
B C
/ \ / \
D E F G
B 也是一棵二叉树(递归定义)
1.3 树的关键概念
| 概念 | 定义 | 示例 |
|---|---|---|
| 层次 | 根节点在第 1 层,子节点逐层加 1 | A(1), B/C(2), D/E/F/G(3) |
| 高度 | 叶子节点高度为 1,向上逐层加 1 | D/E/F/G(1), B/C(2), A(3) |
| 度 | 一个节点开叉出去的子树数量 | A 的度为 2,D 的度为 0 |
| 叶子节点 | 度为 0 的节点(没有子节点) | D、E、F、G |
less
层次与高度:
层次: 高度:
第1层 A A: 3
/ \
第2层 B C B: 2 C: 2
/ \ / \
第3层 D E F G D: 1 E: 1 F: 1 G: 1
(叶子节点高度为 1)
🏗️ 二、JS 中如何表达二叉树
2.1 构造函数方式
javascript
function TreeNode(data) {
this.data = data; // 数据域
this.left = this.right = null; // 左右子节点引用
}
// 创建节点
const root = new TreeNode('A');
root.left = new TreeNode('B');
root.right = new TreeNode('C');
2.2 对象字面量方式(更直观)
javascript
const tree = {
val: 'A',
left: {
val: 'B',
left: {
val: 'D',
left: null,
right: null
},
right: {
val: 'E',
left: null,
right: null
}
},
right: {
val: 'C',
left: {
val: 'F',
left: null,
right: null
},
right: {
val: 'G',
left: null,
right: null
}
}
};
css
JS 对象与树结构的映射:
{val:'A'} ←── 根节点
/ \
/ \
{val:'B'} {val:'C'} ←── 子节点
/ \ / \
/ \ / \
{D} {E} {F} {G} ←── 叶子节点(left/right 都为 null)
每个节点对象包含三个部分:
┌─────────────────────────────┐
│ val: 节点值(数据域) │
│ left: 左子节点引用 │
│ right: 右子节点引用 │
└─────────────────────────────┘
🔄 三、二叉树的四种遍历
遍历是指按某种顺序访问树中所有节点,且每个节点只访问一次。
3.1 前序遍历(Preorder):根 → 左 → 右
💡 先访问根节点,再访问左子树,最后访问右子树。
javascript
function preorder(root) {
// 退出条件:遇到空节点
if (!root) {
return;
}
// ① 访问根节点
console.log(`当前遍历节点是`, root.val);
// ② 递归遍历左子树
preorder(root.left);
// ③ 递归遍历右子树
preorder(root.right);
}
preorder(tree);
// 输出:A B D E C F G
css
前序遍历过程:
A
/ \
B C
/ \ / \
D E F G
访问顺序:
① A(根)
② B(左子树的根)
③ D(左子树的左)
④ E(左子树的右)
⑤ C(右子树的根)
⑥ F(右子树的左)
⑦ G(右子树的右)
结果:A → B → D → E → C → F → G
3.2 中序遍历(Inorder):左 → 根 → 右
💡 先访问左子树,再访问根节点,最后访问右子树。
javascript
function inorder(root) {
// 退出条件
if (!root) {
return;
}
// ① 递归遍历左子树
inorder(root.left);
// ② 访问根节点
console.log(`当前遍历节点是`, root.val);
// ③ 递归遍历右子树
inorder(root.right);
}
inorder(tree);
// 输出:D B E A F C G
css
中序遍历过程:
A
/ \
B C
/ \ / \
D E F G
访问顺序:
① D(最左)
② B(D 的根)
③ E(B 的右)
④ A(整棵树的根)
⑤ F(C 的左)
⑥ C(F 的根)
⑦ G(C 的右)
结果:D → B → E → A → F → C → G
3.3 后序遍历(Postorder):左 → 右 → 根
💡 先访问左子树,再访问右子树,最后访问根节点。
javascript
function postorder(root) {
// 退出条件
if (!root) {
return;
}
// ① 递归遍历左子树
postorder(root.left);
// ② 递归遍历右子树
postorder(root.right);
// ③ 访问根节点
console.log(`当前遍历节点是`, root.val);
}
postorder(tree);
// 输出:D E B F G C A
css
后序遍历过程:
A
/ \
B C
/ \ / \
D E F G
访问顺序:
① D(最左叶子)
② E(D 的兄弟)
③ B(D、E 的根)
④ F(右子树的最左)
⑤ G(F 的兄弟)
⑥ C(F、G 的根)
⑦ A(整棵树的根,最后访问)
结果:D → E → B → F → G → C → A
3.4 层序遍历(Level Order):按层访问
💡 从根节点开始,按层访问节点,每层从左到右。 使用队列实现。
javascript
function levelorder(root) {
const queue = []; // 队列
const result = []; // 结果数组
if (!root) {
return result;
}
queue.push(root); // 根节点入队
while (queue.length) {
const node = queue.shift(); // 出队
result.push(node.val); // 记录值
// 左子节点入队
if (node.left) {
queue.push(node.left);
}
// 右子节点入队
if (node.right) {
queue.push(node.right);
}
}
return result;
}
console.log(levelorder(tree));
// → ['A', 'B', 'C', 'D', 'E', 'F', 'G']
ini
层序遍历过程(队列实现):
初始:queue = [A]
第1轮:
出队 A,result = ['A']
A.left = B 入队,A.right = C 入队
queue = [B, C]
第2轮:
出队 B,result = ['A', 'B']
B.left = D 入队,B.right = E 入队
queue = [C, D, E]
第3轮:
出队 C,result = ['A', 'B', 'C']
C.left = F 入队,C.right = G 入队
queue = [D, E, F, G]
第4~7轮:
依次出队 D、E、F、G(都没有子节点)
result = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
3.5 四种遍历对比
| 遍历方式 | 顺序 | 实现方式 | 应用场景 |
|---|---|---|---|
| 前序 | 根 → 左 → 右 | 递归 | 复制树、序列化 |
| 中序 | 左 → 根 → 右 | 递归 | 二叉搜索树排序 |
| 后序 | 左 → 右 → 根 | 递归 | 删除树、计算高度 |
| 层序 | 按层从左到右 | 队列(迭代) | BFS、最短路径 |
ini
遍历口诀:
前序:根左右 → 先打印根,再递归左右
中序:左根右 → 先递归左,再打印根,再递归右
后序:左右根 → 先递归左右,最后打印根
记忆技巧:
"前" = 根在最前
"中" = 根在中间
"后" = 根在最后
🧮 四、递归思维:以爬楼梯为例
4.1 问题描述
假设你正在爬楼梯。需要
n阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。问有多少种不同的方法可以爬到楼顶?
4.2 自顶向下分解问题
scss
爬楼梯问题的树状分解:
f(10) ------ 爬到第 10 阶的方法数
│
├── 最后一步跨 1 阶 → f(9)
│
└── 最后一步跨 2 阶 → f(8)
f(10) = f(9) + f(8)
继续分解:
f(10)
/ \
f(9) f(8)
/ \ / \
f(8) f(7) f(7) f(6)
│
...
f(1) = 1 (只有1种:跨1阶)
f(2) = 2 (2种:1+1 或 2)
4.3 递归三要素
javascript
function climbStairs(n) {
// ① 退出条件(边界条件)
if (n == 1) {
return 1;
}
if (n == 2) {
return 2;
}
// ② 递归公式
return climbStairs(n - 1) + climbStairs(n - 2);
}
console.log(climbStairs(10)); // → 89
| 要素 | 说明 | 示例 |
|---|---|---|
| 退出条件 | 递归的终止条件,防止无限递归 | n == 1 返回 1,n == 2 返回 2 |
| 递归公式 | 将大问题分解为小问题的规律 | f(n) = f(n-1) + f(n-2) |
| 规模递减 | 每次递归问题规模都在减小 | n → n-1 和 n-2 |
4.4 递归与树的联系
scss
递归调用树:
f(5)
/ \
f(4) f(3)
/ \ / \
f(3) f(2) f(2) f(1)
│
f(2) f(1)
递归的本质就是树!
每个函数调用是一个节点
每次递归调用是子节点
💡 树状结构用递归:因为树本身就是递归定义的(每个子树也是树),所以递归是处理树问题的最自然方式。
⚠️ 五、递归的陷阱与优化
5.1 栈溢出风险
javascript
// 递归深度过大时,会爆栈!
// climbStairs(10000) → RangeError: Maximum call stack size exceeded
scss
递归调用栈:
climbStairs(5)
climbStairs(4)
climbStairs(3)
climbStairs(2) ← 返回 2
climbStairs(1) ← 返回 1
climbStairs(2) ← 返回 2
climbStairs(3)
...
每层递归都会占用栈内存
深度过大 → 栈内存耗尽 → 爆栈
5.2 记忆化优化
javascript
// 使用记忆化(Memoization)避免重复计算
function climbStairsMemo(n, memo = {}) {
// 退出条件
if (n == 1) return 1;
if (n == 2) return 2;
// 如果已经计算过,直接返回缓存结果
if (memo[n]) {
return memo[n];
}
// 计算并缓存
memo[n] = climbStairsMemo(n - 1, memo) + climbStairsMemo(n - 2, memo);
return memo[n];
}
console.log(climbStairsMemo(100)); // → 可以快速计算!
| 优化方式 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 纯递归 | O(2^n) | O(n) | 大量重复计算 |
| 记忆化递归 | O(n) | O(n) | 缓存中间结果 |
| 动态规划 | O(n) | O(1) | 自底向上迭代 |
📊 核心知识速查表
树的关键概念
| 概念 | 定义 |
|---|---|
| 根节点 | 树的最顶层节点 |
| 叶子节点 | 度为 0 的节点(没有子节点) |
| 度 | 节点拥有的子树数量 |
| 层次 | 根节点在第 1 层,逐层加 1 |
| 高度 | 叶子节点高度为 1,向上逐层加 1 |
遍历对比
| 遍历 | 顺序 | 根的位置 | 代码模式 |
|---|---|---|---|
| 前序 | 根 → 左 → 右 | 最先 | console.log → 递归左 → 递归右 |
| 中序 | 左 → 根 → 右 | 中间 | 递归左 → console.log → 递归右 |
| 后序 | 左 → 右 → 根 | 最后 | 递归左 → 递归右 → console.log |
| 层序 | 按层 | --- | 队列实现 |
递归三要素
| 要素 | 作用 | 示例 |
|---|---|---|
| 退出条件 | 终止递归 | if (n <= 2) return n |
| 递归公式 | 分解问题 | f(n) = f(n-1) + f(n-2) |
| 规模递减 | 向退出条件靠近 | n → n-1 |
💡 学习建议
- 画图理解:每次遇到树的问题,先在纸上画出树结构和遍历顺序
- 掌握递归三要素:退出条件、递归公式、规模递减缺一不可
- 前中后序对比记忆:记住"根"的位置------前(根在前)、中(根在中)、后(根在后)
- 层序用队列:BFS 问题优先考虑队列实现
- 注意栈溢出:递归深度过大时考虑记忆化或改迭代
📚 推荐阅读
- MDN - 递归
- LeetCode 94 --- 二叉树的中序遍历
- LeetCode 102 --- 二叉树的层序遍历
- LeetCode 70 --- 爬楼梯
- 《数据结构与算法 JavaScript 描述》--- 树与二叉树
🏷️ 标签 :
JavaScript二叉树递归数据结构算法遍历LeetCode前端面试
如果这篇文章帮你理解了二叉树和递归的本质,欢迎点赞 + 收藏 + 关注!有任何疑问欢迎在评论区交流~ 🎉