树与二叉树:从抽象概念到代码实现

70 阅读6分钟

在计算机科学中,是一种非常重要的非线性数据结构。它源于对自然界中“树”的抽象:树根对应根节点(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' }
  }
};

这种写法的优势在于:

  • 声明即结构:代码本身就像一棵树的图形化表示,一眼就能看出层次关系;
  • 无需额外变量:不用先创建节点再连线,直接嵌套定义;

然而,它也有局限性:

  • 若省略 leftright 字段(如 { 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; // 注意:应在循环结束后返回
}

层序遍历常用于求树的宽度、逐层打印、序列化/反序列化等场景,是连接树结构与队列这一线性结构的桥梁。

递归:树的灵魂

递归并非算法,而是一种编程策略。当一个函数直接或间接调用自身时,即构成递归。树的定义本身就是递归的——“二叉树由根节点、左子树、右子树组成,且左右子树也是二叉树”。因此,树与递归天然契合

在解决树相关问题时,关键步骤是:

  1. 画出树形结构,观察重复模式;
  2. 识别子问题:左子树和右子树是否具有相同结构?
  3. 确定递归出口:通常是节点为 null 的情况;
  4. 组合结果:如何将子问题的解合并为原问题的解?

例如,在遍历中,我们并不需要显式“组合”结果(仅打印即可),但在求树高、判断对称、翻转二叉树等问题中,递归返回值的处理就至关重要。

总结

树作为一种基础而强大的数据结构,其核心在于层次性递归性。二叉树作为树的特例,通过严格的左右子树区分,为各种高效算法(如二叉搜索树、堆、AVL树等)奠定了基础。

从节点定义到四种遍历方式,代码虽简,却蕴含深刻的设计思想:

  • 递归体现“自相似”结构;
  • 队列支撑广度优先探索;
  • 明确的退出条件保障程序安全;
  • 对象字面量提升可读性与调试效率。

特别值得强调的是,构造函数与对象字面量并非孰优孰劣,而是适用场景不同。前者强调行为一致性与可扩展性,后者强调结构直观与开发效率。在学习和面试中,应能灵活切换两种风格;在工程实践中,则应根据上下文选择最合适的表达方式。