在前端开发的日常工作中,树形结构无处不在。从浏览器渲染的 DOM 树,到 React/Vue 核心的 Virtual DOM,再到我们习以为常的 JSON 对象嵌套,本质上都是树形数据结构的应用。
然而,很多前端工程师在面对数据结构与算法时,往往止步于“能用数组解决一切”。二叉树(Binary Tree)作为非线性数据结构的基石,不仅是算法面试的必考题,更是理解高级概念(如堆、图、AST 语法树)的必经之路。
本文将结合严谨的计算机科学理论与现代 JavaScript 实践,带你彻底搞懂二叉树。
第一部分:什么是二叉树?不仅仅是“分两叉”
1. 严谨定义
在数据结构理论中,二叉树并不等同于“度为 2 的树”。二叉树是一种递归定义的数据结构:
- 它可以是空树。
- 如果不为空,它由一个根节点 (Root) 和两棵互不相交的、分别被称为左子树 (Left Subtree) 和右子树 (Right Subtree) 的二叉树组成。
关键点:二叉树是有序树。左子树和右子树的次序不能任意颠倒。即使某个节点只有一棵子树,也要区分它是左子树还是右子树。
2. 核心术语
为了与后端或算法岗同学高效沟通,我们需要掌握以下标准术语:
-
节点 (Node) :数据的存储单元,包含数据域和指针域。
-
度 (Degree) :
- 结点的度:一个结点拥有的子树个数(0, 1 或 2)。
- 树的度:树中所有结点的度的最大值。
-
叶子结点 (Leaf) :度为 0 的结点,即没有子树的结点。
-
深度 (Depth) / 高度 (Height) :树中结点的最大层数(根节点通常记为第 1 层)。
3. 特殊形态:满二叉树与完全二叉树
这是面试中极易混淆的概念,也是理解**堆(Heap)**的基础。
- 满二叉树 (Full Binary Tree) :
深度为 k 且有 2^k - 1 个结点的二叉树。
特征:每一层都“铺满”了结点,不存在任何缺口。 - 完全二叉树 (Complete Binary Tree) :
深度为 k 的有 n 个结点的二叉树,当且仅当其每一个结点都与深度为 k 的满二叉树中编号从 1 至 n 的结点一一对应。
特征:叶子结点只能出现在最下两层,且最下层的叶子结点集中在左侧。
4. 为什么前端需要了解这些性质?
在 C 语言等底层实现中,利用完全二叉树的性质,我们可以用数组(顺序存储)而非链表来存储树。
对于数组中任意位置 i 的元素(从 1 开始编号):
- 左孩子的位置是 2 * i
- 右孩子的位置是 2 * i + 1
- 父节点的位置是 Math.floor(i / 2)
这种性质是二叉堆 (Binary Heap) 的实现基础,而堆又是高效实现优先级队列(如 React 的 Scheduler 调度任务)的关键结构。此外,还有一个重要公式:
对任何一棵二叉树,如果其叶子结点数为 n0,度为 2 的结点数为 n2,则必有 n0 = n2 + 1。
第二部分:JavaScript 中的二叉树表示
在 JavaScript 中,我们通常使用链式存储的思想来表示二叉树。随着语言的发展,有三种常见的表达方式:
1. 对象字面量 (Object Literal)
最直观,常用于编写测试用例或 LeetCode 的输入数据。
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
}
};
2. ES5 构造函数 (Function)
早期的经典写法。
JavaScript
function TreeNode(val) {
this.val = val;
// 从右向左赋值,初始化左右指针为 null
this.left = this.right = null;
}
3. ES6 Class 语法 (推荐)
现代前端开发的标准写法,语义清晰,易于扩展。
JavaScript
class TreeNode {
constructor(val) {
this.val = val;
this.left = null;
this.right = null;
}
}
// 构建示例
const root = new TreeNode(1);
root.left = new TreeNode(2);
root.right = new TreeNode(3);
第三部分:深度优先遍历 (DFS)
二叉树的定义是递归的,因此递归是操作二叉树最自然的方式。深度优先遍历分为三种,其核心区别在于根节点 (Root) 被访问的时机。
记忆口诀:所谓“前、中、后”,指的都是根节点什么时候打印。左右子树的顺序永远是“先左后右”。
1. 前序遍历 (Pre-order)
顺序:根 -> 左 -> 右
应用:打印目录结构,复制树。
JavaScript
function preorder(root) {
if (!root) return;
// 1. 访问根节点
console.log(root.val);
// 2. 递归左子树
preorder(root.left);
// 3. 递归右子树
preorder(root.right);
}
2. 中序遍历 (In-order)
顺序:左 -> 根 -> 右
应用:对于二叉搜索树 (BST) ,中序遍历可以得到有序的数列。
JavaScript
function inorder(root) {
if (!root) return;
inorder(root.left);
console.log(root.val); // 根节点在中间访问
inorder(root.right);
}
3. 后序遍历 (Post-order)
顺序:左 -> 右 -> 根
应用:计算文件系统大小(先统计子目录,再汇总到父目录),销毁树。
JavaScript
function postorder(root) {
if (!root) return;
postorder(root.left);
postorder(root.right);
console.log(root.val); // 根节点最后访问
}
第四部分:广度优先遍历 (BFS) —— 层序遍历
深度优先关注“钻得深”,而广度优先关注“铺得广”。层序遍历要求从上到下、从左到右依次访问。
实现痛点:递归本质上是利用函数调用栈 (Stack),适合 DFS。而 BFS 需要先进先出 (FIFO) 的特性,因此我们需要借助 队列 (Queue) 。
在 JavaScript 中,我们可以使用数组的 push (入队) 和 shift (出队) 来模拟队列。
JavaScript
function levelOrder(root) {
if (!root) return [];
const result = [];
const queue = [root]; // 初始化队列
while (queue.length > 0) {
// 取出队头元素
const node = queue.shift();
result.push(node.val);
// 将左右子节点依次入队
if (node.left) {
queue.push(node.left);
}
if (node.right) {
queue.push(node.right);
}
}
return result;
}
第五部分:实际应用场景
为什么前端面试总考二叉树?因为它不仅仅是刷题,而是解决复杂问题的思维模型。
- DOM 遍历与操作
DOM 树本质上就是多叉树。当你使用 document.querySelectorAll 或者遍历 childNodes 时,你正在进行树的遍历。 - 对象深度克隆 (Deep Clone)
JavaScript 的对象嵌套结构就是一棵树。手写 deepClone 函数时,我们通常使用递归(DFS)来遍历每个属性,如果属性是对象则继续递归,这与二叉树的先序遍历逻辑异曲同工。 - 抽象语法树 (AST)
无论是 Babel 转译 ES6 代码,还是 ESLint 检查代码规范,第一步都是将代码解析为 AST。AST 是一棵极其复杂的树,对代码的修改本质上就是对这棵树的增删改查。
总结
二叉树是数据结构的敲门砖。通过本文,我们从底层的 C 语言定义出发,理解了满二叉树与完全二叉树的内存意义,并掌握了 JavaScript 中递归 (DFS) 与迭代 (BFS) 的标准写法。
学习建议:
不要只看代码,请打开编辑器,手动实现一遍 TreeNode 类和四种遍历方式。当你能徒手写出层序遍历时,你就已经迈过了数据结构的第一道门槛。