嘿,各位正在卷大厂、刷 LeetCode 的小伙伴们!今天咱们不聊那些枯燥的定义,咱们来聊聊程序员进阶路上的第一道真“坎儿”——树(Tree) 。
如果你觉得树的结构总是绕不明白,或者一写递归就头大,别担心,这说明你还没掌握树的“灵魂”。树这种数据结构,本质上就是一种优美的递归艺术。掌握了递归,你就掌握了树的通关密码。
一、 从大自然到代码:树到底是个啥?
别被“非线性数据结构”这种高大上的词吓到。其实,数据结构里的树,就是把大自然的树倒过来看:
- 树根(Root): 唯一的源头。一棵树只能有一个根,它是所有节点的祖先。
- 树枝(Edge): 连接节点的线。在代码里,这就是我们的指针或引用。
- 叶子(Leaf): 那些不再分叉的末端,它们的“度”为 0。
面试必考的高频概念(小笔记记好!)
在面试中,面试官可能会随口问你几个关于树的度量衡,这些术语一定要专业:
- 层次(Level): 根节点是第 1 层,往下一级 +1。
- 高度(Height): 必须从下往上看!叶子节点是 1,往上回溯。
- 度(Degree): 这个节点开了几个叉?比如二叉树,每个节点的度最大就是 2。
二、 二叉树:不只是“度为2的树”
很多人面试时会掉进这个坑:“二叉树不就是每个节点最多有两个子节点的树吗?”
不对! 二叉树有一个极其重要的特征:有序性。
二叉树的定义是递归的:
- 它可以是一棵空树。
- 如果不是空树,它由根节点、左子树、右子树组成,且左右子树本身也是二叉树。
核心知识点: 二叉树的左右子树顺序是严格确定的,不能随意交换。交换了,它就不是原来那棵树了。
在 JS 中如何定义一个节点?
我们通常用构造函数或 Class 来定义二叉树节点。注意看注释,这在面试写手写代码(Whiteboard Coding)时是标配:
JavaScript
class TreeNode {
constructor(val) {
this.val = val; // 数据域:存储当前节点的值
this.left = null; // 左引用的引用:指向左子树(初始为空)
this.right = null; // 右引用的引用:指向右子树(初始为空)
}
}
三、 为什么说“理解树”就是“理解递归”?
这是本文最硬核的部分。如果你去翻看二叉树的定义,你会发现它是用自己定义自己的。这种“套娃”逻辑,决定了递归是处理树的最自然、最优雅的手段。
1. 递归的精髓:找重复,定边界
写递归函数时,你脑子里要装两件事:
- 公式(重复子问题): 每一个子树的结构都和整棵树一模一样。
- 退出条件: 什么时候“套娃”结束?通常是遇到
null(空树)的时候。
2. 三大经典遍历:变的是根,不变的是“左右”
所谓的“前、中、后序”遍历,很多初学者背得滚瓜烂熟但一写就错。其实你只需要记住一句话:左右子树的相对顺序永远是“先左后右”,改变的只是“根节点”介入的时机。
A. 前序遍历 (Preorder): 根 -> 左 -> 右
就像侦查兵,先到达根部标记一下,再去侦查左边,最后侦查右边。
JavaScript
function preorder(root) {
// 1. 递归退出条件:遇到空节点就返回,这是防止死循环的“刹车”
if (!root) return;
// 2. 根节点的输出顺序:先访问根节点
console.log(root.val);
// 3. 递归处理左子树:把左孩子当成一棵新的树传进去
preorder(root.left);
// 4. 递归处理右子树
preorder(root.right);
}
B. 中序遍历 (Inorder): 左 -> 根 -> 右
这在二叉搜索树(BST)中极其重要,因为中序遍历的结果是有序的。
JavaScript
function inorder(root) {
if (!root) return; // 退出条件
inorder(root.left); // 先去最左边
console.log(root.val); // 回头看根节点
inorder(root.right); // 再去右边
}
C. 后序遍历 (Postorder): 左 -> 右 -> 根
典型应用是计算目录大小或删除树——你得先处理完所有子节点,才能处理父节点。
四、 实战演练:手动构建一棵树
面试时,面试官可能会给你一个对象字面量,让你写遍历。我们要学会在脑海里把代码“可视化”。
比如下面这个结构:
JavaScript
const root = {
val: 'A',
left: {
val: 'B',
left: { val: 'D', left: null, right: null },
right: { val: 'E', left: null, right: null }
},
right: {
val: 'C',
left: null,
right: { val: 'F', left: null, right: null }
}
}
跟着代码走一遍 preorder(root):
-
打印
A -
进入
A.left(即 B),打印B -
进入
B.left(即 D),打印D -
D.left为空,返回;D.right为空,返回。 -
回到 B,进入 B.right(即 E),打印 E...
以此类推。你会发现,递归其实是利用了函数调用栈帮我们记住了回家的路。
五、 层序遍历:打破递归的另一种思维
虽然递归很美,但有时候面试官会刁难你:“能不能不按深度走,而是按层走?”
这就是层序遍历(Level Order Traversal)。
层序遍历不能用简单的递归(因为递归本质是深度优先),我们需要借助一个外援:队列(Queue) 。
核心逻辑:
- 根节点入队。
- 只要队列不空,就弹出一个节点(FIFO)。
- 把它的值存起来,然后把它的左右孩子(如果有)依次塞进队尾。
JavaScript
function levelOrder(root) {
// 关键点 1:边界处理,空树直接返回空数组
if (!root) return [];
const result = [];
// 关键点 2:初始化队列,根节点先入队
const queue = [root];
// 关键点 3:迭代开始,只要队列里还有节点,就说明没走完
while (queue.length) {
// 关键点 4:shift() 弹出队首元素(先进先出)
const node = queue.shift();
result.push(node.val);
// 关键点 5:把下一层的种子撒下去
// 如果左孩子存在,入队
if (node.left) queue.push(node.left);
// 如果右孩子存在,入队
if (node.right) queue.push(node.right);
}
return result;
}
六、 总结与面试高分技巧
在面试中谈论树和递归时,如果你能顺便提到以下几点,面试官绝对会对你刮目相看:
-
空间复杂度: 递归虽然代码简洁,但它依赖系统栈。如果树太深(比如退化成链表),可能会导致栈溢出。
-
迭代 vs 递归: 所有的递归遍历都可以改写成基于栈的迭代写法。如果你能手写迭代版的前序遍历,那稳稳的加分项。
-
应用场景: * DOM 树:前端最熟悉的树,遍历 DOM 节点就是树的遍历。
- 路由配置:嵌套路由本质上也是一棵树。
- 表达式解析:比如
1 + (2 * 3)在计算机底层就是一棵表达式树。
最后送大家一句话:
理解树,不要去死记硬背代码,要在纸上画图。当你发现每一个节点都在做重复的“看自己、看左边、看右边”这三件事时,你就彻底掌握了递归的真谛。