从零开始写算法——二叉树篇7:从前序与中序遍历序列构造二叉树 + 二叉树的最近公共祖先

在二叉树的算法面试中,有两个核心命题始终绕不开:一个是**"如何根据遍历序列还原一棵树"(构造),另一个是"如何在树中找到两个节点的交汇点"**(查找)。

这两道题分别代表了递归思维的两种极端模式:

  1. 构造二叉树:自顶向下,精准切割数组,类似"分治法"。

  2. 最近公共祖先:自底向上,汇聚搜索结果,类似"后序遍历"。

本文将结合代码,深度剖析这两道经典题目的底层逻辑与实现细节。


一、 构造的艺术:从前序与中序遍历序列构造二叉树

1. 题目解析与难点

题目给出了前序遍历(Preorder)和中序遍历(Inorder),要求我们还原整棵树。

  • 前序遍历:[ 根节点 | 左子树区域 | 右子树区域 ]

  • 中序遍历:[ 左子树区域 | 根节点 | 右子树区域 ]

难点在于:如何利用下标运算,将两个数组精准地"切割"成左右子树对应的部分,并递归处理。

2. 代码实现

C++代码实现:

cpp 复制代码
class Solution {
    // 3 9 20 15 7
    // 9 3 15 20 7  
    unordered_map<int, int> mp;
    
    // 注意:在实际工程或进一步优化中,建议将 inorder 改为引用传递 vector<int>& inorder
    // 以避免递归过程中频繁的内存拷贝
    TreeNode* build(vector<int>& preorder, vector<int> inorder, int pl, int pr, int il, int ir) {
        if (pl > pr || il > ir)  return nullptr;
        
        // 1. 前序遍历的第一个元素就是当前的根节点
        TreeNode* root = new TreeNode(preorder[pl]);
        
        // 2. 计算左子树的节点数量 k
        // mp[root->val] 得到根在中序中的下标,减去中序起始位置 il,就是左子树的长度
        int k = mp[preorder[pl]] - il;
        
        // 3. 递归构造左右子树
        // 这里的区间是左闭右闭
        
        // 构造左子树:
        // 前序:跳过当前根(pl+1),长度为 k,结束点为 pl + k
        // 中序:从 il 开始,长度为 k,结束点为 il + k - 1 (不包含根)
        root->left = build(preorder, inorder, pl + 1, pl + k, il, il + k - 1);
        
        // 构造右子树:
        // 前序:跳过根和左子树(pl + k + 1),直到最后 pr
        // 中序:跳过左子树和根(il + k + 1),直到最后 ir
        root->right = build(preorder, inorder, pl + k + 1, pr, il + k + 1, ir);
        
        return root;

    }
public:
    TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
        // 思路: 先用哈希表记录下来中序的下标, 然后递归构造
        int n = preorder.size();
        for (int i = 0; i < n; ++i) {
            mp[inorder[i]] = i;
        }
        return build(preorder, inorder, 0, n - 1, 0, n - 1);
    }
};

3. 深度解析:区间计算的"加一"与"减一"

这段代码最容易出错的地方就是 build 函数中的下标计算。我们采用的是 左闭右闭区间 [Start, End] 的策略。

关键变量 k 的物理含义: k = mp[preorder[pl]] - il 这代表了 左子树一共有多少个节点

为什么左子树的中序区间是 il + k - 1

  • 起点是 il

  • 长度是 k

  • 根据数组下标公式:End = Start + Length - 1

  • 所以结尾必须减 1。这也是为了在中序序列中,把"根节点"排除在左子树范围之外。

为什么右子树的前序区间是 pl + k + 1

  • 起点是 pl(当前根)。

  • 我们需要跳过 1个根节点k个左子树节点 才能到达右子树。

  • 所以 Start = pl + 1 + k

通过哈希表 unordered_map 将查找根节点下标的时间复杂度从 O(N) 降为 O(1),使得整体算法的时间复杂度优化到了 O(N)。


二、 查找的智慧:二叉树的最近公共祖先 (LCA)

1. 题目解析与难点

这是一个分类讨论的题目:给定两个节点 p 和 q,找到它们在树中最近的公共祖先。 这道题的难点在于:如何让底层的节点把"找到了"这个信息一层层地向上传递,并在交汇点进行结算。

2. 代码实现

C++代码实现:

cpp 复制代码
class Solution {
    // 思路:
    // 分类讨论,如果当前节点是p或q或空,直接返回
    // 如果不是那么递归左右子树
    // 如果左右都存在那么当前节点就是返回值, 如果都在左边,那么递归左子树得到的结果就是返回值。
public:
    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
        // 递归终止条件 / 找到目标
        if (root == NULL || root == p || root == q) {
            return root;
        }
        
        // 后序遍历:先去左右子树找
        TreeNode* left = lowestCommonAncestor(root->left, p, q);
        TreeNode* right = lowestCommonAncestor(root->right, p, q);
        
        // 核心判断逻辑
        if (left && right) {
            // 左右各找到一个,说明当前 root 就是最近公共祖先
            return root;
        }
        else if (left) {
            // 只在左边找到了(可能找到了一个,也可能左边已经把 LCA 传上来了)
            return left;
        }
        else {
            // 只在右边找到了
            return right;
        }
    }
};

3. 深度解析:递归返回值的"三重含义"

这个递归函数 lowestCommonAncestor 的返回值设计非常精妙,它承载了三种不同的含义:

  1. 返回 NULL:代表这棵子树里,既没有 p,也没有 q,是一片"荒原"。

  2. 返回 p 或 q:代表这棵子树里发现了目标人物。

  3. 返回 LCA (公共祖先):代表这棵子树里,p 和 q 都已经被找到了,并且已经合并出了结果。

为什么"都在左边"就直接返回左边的结果? 代码中的 else if (left) return left; 涵盖了两种情况:

  • 情况 A :p 和 q 都在左子树中,且它们在左子树的某处已经"相遇"了,此时 left 变量里存的就是算好的 LCA。我们只需要做一个"传声筒",把它继续往上传。

  • 情况 B:p 和 q 是直系亲属关系(比如 p 是 q 的爸爸)。当我们在左边找到 p 时,直接返回 p。因为根据题目定义,如果一个节点是另一个节点的祖先,那么这个节点本身就是 LCA。我们不需要再去下面找 q 了。

最精彩的"交汇": if (left && right) return root; 这是整道题的灵魂。当当前节点发现:左手搜到了 p(或 q),右手搜到了 q(或 p)。 这意味着当前节点正是 p 和 q 分道扬镳的路口 ,也就是它们的最近公共祖先。于是,当前节点不再返回 left 或 right,而是挺身而出,返回 root(自己)。


三、 复杂度深度剖析:从理论到工程细节

写出正确的算法只是第一步,在面试和工程实践中,我们必须清楚每一行代码背后的资源消耗。以下是对上述两段代码的时空复杂度分析。

1. 构造二叉树 (Build Tree) 的复杂度

时间复杂度:O(N)

  • 哈希表构建 :我们在开始递归前,遍历了一遍中序数组 inorder 来构建 unordered_map,耗时 O(N)。

  • 递归构建 :构建整棵树需要递归调用 build 函数 N 次(每个节点被创建一次)。在哈希表的帮助下,每次查找根节点位置的操作是 O(1) 的。

  • 总计:O(N) + O(N) = O(N)。

  • 注意:如果这里不使用哈希表,而是每次在 inorder 数组里 for 循环寻找根节点,时间复杂度会退化为 O(N^2)。

空间复杂度:O(N)

  • 哈希表开销mp 存储了 N 个键值对,占用 O(N) 空间。

  • 递归栈开销:这是递归算法隐形的内存消耗。

    • 平均情况(树比较平衡):栈深度为 O(log N)。

    • 最坏情况(树退化成链表):栈深度为 O(N)。

  • 工程细节警示(关键点) : 在你的 build 函数签名中:TreeNode* build(vector<int>& preorder, vector<int> inorder, ...)。 如果不加引用符号 &(即写成 vector<int> inorder),那么每次递归调用都会触发一次数组的深拷贝 。这会导致空间复杂度暴涨,且产生巨大的时间开销。务必确保传入大数组时使用引用传递 (vector<int>&)。

2. 最近公共祖先 (LCA) 的复杂度

时间复杂度:O(N)

  • 在最坏的情况下(例如树退化为链表,或者 p 和 q 分别位于树的最底端),我们需要遍历整棵树的所有节点才能确定结果。

  • 每个节点只会被访问一次,因此时间复杂度为线性的 O(N)。

空间复杂度:O(N)

  • 此算法没有使用任何额外的辅助数据结构(如数组、哈希表等),仅依靠递归来实现。

  • 因此,空间复杂度完全取决于递归调用栈的最大深度,也就是树的高度。

    • 平均情况:O(log N)。

    • 最坏情况:O(N)。


四、 总结

这两道题目展示了二叉树递归的两种核心心法:

  1. 构造树 (Build Tree)

    • 思维前序定位,中序定长

    • 技巧 :利用 Map 加速定位,利用 k 值精准进行区间切割。

    • 本质:将大问题拆解为参数不同的独立子问题(分治)。

  2. 公共祖先 (LCA)

    • 思维后序遍历,自底向上

    • 技巧 :利用返回值来传递"搜索状态"(是找到了一个?还是都找到了?)。

    • 本质:在回溯过程中汇总信息,并在关键节点(分叉口)进行逻辑决策。

相关推荐
一起养小猫2 小时前
LeetCode100天Day13-移除元素与多数元素
java·算法·leetcode
ACERT3333 小时前
10.吴恩达机器学习——无监督学习01聚类与异常检测算法
python·算法·机器学习
诗词在线3 小时前
从算法重构到场景复用:古诗词数字化的技术破局与落地实践
python·算法·重构
hetao17338373 小时前
2026-01-12~01-13 hetao1733837 的刷题笔记
c++·笔记·算法
曹仙逸3 小时前
数据结构day06小项目
数据结构
无限码力3 小时前
美团秋招笔试真题 - 放它一马 & 信号模拟
算法·美团秋招·美团笔试·美团笔试真题
qq_433554543 小时前
C++ 图论算法:强连通分量
c++·算法·图论
YuTaoShao3 小时前
【LeetCode 每日一题】2943. 最大化网格图中正方形空洞的面积——(解法二)哈希集合
算法·leetcode·哈希算法
开开心心就好3 小时前
内存清理工具显示内存,优化释放自动清理
java·linux·开发语言·网络·数据结构·算法·电脑