算法篇 | 二叉树
前言
二叉树是算法面试中的”万题之源”。很多看似复杂的问题,拆解到最后都回归到二叉树的遍历和递归。如果你能熟练地在二叉树上做 DFS 和 BFS,很多其他数据结构和算法的题目也会变得容易。
对前端工程师来说,二叉树并不陌生——DOM 树就是树结构,虚拟 DOM 的 diff 算法、组件树的渲染、路由匹配,底层都涉及树的遍历。所以掌握二叉树不仅是为了面试,也是理解这些前端核心机制的基础。
二叉树的题目看起来种类繁多,但核心就两件事:怎么遍历和在遍历的过程中做什么。本章我们会系统地讲解四种遍历方式(前/中/后/层序)的递归和迭代实现,然后通过经典题目帮你建立”拿到一道二叉树题,第一步该怎么想”的思维框架。
诊断自测
Q1:前序遍历、中序遍历、后序遍历的区别是什么?分别在什么场景下使用?
点击查看答案
三者的区别在于”处理当前节点”的时机:前序是先处理再递归左右子树,中序是先递归左子树再处理再递归右子树,后序是先递归左右子树再处理。
场景:前序适合复制/序列化树结构;中序在 BST 上可以得到有序序列;后序适合需要先知道子树结果才能计算当前节点的场景(如计算高度、删除节点)。
Q2:BFS 和 DFS 在二叉树上的区别是什么?各自的典型应用场景是什么?
点击查看答案
BFS(广度优先)逐层遍历,使用队列实现,典型应用是层序遍历、最短路径、按层收集节点。DFS(深度优先)沿一条路径走到底再回溯,使用递归或栈实现,典型应用是路径问题、子树问题、回溯搜索。
Q3:如何验证一棵二叉搜索树(BST)是否合法?
点击查看答案
方法一:中序遍历 BST 应该得到一个严格递增序列,检查是否单调递增即可。方法二:递归验证,每个节点都有一个合法的值域范围 (min, max),根节点的范围是 (-Infinity, Infinity),左子节点的上界更新为父节点的值,右子节点的下界更新为父节点的值。
一、二叉树的遍历
1.1 节点定义
class TreeNode {
constructor(val = 0, left = null, right = null) {
this.val = val;
this.left = left;
this.right = right;
}
}
1.2 前序遍历(Pre-order):根 → 左 → 右
递归版:
function preorderTraversal(root) {
const result = [];
function dfs(node) {
if (node === null) return;
result.push(node.val); // 先处理当前节点
dfs(node.left);
dfs(node.right);
}
dfs(root);
return result;
}
迭代版(用栈模拟递归):
function preorderTraversal(root) {
if (root === null) return [];
const result = [];
const stack = [root];
while (stack.length > 0) {
const node = stack.pop();
result.push(node.val);
// 先压右再压左,这样左子节点先出栈
if (node.right) stack.push(node.right);
if (node.left) stack.push(node.left);
}
return result;
}
1.3 中序遍历(In-order):左 → 根 → 右
递归版:
function inorderTraversal(root) {
const result = [];
function dfs(node) {
if (node === null) return;
dfs(node.left);
result.push(node.val); // 在中间处理当前节点
dfs(node.right);
}
dfs(root);
return result;
}
迭代版:
function inorderTraversal(root) {
const result = [];
const stack = [];
let curr = root;
while (curr !== null || stack.length > 0) {
// 一路向左,把所有左子节点压栈
while (curr !== null) {
stack.push(curr);
curr = curr.left;
}
// 弹出栈顶(最左的未处理节点)
curr = stack.pop();
result.push(curr.val);
// 转向右子树
curr = curr.right;
}
return result;
}
中序遍历的特殊意义:对 BST 做中序遍历,得到的是一个有序序列。这个性质在很多 BST 相关的题目中都会用到。
1.4 后序遍历(Post-order):左 → 右 → 根
递归版:
function postorderTraversal(root) {
const result = [];
function dfs(node) {
if (node === null) return;
dfs(node.left);
dfs(node.right);
result.push(node.val); // 最后处理当前节点
}
dfs(root);
return result;
}
迭代版(技巧:前序的变体,最后反转):
function postorderTraversal(root) {
if (root === null) return [];
const result = [];
const stack = [root];
while (stack.length > 0) {
const node = stack.pop();
result.push(node.val);
// 先压左再压右(和前序相反)
if (node.left) stack.push(node.left);
if (node.right) stack.push(node.right);
}
return result.reverse(); // 最后反转
}
后序遍历的典型应用:当你需要先知道子树的结果才能计算当前节点时,就用后序。比如:计算树的高度、判断是否平衡、删除节点等。
1.5 层序遍历(Level-order):逐层从左到右
层序遍历使用 BFS,核心数据结构是队列。
function levelOrder(root) {
if (root === null) return [];
const result = [];
const queue = [root];
while (queue.length > 0) {
const levelSize = queue.length;
const currentLevel = [];
for (let i = 0; i < levelSize; i++) {
const node = queue.shift();
currentLevel.push(node.val);
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
result.push(currentLevel);
}
return result;
}
关键细节:levelSize = queue.length 必须在 for 循环之前取值,因为循环中会往队列里加新元素。levelSize 记录的是当前层的节点数。
二、BFS vs DFS
| 特征 | BFS | DFS |
|---|---|---|
| 数据结构 | 队列 | 栈(或递归) |
| 遍历顺序 | 逐层 | 沿路径走到底 |
| 空间复杂度 | O(w),w 是最大宽度 | O(h),h 是树高 |
| 适用场景 | 层序遍历、最短路径、逐层处理 | 路径问题、子树问题、回溯 |
| 找最近/最浅的节点 | 更适合(第一个找到的就是最近的) | 需要遍历所有路径才能确定 |
怎么选? 一般来说:
- 题目涉及”层”或”最短”→ BFS
- 题目涉及”路径”、“子树”、“递归定义” → DFS
- 如果都可以,DFS 通常代码更简洁(递归写法)
三、经典题目
3.1 二叉树的最大深度(LeetCode 104)
给定一棵二叉树,找出其最大深度。
这是入门级的二叉树题目,但它完美展示了递归的思维方式。
function maxDepth(root) {
if (root === null) return 0;
const leftDepth = maxDepth(root.left);
const rightDepth = maxDepth(root.right);
return Math.max(leftDepth, rightDepth) + 1;
}
一行代码的含义:当前树的最大深度 = 左子树深度和右子树深度的较大值 + 1。
也可以用 BFS 做——层数就是深度:
function maxDepth(root) {
if (root === null) return 0;
const queue = [root];
let depth = 0;
while (queue.length > 0) {
const size = queue.length;
for (let i = 0; i < size; i++) {
const node = queue.shift();
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
depth++;
}
return depth;
}
复杂度:时间 O(n),空间 O(h)(递归)或 O(w)(BFS)。
3.2 翻转二叉树(LeetCode 226)
翻转一棵二叉树(所有节点的左右子树互换)。
function invertTree(root) {
if (root === null) return null;
// 交换左右子树
[root.left, root.right] = [root.right, root.left];
// 递归翻转子树
invertTree(root.left);
invertTree(root.right);
return root;
}
这道题有个有趣的典故:Homebrew 的作者 Max Howell 在 Google 面试时因为没做出这道题而被拒了。他在推特上吐槽说”我造了大多数 Mac 开发者都用的工具,但我不会在白板上翻转二叉树”。
复杂度:时间 O(n),空间 O(h)。
3.3 验证二叉搜索树(LeetCode 98)
给定一棵二叉树,判断它是否是合法的二叉搜索树(BST)。
BST 的定义:左子树所有节点 < 根 < 右子树所有节点。注意是”所有节点”,不仅仅是直接子节点。
方法一:递归验证(传递值域)
function isValidBST(root) {
return validate(root, -Infinity, Infinity);
}
function validate(node, min, max) {
if (node === null) return true;
if (node.val <= min || node.val >= max) {
return false;
}
return (
validate(node.left, min, node.val) &&
validate(node.right, node.val, max)
);
}
方法二:中序遍历检查单调性
function isValidBST(root) {
let prev = -Infinity;
function inorder(node) {
if (node === null) return true;
if (!inorder(node.left)) return false;
if (node.val <= prev) return false;
prev = node.val;
return inorder(node.right);
}
return inorder(root);
}
复杂度:时间 O(n),空间 O(h)。
3.4 二叉树的最近公共祖先(LeetCode 236)
给定一棵二叉树和两个节点 p、q,找出它们的最近公共祖先(LCA)。
这是面试高频题,也是理解后序遍历的好例子。
function lowestCommonAncestor(root, p, q) {
// base case
if (root === null || root === p || root === q) {
return root;
}
// 后序遍历:先递归左右子树
const left = lowestCommonAncestor(root.left, p, q);
const right = lowestCommonAncestor(root.right, p, q);
// 当前层逻辑
if (left !== null && right !== null) {
return root; // p 和 q 分别在左右子树中,当前节点就是 LCA
}
return left !== null ? left : right;
}
理解这段代码的关键:
- 如果当前节点是 p 或 q,直接返回当前节点
- 递归查找左右子树
- 如果左右子树的返回值都不为 null,说明 p 和 q 分别在左右子树中,当前节点就是 LCA
- 如果只有一边不为 null,说明 p 和 q 都在那一边,返回那边的结果
复杂度:时间 O(n),空间 O(h)。
四、二叉树题的解题思维框架
拿到一道二叉树题,按这个思路想:
- 能否用递归定义? 大多数二叉树题可以分解为”当前节点 + 左子树 + 右子树”的递归结构
- 用什么遍历顺序?
- 需要先处理当前节点 → 前序
- 需要在 BST 上有序处理 → 中序
- 需要先知道子树结果 → 后序
- 需要逐层处理 → 层序(BFS)
- 递归函数返回什么? 高度、布尔值、节点、还是其他信息
- 需要什么辅助信息? 有时需要传递额外参数(如值域范围、路径和等)
常见误区
误区一:“递归就是 DFS,迭代就是 BFS”
不对。DFS 可以用递归实现,也可以用栈迭代实现。BFS 用队列迭代实现。“递归 vs 迭代”和”DFS vs BFS”是两个独立的维度。
误区二:“前序/中序/后序只是遍历顺序不同,实际效果一样”
不一样。它们决定了”处理当前节点”的时机,这对结果有本质影响。比如验证 BST 必须用中序(利用有序性),计算深度用后序更自然(先知道子树深度)。选错遍历顺序可能导致代码复杂度大增甚至写不出来。
误区三:“层序遍历直接用 queue.shift() 就行了”
Array.shift() 的时间复杂度是 O(n)(因为要移动所有元素),严格来说用数组模拟队列的性能并不好。面试中一般可以接受,但如果面试官追问,你应该知道可以用双指针(维护一个 front 指针)或链表来实现 O(1) 的出队操作。
误区四:“二叉搜索树的验证只需要检查每个节点和它的直接子节点的关系”
这是一个经典的 Bug。仅检查 node.left.val < node.val < node.right.val 是不够的。比如下面这棵树通过了局部检查但不是合法 BST:
5
/ \
1 6
/ \
3 7
节点 3 虽然 < 6(它的父节点),但它 < 5(它祖父节点),不应该出现在 5 的右子树中。所以必须传递值域范围来验证。
小结
二叉树是算法面试的核心题型,掌握了二叉树的遍历和递归思维,很多其他问题都能迎刃而解。
核心要点
- 四种遍历:前序(根左右)、中序(左根右)、后序(左右根)、层序(BFS)
- 递归三要素:base case、递归调用、当前层逻辑
- 选择遍历顺序:根据”处理当前节点”的时机来选择
- BFS vs DFS:层相关问题用 BFS,路径/子树问题用 DFS
- BST 的特殊性质:中序遍历有序,验证需要传递值域范围
本章思维导图
- 四种遍历
- 前序:根 → 左 → 右(递归 + 栈迭代)
- 中序:左 → 根 → 右(BST 有序性)
- 后序:左 → 右 → 根(需要子树结果时用)
- 层序:BFS + 队列
- DFS vs BFS
- DFS:栈/递归,路径/子树问题
- BFS:队列,层相关/最短路径问题
- 递归思维
- base case:null 节点
- 递归调用:对左右子树递归
- 当前层逻辑:根据遍历顺序处理
- 递归函数返回什么?高度、布尔、节点等
- 经典题
- 最大深度(LeetCode 104)
- 翻转二叉树(LeetCode 226)
- 验证 BST(LeetCode 98)
- 最近公共祖先(LeetCode 236)
- 解题框架
- 能否递归定义?
- 什么遍历顺序?
- 返回什么?需要什么辅助信息?
练习挑战
第一题 ⭐:二叉树的最小深度(LeetCode 111)
给定一棵二叉树,找出其最小深度(根节点到最近叶子节点的路径上的节点数量)。
点击查看答案
function minDepth(root) {
if (root === null) return 0;
// 如果只有右子树,不能返回 0(左子树为空不算叶子)
if (root.left === null) return minDepth(root.right) + 1;
if (root.right === null) return minDepth(root.left) + 1;
return Math.min(minDepth(root.left), minDepth(root.right)) + 1;
}
注意和最大深度的区别:最小深度必须到叶子节点。如果一个节点只有左子树没有右子树,它不是叶子,不能返回右子树的深度 0。
用 BFS 更简洁——第一个遇到的叶子节点所在的层就是最小深度:
function minDepth(root) {
if (root === null) return 0;
const queue = [root];
let depth = 1;
while (queue.length > 0) {
const size = queue.length;
for (let i = 0; i < size; i++) {
const node = queue.shift();
if (node.left === null && node.right === null) return depth;
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
depth++;
}
return depth;
}
时间复杂度 O(n),空间复杂度 O(n)。
第二题 ⭐⭐:二叉树的右视图(LeetCode 199)
给定一棵二叉树,返回从右侧看到的节点值(每层最右边的节点)。
点击查看答案
function rightSideView(root) {
if (root === null) return [];
const result = [];
const queue = [root];
while (queue.length > 0) {
const size = queue.length;
for (let i = 0; i < size; i++) {
const node = queue.shift();
if (i === size - 1) {
result.push(node.val); // 每层最后一个节点
}
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
}
return result;
}
思路:BFS 层序遍历,每层取最后一个节点。也可以用 DFS:先递归右子树再递归左子树,第一次到达新的深度时记录节点值。
时间复杂度 O(n),空间复杂度 O(n)。
第三题 ⭐⭐⭐:二叉树的序列化与反序列化(LeetCode 297)
设计一个算法将二叉树序列化为字符串,以及从字符串反序列化回二叉树。
点击查看答案
function serialize(root) {
const result = [];
function dfs(node) {
if (node === null) {
result.push('null');
return;
}
result.push(String(node.val));
dfs(node.left);
dfs(node.right);
}
dfs(root);
return result.join(',');
}
function deserialize(data) {
const nodes = data.split(',');
let index = 0;
function dfs() {
if (index >= nodes.length || nodes[index] === 'null') {
index++;
return null;
}
const node = new TreeNode(Number(nodes[index]));
index++;
node.left = dfs();
node.right = dfs();
return node;
}
return dfs();
}
思路:使用前序遍历序列化,null 节点也要记录(用 ‘null’ 标记)。反序列化时按同样的前序顺序重建树。
时间复杂度 O(n),空间复杂度 O(n)。
自我检测
- 能默写前序、中序、后序遍历的递归实现
- 能用栈实现至少一种遍历的迭代版本
- 能写出层序遍历(BFS),并正确处理”按层收集”的逻辑
- 能说清楚 DFS 和 BFS 的区别、各自的数据结构和适用场景
- 能用递归求二叉树的最大深度,并解释递归的含义
- 能验证 BST,并解释为什么仅检查局部关系是不够的
- 能求两个节点的最近公共祖先,并解释后序遍历在其中的作用
- 拿到一道二叉树题时,能快速判断用什么遍历顺序和递归返回值
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90