在前端开发中,DOM树、虚拟DOM Diff、路由匹配等场景都离不开树形结构。本文基于数据结构中最基础的「二叉树」,从概念到JS实现逐层拆解,帮你彻底吃透核心知识点。
一、树形结构:现实世界的抽象简化
我们熟悉的家谱、公司组织架构、文件夹目录,本质上都是「树」的具象形态。数据结构的树是对现实的简化抽象:
- 树根 → 根节点(唯一入口,没有父节点)
- 树枝 → 边(连接两个节点的关系)
- 树枝两端的分叉 → 子节点
- 末端没有分叉的细枝 → 叶子节点
和现实中的树相反,数据结构中的树是「根在上、枝叶在下」的倒置结构,所有节点都通过唯一的路径连接到根节点。
二、二叉树:特殊的树形结构
普通树的子节点没有顺序限制,而二叉树是每个节点最多有2个子树,且左右子树严格区分顺序的特殊树结构,这也是它和普通「度为2的树」最核心的区别:哪怕只有一个子节点,也必须明确是左孩子还是右孩子,交换左右子树会得到完全不同的二叉树。
二叉树的递归定义
二叉树天然适配递归思想,我们可以用递归的方式严格定义它,这也对应了递归实现的三个核心要素:
- 自顶向下拆分大问题:把一棵复杂的二叉树拆解为「根节点 + 左子树 + 右子树」三个同构的小问题
- 问题处理逻辑一致:无论子树多大,处理逻辑都是「访问根 → 遍历左子树 → 遍历右子树」
- 明确的退出条件:当遇到空节点时,停止递归
⚠️ 注意:二叉树允许为空树(没有根节点),这是它的特殊形态。
三、二叉树核心概念
掌握这几个基础概念,后续理解和实现遍历会更轻松:
| 概念 | 定义 |
|---|---|
| 层次 | 根节点所在层为第1层,子节点每深入一层,层次+1 |
| 高度 | 叶子节点高度为1,每向上回溯一层高度+1,整棵树的高度等于最大层次数 |
| 节点的度 | 一个节点拥有的子节点数量,二叉树的节点度最大为2 |
| 叶子节点 | 度为0的节点,也就是最后一层的末端节点 |
四、JS实现二叉树节点
二叉树的每个节点只需要包含三部分信息:存储的数据、左子节点的引用、右子节点的引用,用ES6 Class可以很直观地定义:
class TreeNode {
constructor(val, left = null, right = null) {
this.val = val; // 数据域:存储节点值
this.left = left; // 左子节点引用
this.right = right; // 右子节点引用
}
}
我们可以先构造一个示例二叉树,后续所有遍历方法都会基于这个结构验证结果:
// 构造如下结构的二叉树:
// 1
// / \
// 2 3
// / \
// 4 5
const root = new TreeNode(1);
root.left = new TreeNode(2);
root.right = new TreeNode(3);
root.left.left = new TreeNode(4);
root.left.right = new TreeNode(5);
五、二叉树的遍历(核心重点)
遍历是指按某种规则依次访问树中所有节点,且每个节点仅访问一次。根据访问根节点的时机不同,分为三类递归遍历,另外还有按层访问的迭代遍历。
5.1 递归遍历(最直观)
递归遍历的核心差异只有「访问根节点的顺序」,只要记住这个规则,代码几乎不用改:
① 前序遍历(根 → 左 → 右)
先访问根节点,再遍历左子树,最后遍历右子树。
function preorderTraversal(root) {
const res = [];
function dfs(node) {
if (!node) return; // 递归退出条件:遇到空节点停止
res.push(node.val); // 第一步:访问根节点
dfs(node.left); // 第二步:遍历左子树
dfs(node.right); // 第三步:遍历右子树
}
dfs(root);
return res;
}
// 输出:[1, 2, 4, 5, 3]
② 中序遍历(左 → 根 → 右)
先遍历左子树,再访问根节点,最后遍历右子树。
function inorderTraversal(root) {
const res = [];
function dfs(node) {
if (!node) return;
dfs(node.left); // 第一步:遍历左子树
res.push(node.val); // 第二步:访问根节点
dfs(node.right); // 第三步:遍历右子树
}
dfs(root);
return res;
}
// 输出:[4, 2, 5, 1, 3]
③ 后序遍历(左 → 右 → 根)
先遍历左子树,再遍历右子树,最后访问根节点。
function postorderTraversal(root) {
const res = [];
function dfs(node) {
if (!node) return;
dfs(node.left); // 第一步:遍历左子树
dfs(node.right); // 第二步:遍历右子树
res.push(node.val); // 第三步:访问根节点
}
dfs(root);
return res;
}
// 输出:[4, 5, 2, 3, 1]
💡 递归的优缺点:代码简洁易理解,但递归本质是函数不断入栈,如果树的深度过大(比如超过1万层),会出现栈溢出错误,此时需要用迭代方式替代。
5.2 迭代遍历(用栈/队列模拟递归)
递归的本质是利用系统栈保存临时状态,我们可以手动用栈/队列模拟这个过程,避免栈溢出问题。
① 前序遍历(迭代版)
思路和递归一致,用栈保存待访问的节点,由于栈是后进先出,需要先压入右子节点,再压入左子节点,保证左子节点先被访问。
function preorderTraversalIterative(root) {
if (!root) return [];
const res = [];
const stack = [root];
while (stack.length) {
const node = stack.pop();
res.push(node.val);
// 先压右再压左,保证左先出栈
if (node.right) stack.push(node.right);
if (node.left) stack.push(node.left);
}
return res;
}
// 输出:[1, 2, 4, 5, 3]
② 中序遍历(迭代版)
需要先一路向左走到最深的左子节点,再回溯访问根节点,最后转向右子树。
function inorderTraversalIterative(root) {
const res = [];
const stack = [];
let cur = root;
while (cur || stack.length) {
// 一路向左,把路径上的节点全部入栈
while (cur) {
stack.push(cur);
cur = cur.left;
}
// 左到底了,弹出节点访问
cur = stack.pop();
res.push(cur.val);
// 转向右子树
cur = cur.right;
}
return res;
}
// 输出:[4, 2, 5, 1, 3]
③ 后序遍历(迭代版)
后序需要保证根节点在所有子节点之后访问,我们可以用「标记法」:给每个节点加一个是否已访问的标记,第一次入栈时不访问,等左右子树都遍历完后再访问根节点。
function postorderTraversalIterative(root) {
const res = [];
const stack = [];
if (root) stack.push([root, false]); // [节点, 是否已访问]
while (stack.length) {
const [node, visited] = stack.pop();
if (visited) {
// 左右子树都已遍历完,访问根节点
res.push(node.val);
} else {
// 先压入根节点(标记为已访问),保证最后弹出访问
stack.push([node, true]);
// 先压右子树,再压左子树,保证左先出栈
if (node.right) stack.push([node.right, false]);
if (node.left) stack.push([node.left, false]);
}
}
return res;
}
// 输出:[4, 5, 2, 3, 1]
5.3 层序遍历(广度优先,迭代实现)
层序遍历是按「从上到下、从左到右」的顺序逐层访问节点,需要用到队列(先进先出)来保存每一层的节点:
function levelOrderTraversal(root) {
const res = [];
if (!root) return res;
const queue = [root];
while (queue.length) {
const node = queue.shift();
res.push(node.val);
// 左子节点先入队,保证同一层从左到右访问
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
return res;
}
// 输出:[1, 2, 3, 4, 5]
六、总结与实践提示
- 二叉树是前端领域最常用的树形结构,后续的二叉搜索树、平衡二叉树、红黑树都是基于它衍生而来;
- 递归遍历适合树深度较小的场景,代码简洁;迭代遍历适合深度大的场景,避免栈溢出;
- 实际开发中,虚拟DOM Diff、路由权限树、菜单渲染等场景都会用到树的遍历逻辑,掌握基础后才能应对复杂场景。
你可以把上面的代码复制到浏览器控制台直接运行,修改示例二叉树的结构,观察不同遍历方式的输出变化,加深理解。