Skip to content

树 (Tree)

1. 核心概念与特性

树 (Tree) 是一种非线性的数据结构,用于表示具有层级关系(父子关系)的数据。它就像自然界中的树倒过来,根在上面,叶子在下面。

1.1 树的通用术语

术语描述
节点 (Node)树的基本单元,包含数据和指向子节点的引用。
根节点 (Root)树顶部的第一个节点,没有父节点。
叶子节点 (Leaf)没有子节点的节点(树的最末端)。
内部节点至少有一个子节点的节点(非叶子节点)。
边 (Edge)连接两个节点的线。
深度 (Depth)节点到根节点的边的数量。(根的深度为 0 或 1,视定义而定)。
高度 (Height)节点到最远叶子节点的边的数量。整棵树的高度就是根节点的高度。

1.2 树 (Tree) & 二叉树 (Binary Tree)

  • :一种非线性的、表示层级(父子)关系的数据结构(如 DOM 树)。
  • 二叉树:为了便于计算机处理而做出的限制——每个节点最多只能有两个子节点(左孩子和右孩子)。

1.3 二叉搜索树 (BST - Binary Search Tree)

BST 将数据的有序性融入了结构中。这使得在树中查找、插入和删除一个特定的值,平均时间复杂度能够从数组的 $O(n)$ 降维打击到 $O(\log n)$

  • 规则:对于任何节点,它的左子树上所有节点的值必须小于它的值;它的右子树上所有节点的值必须大于它的值。
  • 优势:在理想情况下,每次比较都能淘汰一半的数据,查找、插入的时间复杂度从 $O(n)$ 降为 $O(\log n)$
  • 致命弱点:如果按照从小到大的顺序(如 1, 2, 3, 4)插入节点,树会严重向右倾斜,退化成单向链表。此时时间复杂度又回到了最差的 $O(n)$。

1.4 自平衡二叉搜索树 (Self-Balancing BST)

为了解决 BST 会退化成链表的问题,“自平衡树”应运而生。它们能在每次插入或删除节点后,自动调整结构(通过旋转),保证树的左右两边大致一样高。

  • AVL 树:最早的平衡树,规则极其严格(左右子树高度差不能超过 1)。查询极快,但插入/删除时为了维持绝对平衡,需要频繁旋转,代价高。
  • 红黑树 (Red-Black Tree):一种近似平衡的树。它牺牲了极其微小的查询性能,换来了在插入/删除时极少的旋转次数(最多不超过 3 次旋转)。因此在各种编程语言底层(如 Java 的 TreeMap、C++ 的 map)被作为首选。

2. 深入理解红黑树 (Red-Black Tree) 🔴⚫

理解红黑树,核心在于记住它的“五大黄金规则”以及它的修复手段。

2.1 红黑树的五大规则

一棵满足 BST 特性的树,如果还满足以下 5 条,它就是红黑树,并自然拥有了平衡的魔力:

  • 节点颜色:每个节点要么是红色,要么是黑色
  • 根节点黑:根节点必须是黑色
  • 叶子节点黑:所有的叶子节点(这里指的是最底层的 NIL/Null 空节点)都是黑色的。
  • 不红红:如果一个节点是红色的,那么它的两个直接子节点都必须是黑色的。(绝对不能有两个红节点相连)。
  • 黑高平衡(最重要):从任一节点到其所有叶子节点的所有可能路径上,包含的黑色节点数量必须相同

2.2 失去平衡后的修复手段

当你插入一个新节点时,新节点默认总是红色的(因为这样最不容易破坏规则 5)。如果恰好它的父节点也是红色,就破坏了“不红红”规则。此时需要修复:

  • 变色:将节点的颜色红黑互换。
  • 左旋 (Left Rotate):逆时针旋转,使得右子节点被提拔为父节点,原父节点降级为左子节点。
  • 右旋 (Right Rotate):顺时针旋转,左子节点上位。

3. 模拟实现二叉搜索树 (BST)

在 JavaScript 中,我们通过类和对象的嵌套引用来实现树结构。

3.1 节点类 (Node)

js
class Node {
  constructor(key) {
    this.key = key;   // 节点的值
    this.left = null; // 指向左子节点的引用
    this.right = null;// 指向右子节点的引用
  }
}

3.2 二叉搜索树类 (BinarySearchTree)

包含了核心的插入、查找、遍历等方法。

js
class BinarySearchTree {
  constructor() {
    this.root = null; // 初始化树时,根节点为空
  }

  // --- 1. 插入节点 ---
  insert(key) {
    const newNode = new Node(key);
    if (this.root === null) {
      this.root = newNode; // 如果是空树,新节点就是根
    } else {
      this.insertNode(this.root, newNode); // 否则,调用辅助函数递归寻找插入位置
    }
  }

  // 辅助插入函数 (递归)
  insertNode(node, newNode) {
    // 如果新值小于当前节点的值,向左走
    if (newNode.key < node.key) {
      if (node.left === null) {
        node.left = newNode; // 左边没路了,就在这里安家
      } else {
        this.insertNode(node.left, newNode); // 继续向左递归
      }
    } 
    // 如果新值大于等于当前节点的值,向右走
    else {
      if (node.right === null) {
        node.right = newNode; // 右边没路了,在这里安家
      } else {
        this.insertNode(node.right, newNode); // 继续向右递归
      }
    }
  }

  // --- 2. 查找特定值是否存在 ---
  search(key) {
    return this.searchNode(this.root, key);
  }

  searchNode(node, key) {
    if (node === null) return false; // 找到底了还没找到
    
    if (key < node.key) {
      return this.searchNode(node.left, key); // 去左子树找
    } else if (key > node.key) {
      return this.searchNode(node.right, key); // 去右子树找
    } else {
      return true; // 找到了!
    }
  }

  // --- 3. 查找最小值和最大值 ---
  // 根据 BST 的特性,最小值一定在树的最左端,最大值一定在最右端
  min() {
    let current = this.root;
    while (current != null && current.left !== null) {
      current = current.left;
    }
    return current ? current.key : null;
  }

  max() {
    let current = this.root;
    while (current != null && current.right !== null) {
      current = current.right;
    }
    return current ? current.key : null;
  }

  // --- 4. 树的遍历 (极其重要) ---
  // ① 中序遍历 (In-order): 左 -> 根 -> 右
  // 特点:对于 BST,中序遍历会得到一个由小到大排序的序列!
  inOrderTraverse(callback) {
    this.inOrderTraverseNode(this.root, callback);
  }
  inOrderTraverseNode(node, callback) {
    if (node !== null) {
      this.inOrderTraverseNode(node.left, callback); // 先遍历左子树
      callback(node.key);                            // 访问当前节点
      this.inOrderTraverseNode(node.right, callback);// 再遍历右子树
    }
  }

  // ② 先序遍历 (Pre-order): 根 -> 左 -> 右
  // 特点:通常用于打印一棵结构化的树。
  preOrderTraverse(callback) {
    this.preOrderTraverseNode(this.root, callback);
  }
  preOrderTraverseNode(node, callback) {
    if (node !== null) {
      callback(node.key);                             // 先访问当前节点
      this.preOrderTraverseNode(node.left, callback); // 然后遍历左子树
      this.preOrderTraverseNode(node.right, callback);// 最后遍历右子树
    }
  }

  // ③ 后序遍历 (Post-order): 左 -> 右 -> 根
  // 特点:常用于计算目录/树的大小,因为你必须先知道所有子目录的大小才能计算当前目录。
  postOrderTraverse(callback) {
    this.postOrderTraverseNode(this.root, callback);
  }
  postOrderTraverseNode(node, callback) {
    if (node !== null) {
      this.postOrderTraverseNode(node.left, callback); // 先遍历左子树
      this.postOrderTraverseNode(node.right, callback);// 再遍历右子树
      callback(node.key);                              // 最后访问当前节点
    }
  }
}

// === 测试演示 ===
const tree = new BinarySearchTree();
tree.insert(11);
tree.insert(7);
tree.insert(15);
tree.insert(5);
tree.insert(9);
tree.insert(13);
tree.insert(20);

// 中序遍历结果:将按从小到大输出
console.log('中序遍历:');
tree.inOrderTraverse(value => console.log(value)); // 5, 7, 9, 11, 13, 15, 20

console.log('最小值:', tree.min()); // 5
console.log('搜索 9:', tree.search(9)); // true
console.log('搜索 8:', tree.search(8)); // false

4. 常见问题 (FAQ) 与面试高频考点

4.1 二叉搜索树的性能永远都是 $O(\log n)$ 吗?

  • 答:不是。
  • 最坏情况 (Worst Case):如果你按照按顺序插入节点(例如:依次插入 1, 2, 3, 4, 5),这棵树就会变成一条向右倾斜的长线,完全退化成了单向链表。此时查找的时间复杂度会暴跌至 $O(n)$
  • 如何解决:为了解决这个问题,计算机科学家发明了自平衡二叉搜索树。这类树在插入和删除节点时,会自动通过“旋转”操作来保持树的左右两边大致平衡,从而确保性能稳定在 $O(\log n)$。最著名的自平衡树是 AVL 树红黑树 (Red-Black Tree)

4.2 树的“广度优先搜索 (BFS)”和“深度优先搜索 (DFS)”有什么区别?

  • 深度优先 (DFS):一条路走到黑,撞到南墙(叶子节点)再回头。我们在上面代码中写的中序、先序、后序遍历,底层都是基于 DFS 实现的。DFS 通常使用递归或借助栈 (Stack) 来实现。
  • 广度优先 (BFS):也叫层序遍历。像水波纹一样,一层一层地向下遍历。BFS 不使用递归,它必须借助队列 (Queue) 来实现:将当前层的所有节点入队,然后依次出队并把它们的子节点入队。

4.3 面试高频题:如何翻转(镜像)一棵二叉树?(LeetCode 226)

  • 题目:给你一棵二叉树的根节点,翻转这棵二叉树,并返回其根节点。(比如左子节点变右子节点,右子节点变左子节点)。
  • 解法 (DFS)
js
var invertTree = function(root) {
    if (root === null) return null; // 递归终点
    
    // 1. 递归翻转左子树和右子树
    const left = invertTree(root.left);
    const right = invertTree(root.right);
    
    // 2. 交换当前节点的左右子树指针
    root.left = right;
    root.right = left;
    
    return root;
};

4.4 如果不用递归,怎么实现 DFS(如前序遍历)?

  • 答:所有的递归都可以用手动维护一个栈 (Stack) 来替代。 这样可以防止树太深导致系统调用栈溢出 (Maximum call stack size exceeded)。
  • 非递归前序遍历实现
js
function preOrderIterative(root) {
    // 1. 如果树为空,直接返回空数组
    if (root === null) {
      return [];
    }
    const result = [];    // 存放遍历结果
    const stack = [root]; // 手动维护的栈,初始化推入根节点
    // 2. 当栈不为空时,持续循环
    while (stack.length > 0) {
        // 弹出栈顶元素作为当前节点
        const currentNode = stack.pop();
        // 访问该节点(将值推入结果数组)
        result.push(currentNode.key);
        // 关键点:因为栈是后进先出 (LIFO),
        // 为了让左孩子先被访问,必须先压入右孩子,再压入左孩子!
        if (currentNode.right){
            stack.push(currentNode.right);
        }
        if (currentNode.left){
            stack.push(currentNode.left);
        }
    }
    return result
}

4.5 为什么数据库索引不用红黑树,而是用 B+ 树?

这是一道衡量计算机基础深度的经典题。

  • 红黑树的软肋:红黑树每个节点最多只有 2 个分支。当数据量千万级时,树会变得非常深(高)。在内存里这没事,但如果数据在硬盘里,树深意味着要进行多次磁盘 I/O(这是极慢的操作)。
  • B+ 树的优势:它是为了磁盘而生的“多叉树”。它的每个节点非常“胖”,能存几百个分支。这使得千万级的数据,B+ 树只需要 3 到 4 层的高度就能装下,大大减少了访问硬盘的次数。

4.6 为什么前端开发(Vue/React)需要掌握树?

  • 虚拟 DOM (Virtual DOM):现代前端框架的底层核心,就是一个巨大的由 JavaScript 对象构成的树形结构
  • DOM Diff 算法:当数据改变时,框架需要对比新旧两棵虚拟 DOM 树的差异。这个比对过程就是极其复杂的树的遍历算法
  • 组件通信:前端组件的嵌套关系(父-子-孙)天生就是一棵树。理解树的层级有助于理解事件冒泡、Context 上下文传递的路径。