在计算机科学中,树是一种非常重要的非线性数据结构。它源于对自然界中“树”的抽象:树根对应根节点(root) ,树枝对应边(edge) ,而树叶则被抽象为叶子节点(leaf) 。这种结构天然具有层次性和递归性,非常适合用于表示具有层级关系的数据。
树的基本特性
一棵树有且仅有一个根节点,这是整棵树的起点。从根节点出发,向上伸展出无数的“树枝”,每个树枝连接一个子节点(child) ,形成父子关系。节点之间通过边相连,而没有任何环路——这是树区别于图的关键特征。
在树的术语体系中:
- 层次(Level) :从根节点开始计算,根为第1层,其子节点为第2层,依此类推。
- 高度(Height) :从叶子节点开始向上计数,叶子的高度为1,父节点的高度为其子树最大高度加1。
- 度(Degree) :指一个节点拥有的子树数量。叶子节点的度为0。
值得注意的是,二叉树不能被简单定义为 度为2的树。二叉树的核心在于有序性——每个节点最多有两个子节点,并且严格区分左子树和右子树,二者不可随意交换。
二叉树的定义与节点结构
二叉树可以是空树;若非空,则由三部分组成:根节点、左子树、右子树,且左右子树本身也是二叉树。这种递归定义使得二叉树天然适合用递归方法处理。
在 JavaScript 中,通常有两种主流方式来表示二叉树节点:构造函数(或类)方式 和 对象字面量方式。这两种写法各有适用场景,理解它们的差异有助于写出更清晰、可维护的代码。
构造函数方式:面向对象的封装
function TreeNode(val) {
this.val = val;
// 从右到左赋值,简洁地初始化左右指针
this.left = this.right = null;
}
它的优势在于:
- 可复用性强:每次调用
new TreeNode(val)都会创建一个结构一致的新节点; - 语义明确:
TreeNode作为一个类型标识,便于团队协作和类型推断;
例如,构建如下树:
const root = new TreeNode(1);
const node2 = new TreeNode(2);
const node3 = new TreeNode(3);
root.left = node2;
root.right = node3;
const node4 = new TreeNode(4);
const node5 = new TreeNode(5);
node2.left = node4;
node2.right = node5;
这种方式逻辑清晰,但略显冗长,尤其在快速验证想法或编写测试用例时,需要多次调用构造函数并手动连接指针。
对象字面量方式:声明式的直观表达
另一种常见写法是直接使用 JavaScript 的对象字面量:
let 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
}
};
或者更简洁(但需注意潜在风险):
const root = {
val: 'A',
left: {
val: 'B',
left: { val: 'D' },
right: { val: 'E' }
},
right: {
val: 'C',
left: { val: null },
right: { val: 'F' }
}
};
这种写法的优势在于:
- 声明即结构:代码本身就像一棵树的图形化表示,一眼就能看出层次关系;
- 无需额外变量:不用先创建节点再连线,直接嵌套定义;
然而,它也有局限性:
- 若省略
left或right字段(如{ val: 'D' }),访问node.left会得到undefined而非null,可能引发类型判断错误; - 不适合动态构建大型树,因为无法复用节点引用(容易产生重复对象而非共享引用);
因此,在实际开发中,构造函数方式更适合算法实现和工程化代码,而对象字面量更适合快速原型、教学示例和测试数据构造。两者并非对立,而是互补。
二叉树的遍历:递归的艺术
遍历是访问树中所有节点的过程。由于树的非线性结构,遍历顺序并非唯一。最常见的三种深度优先遍历方式均基于递归思想:
- 先序遍历(Preorder) :根 → 左 → 右
- 中序遍历(Inorder) :左 → 根 → 右
- 后序遍历(Postorder) :左 → 右 → 根
它们的共同点在于:左右子树的访问顺序始终不变,变化的只是根节点的输出时机。
先序遍历
function preorder(root) {
if (!root) return;
console.log(root.val); // 先处理当前根节点
preorder(root.left); // 再递归遍历左子树
preorder(root.right); // 最后遍历右子树
}
中序遍历
function inorder(root) {
if (!root) return;
inorder(root.left); // 先遍历左子树
console.log(root.val); // 再处理根节点
inorder(root.right); // 最后遍历右子树
}
后序遍历
function postorder(root) {
if (!root) return;
postorder(root.left); // 先遍历左子树
postorder(root.right); // 再遍历右子树
console.log(root.val); // 最后处理根节点
}
这三种遍历的递归退出条件一致:当当前节点为 null 时,停止递归。这种设计体现了“分而治之”的思想——将大问题分解为结构相同的子问题,直到达到最简情况(空节点)。
层序遍历:广度优先的迭代实现
与深度优先不同,层序遍历(Level Order Traversal) 按照树的层级从上到下、每层从左到右访问节点。它不依赖递归,而是借助队列(Queue) 实现,利用其“先进先出(FIFO)”的特性。
class TreeNode {
constructor(val) {
this.val = val;
this.left = this.right = null;
}
}
//迭代思想 借助队列
function levelOrder(root) {
if (!root) return []; // 空树直接返回空数组
const result = [];
const queue = [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; // 注意:应在循环结束后返回
}
层序遍历常用于求树的宽度、逐层打印、序列化/反序列化等场景,是连接树结构与队列这一线性结构的桥梁。
递归:树的灵魂
递归并非算法,而是一种编程策略。当一个函数直接或间接调用自身时,即构成递归。树的定义本身就是递归的——“二叉树由根节点、左子树、右子树组成,且左右子树也是二叉树”。因此,树与递归天然契合。
在解决树相关问题时,关键步骤是:
- 画出树形结构,观察重复模式;
- 识别子问题:左子树和右子树是否具有相同结构?
- 确定递归出口:通常是节点为
null的情况; - 组合结果:如何将子问题的解合并为原问题的解?
例如,在遍历中,我们并不需要显式“组合”结果(仅打印即可),但在求树高、判断对称、翻转二叉树等问题中,递归返回值的处理就至关重要。
总结
树作为一种基础而强大的数据结构,其核心在于层次性与递归性。二叉树作为树的特例,通过严格的左右子树区分,为各种高效算法(如二叉搜索树、堆、AVL树等)奠定了基础。
从节点定义到四种遍历方式,代码虽简,却蕴含深刻的设计思想:
- 递归体现“自相似”结构;
- 队列支撑广度优先探索;
- 明确的退出条件保障程序安全;
- 对象字面量提升可读性与调试效率。
特别值得强调的是,构造函数与对象字面量并非孰优孰劣,而是适用场景不同。前者强调行为一致性与可扩展性,后者强调结构直观与开发效率。在学习和面试中,应能灵活切换两种风格;在工程实践中,则应根据上下文选择最合适的表达方式。