【算法进阶】从0到1:二叉树构造完全指南与经典面试题解析

35 阅读11分钟

【算法进阶】从0到1:二叉树构造完全指南与经典面试题解析

面试官问你:如何从前序和中序遍历结果构造二叉树? 这是算法面试中最经典的问题之一。掌握二叉树构造不仅能帮你通过面试,更能培养你的递归思维和问题分解能力。本文将深入剖析二叉树构造的核心原理,并通过三道经典面试题带你全面掌握这一技能。

📚 二叉树构造的基本思想

在深入具体问题之前,我们需要先理解二叉树构造的核心思想。二叉树的本质是一种递归数据结构,这意味着我们可以通过分解问题的方式来构建它:

整棵树 = 根节点 + 左子树 + 右子树

这个公式揭示了二叉树构造的基本范式:

  1. 确定根节点
  2. 递归构造左子树
  3. 递归构造右子树
  4. 返回完整树

二叉树就像一个无限套娃,而递归是处理这种结构最自然的方式。在构造二叉树时,我们只需要关注如何找到当前层次的根节点以及如何划分左右子树,剩下的工作递归会帮我们完成。

🌳 二叉树的基本结构

首先,让我们明确二叉树节点的结构定义:

/**
 * 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,构建最大二叉树。构建规则如下:

  1. 创建一个根节点,其值为 nums 中的最大值
  2. 递归地在最大值左边子数组前缀上构建左子树
  3. 递归地在最大值右边子数组后缀上构建右子树

示例:

输入:nums = [3,2,1,6,0,5] 输出:[6,3,5,null,2,0,null,null,1]

🔍 解题思路

这道题完美体现了二叉树构造的基本范式:

  1. 确定根节点:数组中的最大值就是根节点
  2. 划分左右子树:最大值左侧的所有元素构成左子树,右侧的所有元素构成右子树
  3. 递归构造:分别对左右子数组递归应用相同的逻辑
💻 代码实现
/**
 * @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. 从前序与中序遍历序列构造二叉树

题目描述: 给定两个整数数组 preorderinorder,其中 preorder 是二叉树的先序遍历,inorder 是同一棵树的中序遍历,请构造二叉树并返回其根节点。

示例:

输入:preorder = [3,9,20,15,7], inorder = [9,3,15,20,7] 输出:[3,9,20,null,null,15,7]

🔍 解题思路

要解决这个问题,我们需要充分利用两种遍历的特性:

  • 前序遍历根节点 -> 左子树 -> 右子树,所以前序遍历的第一个元素一定是根节点
  • 中序遍历左子树 -> 根节点 -> 右子树,所以一旦知道根节点,就可以将中序遍历数组分为左子树部分右子树部分

构造步骤:

  1. 从前序遍历数组中取出第一个元素作为根节点
  2. 在中序遍历数组中找到根节点的位置,确定左右子树的范围
  3. 根据中序遍历中左右子树的元素数量,确定前序遍历中左右子树的范围
  4. 递归构造左右子树
💻 代码实现
/**
 * @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. 从中序与后序遍历序列构造二叉树

题目描述: 给定两个整数数组 inorderpostorder,其中 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. 注意特殊情况

处理可能的特殊输入,例如:

  • 空数组
  • 只有一个节点的树
  • 所有节点值相同的树

🔍 总结与思考

二叉树构造问题是算法面试中的经典题型,它不仅考察了对二叉树结构的理解,更检验了递归思维和问题分解能力。通过本文的学习,我们掌握了:

  1. 二叉树构造的基本思想:分解问题,递归实现
  2. 三道经典题目:最大二叉树、前序+中序构造二叉树、中序+后序构造二叉树
  3. 优化技巧:使用索引指针、哈希表加速等
  4. 通用思维框架:适用于各种二叉树构造问题

思考: 为什么前序+后序无法唯一确定一棵二叉树?前序+中序或中序+后序为什么可以?


📚 延伸阅读


💬 互动交流

你在解决二叉树构造问题时遇到过什么有趣的挑战?或者有什么独特的解题思路?欢迎在评论区分享你的经验和想法!

如果你觉得这篇文章对你有帮助,请点赞、收藏、转发支持一下!你的支持是我持续创作的动力!