【算法进阶】从0到1:二叉树构造完全指南与经典面试题解析
面试官问你:如何从前序和中序遍历结果构造二叉树? 这是算法面试中最经典的问题之一。掌握二叉树构造不仅能帮你通过面试,更能培养你的递归思维和问题分解能力。本文将深入剖析二叉树构造的核心原理,并通过三道经典面试题带你全面掌握这一技能。
📚 二叉树构造的基本思想
在深入具体问题之前,我们需要先理解二叉树构造的核心思想。二叉树的本质是一种递归数据结构,这意味着我们可以通过分解问题的方式来构建它:
整棵树 = 根节点 + 左子树 + 右子树
这个公式揭示了二叉树构造的基本范式:
- 确定根节点
- 递归构造左子树
- 递归构造右子树
- 返回完整树
二叉树就像一个无限套娃,而递归是处理这种结构最自然的方式。在构造二叉树时,我们只需要关注如何找到当前层次的根节点以及如何划分左右子树,剩下的工作递归会帮我们完成。
🌳 二叉树的基本结构
首先,让我们明确二叉树节点的结构定义:
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val === undefined ? 0 : val);
* this.left = (left === undefined ? null : left);
* this.right = (right === undefined ? null : right);
* }
*/
🎯 经典问题一:最大二叉树构造
让我们从一道直观的题目开始,逐步理解二叉树构造的核心思路。
654. 最大二叉树
题目描述: 给定一个不重复的整数数组 nums,构建最大二叉树。构建规则如下:
- 创建一个根节点,其值为
nums中的最大值 - 递归地在最大值左边的子数组前缀上构建左子树
- 递归地在最大值右边的子数组后缀上构建右子树
示例:
输入:nums = [3,2,1,6,0,5]
输出:[6,3,5,null,2,0,null,null,1]
🔍 解题思路
这道题完美体现了二叉树构造的基本范式:
- 确定根节点:数组中的最大值就是根节点
- 划分左右子树:最大值左侧的所有元素构成左子树,右侧的所有元素构成右子树
- 递归构造:分别对左右子数组递归应用相同的逻辑
💻 代码实现
/**
* @param {number[]} nums
* @return {TreeNode}
*/
function constructMaximumBinaryTree(nums) {
// 递归终止条件:空数组返回null
if (nums.length === 0) return null;
// 1. 找到数组中的最大值和其索引
let maxIndex = 0;
for (let i = 1; i < nums.length; i++) {
if (nums[i] > nums[maxIndex]) {
maxIndex = i;
}
}
// 2. 创建根节点
const root = new TreeNode(nums[maxIndex]);
// 3. 递归构造左子树(最大值左边的子数组)
root.left = constructMaximumBinaryTree(nums.slice(0, maxIndex));
// 4. 递归构造右子树(最大值右边的子数组)
root.right = constructMaximumBinaryTree(nums.slice(maxIndex + 1));
// 5. 返回构造好的树
return root;
}
📊 复杂度分析
- 时间复杂度:O(n²),其中 n 是数组长度。对于每个递归调用,我们需要 O(n) 的时间找到最大值,而最坏情况下树退化成链表,递归深度为 O(n)。
- 空间复杂度:O(n),递归调用栈的深度。
🚀 优化思路
当前解法在每次递归时都创建了新的子数组,造成了额外的空间开销。我们可以通过传递索引范围而不是创建新数组来优化:
function constructMaximumBinaryTree(nums) {
// 辅助函数:在[left, right]范围内构建最大二叉树
function build(nums, left, right) {
// 边界条件
if (left > right) return null;
// 找最大值索引
let maxIndex = left;
for (let i = left + 1; i <= right; i++) {
if (nums[i] > nums[maxIndex]) {
maxIndex = i;
}
}
const root = new TreeNode(nums[maxIndex]);
// 递归构造左右子树
root.left = build(nums, left, maxIndex - 1);
root.right = build(nums, maxIndex + 1, right);
return root;
}
// 初始调用
return build(nums, 0, nums.length - 1);
}
优化后的时间复杂度降至 O(n),空间复杂度仍为 O(n)。
🎯 经典问题二:从前序与中序遍历序列构造二叉树
这是面试中最常见的二叉树构造问题,也是对理解二叉树遍历本质的绝佳考察。
105. 从前序与中序遍历序列构造二叉树
题目描述: 给定两个整数数组 preorder 和 inorder,其中 preorder 是二叉树的先序遍历,inorder 是同一棵树的中序遍历,请构造二叉树并返回其根节点。
示例:
输入:preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
输出:[3,9,20,null,null,15,7]
🔍 解题思路
要解决这个问题,我们需要充分利用两种遍历的特性:
- 前序遍历:
根节点 -> 左子树 -> 右子树,所以前序遍历的第一个元素一定是根节点 - 中序遍历:
左子树 -> 根节点 -> 右子树,所以一旦知道根节点,就可以将中序遍历数组分为左子树部分和右子树部分
构造步骤:
- 从前序遍历数组中取出第一个元素作为根节点
- 在中序遍历数组中找到根节点的位置,确定左右子树的范围
- 根据中序遍历中左右子树的元素数量,确定前序遍历中左右子树的范围
- 递归构造左右子树
💻 代码实现
/**
* @param {number[]} preorder
* @param {number[]} inorder
* @return {TreeNode}
*/
function buildTree(preorder, inorder) {
// 递归终止条件
if (preorder.length === 0 || inorder.length === 0) return null;
// 1. 从前序遍历第一个元素创建根节点
const rootVal = preorder.shift(); // 注意:shift()会修改原数组
const root = new TreeNode(rootVal);
// 2. 在中序遍历中找到根节点的位置
const rootIndex = inorder.indexOf(rootVal);
// 3. 分割中序遍历数组为左子树和右子树部分
const leftInorder = inorder.slice(0, rootIndex);
const rightInorder = inorder.slice(rootIndex + 1);
// 4. 递归构造左右子树
// 注意:preorder数组经过shift后,剩余部分的前leftInorder.length个元素是左子树的前序遍历
root.left = buildTree(preorder, leftInorder);
// 剩下的元素就是右子树的前序遍历
root.right = buildTree(preorder, rightInorder);
return root;
}
⚠️ 性能优化:避免修改原数组
上面的解法使用了shift()修改原数组,在递归过程中会产生额外开销。更高效的方式是使用索引指针:
function buildTree(preorder, inorder) {
// 创建值到索引的映射,加速查找
const valToIndex = new Map();
for (let i = 0; i < inorder.length; i++) {
valToIndex.set(inorder[i], i);
}
// 辅助函数:在[preStart, preEnd]和[inStart, inEnd]范围内构造树
function build(preStart, preEnd, inStart, inEnd) {
if (preStart > preEnd) return null;
// 创建根节点
const rootVal = preorder[preStart];
const root = new TreeNode(rootVal);
// 找到根节点在中序遍历中的位置
const rootIndex = valToIndex.get(rootVal);
// 计算左子树的节点数量
const leftSize = rootIndex - inStart;
// 递归构造左右子树
// 左子树:前序[preStart+1, preStart+leftSize], 中序[inStart, rootIndex-1]
root.left = build(preStart + 1, preStart + leftSize, inStart, rootIndex - 1);
// 右子树:前序[preStart+leftSize+1, preEnd], 中序[rootIndex+1, inEnd]
root.right = build(preStart + leftSize + 1, preEnd, rootIndex + 1, inEnd);
return root;
}
// 初始调用
return build(0, preorder.length - 1, 0, inorder.length - 1);
}
📊 复杂度分析
- 时间复杂度:O(n),其中 n 是节点数量。构建哈希表需要 O(n) 时间,递归过程中每个节点都会被处理一次。
- 空间复杂度:O(n),哈希表需要 O(n) 空间,递归调用栈的深度为 O(log n) 到 O(n)。
🎯 经典问题三:从中序与后序遍历序列构造二叉树
掌握了前序+中序构造二叉树,这道题就是其「姊妹题」,只需要调整根节点的选择方式即可。
106. 从中序与后序遍历序列构造二叉树
题目描述: 给定两个整数数组 inorder 和 postorder,其中 inorder 是二叉树的中序遍历,postorder 是同一棵树的后序遍历,请构造并返回这颗二叉树。
示例:
输入:inorder = [9,3,15,20,7], postorder = [9,15,7,20,3]
输出:[3,9,20,null,null,15,7]
🔍 解题思路
这道题与上一题类似,但有一个关键区别:
- 后序遍历:
左子树 -> 右子树 -> 根节点,所以后序遍历的最后一个元素是根节点 - 确定根节点后,依然可以在中序遍历中找到其位置,划分左右子树
需要特别注意的是:由于后序遍历的顺序是「左右根」,我们在构造子树时需要先构造右子树,再构造左子树。
💻 代码实现
/**
* @param {number[]} inorder
* @param {number[]} postorder
* @return {TreeNode}
*/
function buildTree(inorder, postorder) {
// 递归终止条件
if (inorder.length === 0 || postorder.length === 0) return null;
// 1. 从后序遍历最后一个元素创建根节点
const rootVal = postorder.pop(); // 注意:pop()会修改原数组
const root = new TreeNode(rootVal);
// 2. 在中序遍历中找到根节点的位置
const rootIndex = inorder.indexOf(rootVal);
// 3. 分割中序遍历数组为左子树和右子树部分
const leftInorder = inorder.slice(0, rootIndex);
const rightInorder = inorder.slice(rootIndex + 1);
// 4. 递归构造子树
// 重要:先构造右子树,因为postorder数组在pop后,剩下的最后元素是右子树的根
root.right = buildTree(rightInorder, postorder);
root.left = buildTree(leftInorder, postorder);
return root;
}
🚀 优化版本:使用索引和哈希表
function buildTree(inorder, postorder) {
// 创建值到索引的映射
const valToIndex = new Map();
for (let i = 0; i < inorder.length; i++) {
valToIndex.set(inorder[i], i);
}
// 辅助函数
function build(inStart, inEnd, postStart, postEnd) {
if (inStart > inEnd) return null;
// 创建根节点
const rootVal = postorder[postEnd];
const root = new TreeNode(rootVal);
// 找到根节点在中序遍历中的位置
const rootIndex = valToIndex.get(rootVal);
// 计算右子树的节点数量
const rightSize = inEnd - rootIndex;
// 递归构造左右子树
// 注意:先构造右子树
root.right = build(
rootIndex + 1, inEnd,
postEnd - rightSize, postEnd - 1
);
root.left = build(
inStart, rootIndex - 1,
postStart, postEnd - rightSize - 1
);
return root;
}
return build(0, inorder.length - 1, 0, postorder.length - 1);
}
🧠 二叉树构造的核心思维方法
通过上面的问题分析,我们可以总结出二叉树构造的通用思维框架:
1. 分解问题
将「构造整棵树」拆解为:
- 构造根节点
- 构造左子树
- 构造右子树
2. 确定根节点
根据不同问题的特性,找到确定根节点的方法:
- 最大二叉树:数组最大值
- 前序+中序:前序数组的第一个元素
- 中序+后序:后序数组的最后一个元素
3. 划分左右子树
根据根节点的位置,确定左右子树的范围:
- 在数组中通过索引划分范围
- 利用不同遍历方式的特性确定子树元素
4. 递归实现
编写递归函数,实现上述三个步骤,并确定递归终止条件(通常是空区间或空数组)。
🔄 常见变形问题
问题1:二叉树的序列化与反序列化
将二叉树转换为字符串,然后再根据字符串还原二叉树。这是对二叉树构造能力的进阶考察。
问题2:根据二叉树的层序遍历构造二叉树
层序遍历的特性是按「从上到下,从左到右」的顺序访问节点。构造时需要使用队列辅助。
问题3:构造平衡二叉搜索树
给定一个有序数组,构造一个高度平衡的二叉搜索树。这是对二叉树构造和平衡树特性的综合考察。
📝 面试技巧与注意事项
1. 递归终止条件
在编写递归代码时,首要考虑的是终止条件。对于二叉树构造问题,常见的终止条件是:
- 数组为空
- 索引范围无效(start > end)
2. 避免数组拷贝
在递归过程中,频繁创建子数组会导致额外的性能开销。优先使用索引指针来表示子数组的范围。
3. 利用哈希表加速查找
对于需要在中序遍历中查找根节点位置的问题,可以使用哈希表预先建立值到索引的映射,将查找时间从 O(n) 降低到 O(1)。
4. 画图辅助理解
在遇到复杂的二叉树构造问题时,画图是理解问题的最佳方式。通过绘制树的结构和对应的数组分割,可以更直观地把握递归关系。
5. 注意特殊情况
处理可能的特殊输入,例如:
- 空数组
- 只有一个节点的树
- 所有节点值相同的树
🔍 总结与思考
二叉树构造问题是算法面试中的经典题型,它不仅考察了对二叉树结构的理解,更检验了递归思维和问题分解能力。通过本文的学习,我们掌握了:
- 二叉树构造的基本思想:分解问题,递归实现
- 三道经典题目:最大二叉树、前序+中序构造二叉树、中序+后序构造二叉树
- 优化技巧:使用索引指针、哈希表加速等
- 通用思维框架:适用于各种二叉树构造问题
思考: 为什么前序+后序无法唯一确定一棵二叉树?前序+中序或中序+后序为什么可以?
📚 延伸阅读
💬 互动交流
你在解决二叉树构造问题时遇到过什么有趣的挑战?或者有什么独特的解题思路?欢迎在评论区分享你的经验和想法!
如果你觉得这篇文章对你有帮助,请点赞、收藏、转发支持一下!你的支持是我持续创作的动力!