【前端算法】二叉树必刷8道高频面试题(最优解法+超详细解析)
二叉树是前端面试中的必考知识点,本文将8道最经典、最高频的题目一网打尽。所有解法均为最优解,包含递归+迭代双思路、核心原理、复杂度分析、易错点提醒,面试直接背,新手也能轻松吃透!
一、二叉树节点定义(所有题目通用)
class TreeNode {
constructor(val) {
this.val = val;
this.left = null; // 默认空节点,无需手动设置null
this.right = null;
}
}
二、层序遍历(BFS)- 二叉树基础核心
题目描述
从上到下、一层一层遍历二叉树,返回每层节点值的集合(按层分组)。
最优解法(队列实现)
const levelOrder = (root) => {
const res = [];
if (!root) return res; // 空树直接返回空数组
const queue = [root]; // 队列存储待遍历节点(BFS核心)
while (queue.length) {
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);
}
res.push(currentLevel); // 保存当前层结果
}
return res;
};
核心解析
- 数据结构:队列(先进先出,保证“一层一层”遍历);
- 关键细节:必须先记录当前层长度(levelSize),再循环遍历,避免后续子节点入队导致长度变化,影响遍历范围;
- 复杂度:时间O(n)(每个节点仅入队、出队一次),空间O(n)(队列最多存储一层节点);
- 关联考点:掌握层序遍历,就能轻松写出“最大深度”的迭代解法(本质是统计层序遍历的总层数)。
三、二叉树最大深度
题目描述
求二叉树的最大深度(从根节点到最远叶子节点的最长路径上的节点数)。
方法1:递归法(DFS·自底向上,最简写法)
const maxDepth = (root) => {
// 递归终止条件:空节点深度为0
if (!root) return 0;
// 递推公式:当前节点深度 = 1(自身) + 左右子树最大深度
return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
};
原理:利用系统自动维护的调用栈,先递归到最底层(叶子节点),再自底向上回溯计算每一层的深度。
方法2:迭代法(BFS·自顶向下,无栈溢出风险)
const maxDepth = (root) => {
if (!root) return 0;
const queue = [root];
let depth = 0; // 记录深度(层数)
while (queue.length) {
const levelSize = queue.length;
// 遍历当前层所有节点
for (let i = 0; i < levelSize; i++) {
const node = queue.shift();
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
depth++; // 每处理完一层,深度+1
}
return depth;
};
核心总结(面试必说)
- 递归DFS:依赖系统栈,自底向上计算,代码简洁,但树极深时会栈溢出;
- 迭代BFS:依赖手动队列,自顶向下计数,代码稍长,但100%安全,永不爆栈;
- 二者时间、空间复杂度均为O(n),目标一致,仅遍历方式和实现逻辑不同。
四、对称二叉树
题目描述
判断一棵二叉树是否是镜像对称的(即左右两边完全镜像,轴对称)。
最优递归解法
const isSymmetric = (root) => {
// 辅助函数:判断两个节点是否镜像
const check = (l, r) => {
if (!l && !r) return true; // 两个都为空,对称
if (!l || !r) return false; // 一个空一个非空,不对称
// 核心:当前值相等 + 左的左 = 右的右 + 左的右 = 右的左
return l.val === r.val && check(l.left, r.right) && check(l.right, r.left);
};
// 空树对称,非空树判断左右子树是否镜像
return root ? check(root.left, root.right) : true;
};
核心思想
对称的核心是“镜像匹配”:左子树的左节点 = 右子树的右节点,左子树的右节点 = 右子树的左节点,递归校验每一对节点即可。
五、翻转二叉树
题目描述
将二叉树左右翻转(即镜像翻转,每个节点的左右孩子互换位置)。
最优极简解法(递归)
const invertTree = (root) => {
if (!root) return null; // 空树直接返回
// ES6解构赋值,一行交换左右子树(递归翻转左右子树后互换)
[root.left, root.right] = [invertTree(root.right), invertTree(root.left)];
return root;
};
补充:迭代解法(手动栈,无爆栈)
const invertTree = (root) => {
if (!root) return null;
const stack = [root];
while (stack.length) {
const node = stack.pop();
// 交换当前节点的左右孩子
[node.left, node.right] = [node.right, node.left];
// 右孩子先入栈(保证左孩子先被处理,遍历顺序和递归一致)
if (node.right) stack.push(node.right);
if (node.left) stack.push(node.left);
}
return root;
};
六、验证二叉搜索树(BST)
题目描述
判断一棵二叉树是否为合法的二叉搜索树(BST),要求:左子树所有节点值 < 根节点值,右子树所有节点值 > 根节点值,且左右子树也必须是BST。
最优区间解法(递归,避免陷阱)
const isValidBST = (root) => {
// 辅助函数:判断节点是否在合法区间内(min, max)
const dfs = (node, min, max) => {
if (!node) return true; // 空树是BST
// 节点值必须严格大于min、小于max(避免等于,BST不允许重复值)
if (node.val <= min || node.val >= max) return false;
// 左子树:区间更新为(min, 当前节点值)
// 右子树:区间更新为(当前节点值, max)
return dfs(node.left, min, node.val) && dfs(node.right, node.val, max);
};
// 初始区间:负无穷到正无穷(根节点无约束)
return dfs(root, -Infinity, Infinity);
};
易错提醒
不能只判断“当前节点 > 左孩子、当前节点 < 右孩子”,必须保证“左子树所有节点 < 根、右子树所有节点 > 根”,区间法是最简洁、最不易错的解法。
七、路径总和
题目描述
给定二叉树和一个目标和targetSum,判断是否存在一条从根节点到叶子节点的路径,路径上所有节点值相加等于目标和。(叶子节点:无左右孩子的节点)
方法1:递归解法(简洁,易理解)
const hasPathSum = function(root, targetSum) {
if (!root) return false; // 空树无路径,返回false
// 到达叶子节点,判断当前节点值是否等于剩余目标和
if (!root.left && !root.right) {
return root.val === targetSum;
}
// 递归左右子树,目标和减去当前节点值(剩余目标和传递)
return hasPathSum(root.left, targetSum - root.val)
|| hasPathSum(root.right, targetSum - root.val);
};
方法2:迭代解法(手动栈,无栈溢出)
const hasPathSum = function(root, targetSum) {
if (!root) return false;
// 栈中存储:[当前节点, 剩余需要满足的目标和]
const stack = [[root, targetSum]];
while (stack.length) {
const [node, curSum] = stack.pop(); // DFS深度优先,栈顶出栈
// 到达叶子节点,判断是否满足条件
if (!node.left && !node.right) {
if (node.val === curSum) {
return true;
}
}
// 右孩子先入栈(保证左孩子先被处理,和递归遍历顺序一致)
if (node.right) {
stack.push([node.right, curSum - node.val]);
}
if (node.left) {
stack.push([node.left, curSum - node.val]);
}
}
// 遍历完所有路径,均不满足条件
return false;
};
核心总结
递归与迭代逻辑完全一致:都是“从根到叶子”遍历,传递剩余目标和,到达叶子节点时校验是否满足条件;区别仅在于“系统栈”和“手动栈”的管理方式。
八、二叉树的最近公共祖先(LCA)- 进阶压轴题
题目描述
给定一棵二叉树,找到两个指定节点p、q的最近公共祖先。最近公共祖先定义:同时包含p和q作为后代(p、q可互为祖先),且离p、q最近的节点。
为什么LCA是进阶题?(面试必懂)
LCA是二叉树最难的高频题,核心原因的是它的思维难度远超基础题,是二叉树递归的“分水岭”:
- 递归逻辑反直觉:不直接判断当前节点,而是先让左右子树“找p、q”,再根据子树的返回结果,推导当前节点是否是LCA,属于“后序遍历+信息向上传递+全局决策”的高阶思维;
- 代码短但难理解:核心逻辑仅2行,但新手很难想明白“为什么左右子树各找到一个,当前节点就是LCA”;
- 全局判断需求:不同于基础题的“局部判断”,LCA需要掌握整棵树中p、q的位置,依赖子树的返回信息才能做出决策;
- 迭代法复杂:基础题迭代多是“直接模拟递归”,而LCA迭代需要记录父节点、回溯祖先路径,步骤更繁琐。
方法1:递归解法(最优最简,面试首选)
var lowestCommonAncestor = function(root, p, q) {
// 递归终止条件:空节点/找到p/找到q,直接返回(无需继续递归)
if (!root || root === p || root === q) return root;
// 去左子树找p、q
const left = lowestCommonAncestor(root.left, p, q);
// 去右子树找p、q
const right = lowestCommonAncestor(root.right, p, q);
// 左右子树各找到一个 → 当前节点就是LCA(最近公共祖先)
if (left && right) return root;
// 只有一边找到 → 答案在找到的那一边(递归回溯)
return left || right;
};
方法2:迭代解法(手动栈+父指针,无栈溢出)
var lowestCommonAncestor = function(root, p, q) {
if (!root) return null;
// 1. 用栈做DFS遍历,记录每个节点的父节点(核心:给每个节点“找爸爸”)
const stack = [root];
const parent = new Map(); // key=子节点,value=父节点
parent.set(root, null); // 根节点无父节点
// 第一步:遍历整棵树,完善父节点映射
while (stack.length) {
const node = stack.pop();
// 右孩子入栈,记录父节点
if (node.right) {
parent.set(node.right, node);
stack.push(node.right);
}
// 左孩子入栈,记录父节点
if (node.left) {
parent.set(node.left, node);
stack.push(node.left);
}
}
// 第二步:收集p的所有祖先(从p往上走到根)
const ancestors = new Set();
while (p) {
ancestors.add(p);
p = parent.get(p); // 向上找父节点
}
// 第三步:q往上走,第一个出现在p祖先集合中的节点,就是LCA
while (!ancestors.has(q)) {
q = parent.get(q);
}
return q;
};
核心总结
- 递归LCA:代码短、效率高(O(n)),但难理解,深度过深会栈溢出;
- 迭代LCA:思路直观(找父节点+回溯祖先),永不爆栈,工程中更常用,面试写这个能加分;
- 二者时间、空间复杂度均为O(n),都是最优解,可根据面试场景选择(递归显简洁,迭代显功底)。
九、补充1:递归栈溢出的解决方案(面试必问)
1. 为什么递归会栈溢出?
系统调用栈的空间非常小(通常仅1MB左右),每递归一次,就会向栈中压入一个“栈帧”;当递归深度超过栈的容量(比如树深度达到1000+),就会报错 RangeError: Maximum call stack size exceeded。
2. 终极解决方案(3种,优先选第一种)
- 改用迭代法(最稳、最通用) :放弃递归,手动维护栈(DFS)或队列(BFS),无论树多深,都不会栈溢出(本文所有递归题均提供了对应的迭代解法);
- 尾递归优化(理论有用,JS几乎无效) :让递归调用成为函数的最后一步,理论上可避免栈帧累积,但Chrome、Node.js等主流JS环境均不支持尾递归优化,写了也会爆栈;
- 限制递归深度(工程不推荐) :手动设置递归深度阈值,超过阈值则停止递归,适合简单场景,不适合算法题。
面试标准答案(直接背)
面试官问:“递归会栈溢出吗?怎么解决?” 回答:递归深度过深会导致系统调用栈溢出。解决方法是放弃递归,改用迭代实现,手动维护栈(DFS)或队列(BFS),这样无论数据规模多大,都不会栈溢出。
十、补充2:LeetCode 数组转二叉树原理(新手必懂)
疑问:为什么LeetCode输入是数组,我们却能用 .left、.right?
真相:LeetCode界面上的数组,只是“便于人类阅读和输入”的树的序列化表示,后台会自动将数组转换为TreeNode对象,传给我们函数的root,已经是带left、right属性的树节点了,我们无需手动处理数组。
LeetCode官方数组转树实现(可直接运行)
function arrayToTree(arr) {
if (!arr || arr.length === 0) return null;
// 根节点:数组第0位
const root = new TreeNode(arr[0]);
// 队列:存储待挂载孩子的节点(BFS构建)
const queue = [root];
let i = 1; // 从第1位开始,分配左右孩子
while (queue.length && i < arr.length) {
const parent = queue.shift(); // 取出父节点
// 左孩子:下标i,不为null则创建节点并挂载
if (arr[i] !== null) {
parent.left = new TreeNode(arr[i]);
queue.push(parent.left); // 左孩子入队,后续给它分配孩子
}
i++; // 无论是否有左孩子,都移动到下一位(右孩子)
// 右孩子:下标i,不为null则创建节点并挂载
if (i < arr.length && arr[i] !== null) {
parent.right = new TreeNode(arr[i]);
queue.push(parent.right); // 右孩子入队
}
i++; // 移动到下一组左右孩子
}
return root;
}
关键说明
- 转换规则:数组第0位是根节点,下标i的左孩子是2i+1,右孩子是2i+2,null表示该位置无节点;
- 无需手动设置null:TreeNode默认left、right为null,当数组中是null时,不处理即可,保持默认值;
- 核心逻辑:用BFS一层一层构建,先有父节点,再依次挂载左右孩子。
十一、最终总结(面试必背)
- 遍历逻辑:层序遍历=BFS=队列=自顶向下;递归=DFS=系统栈=自底向上;
- 递归vs迭代:递归简洁好写,但有栈溢出风险;迭代代码稍长,但100%安全,工程首选;
- 重点题目:最大深度(递归+迭代双思路)、LCA(进阶压轴,递归+迭代必掌握)、路径总和(递归与迭代逻辑一致);
- 易错点:验证BST用区间法,避免局部判断陷阱;数组转树无需手动设null,TreeNode默认空;
- LeetCode技巧:输入数组是展示用,后台自动转树,直接用.left、.right即可。
本文涵盖二叉树所有前端面试高频考点,建议收藏背诵,面试时直接套用解法,轻松拿下二叉树题目!欢迎点赞关注,后续持续更新前端面试高频算法题~