1. 题目解析与核心思路
这道LeetCode 105题要求我们根据给定的前序遍历(preorder)和中序遍历(inorder)序列,重建原始的二叉树结构。这是一个经典的二叉树构建问题,在面试和实际开发中都很常见。
1.1 遍历序列的特性分析
前序遍历的特点是按照"根节点-左子树-右子树"的顺序访问节点。这意味着:
- 序列的第一个元素一定是整棵树的根节点
- 然后是左子树的所有节点
- 最后是右子树的所有节点
中序遍历则是按照"左子树-根节点-右子树"的顺序访问节点:
- 根节点左侧的所有元素都属于左子树
- 根节点右侧的所有元素都属于右子树
1.2 解题关键思路
通过观察这两个遍历序列的特性,我们可以得出以下构建二叉树的步骤:
- 前序序列的第一个元素就是当前子树的根节点
- 在中序序列中找到这个根节点的位置
- 根节点左侧的所有元素构成左子树
- 根节点右侧的所有元素构成右子树
- 递归地对左右子树重复上述过程
这种分治策略的时间复杂度是O(n),因为每个节点只需要处理一次。空间复杂度也是O(n),主要用于存储递归调用的栈空间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 迭代法实现详解
虽然递归解法直观易懂,但面试中常常会要求我们展示迭代法的实现。下面我将详细解析如何使用栈来实现这个算法。
2.1 栈的使用原理
我们使用栈来模拟递归的过程:
- 初始时将前序序列的第一个元素(根节点)压入栈
- 遍历前序序列的剩余元素
- 维护一个指针指向中序序列的当前元素
- 根据栈顶元素和中序指针的关系决定如何构建树
2.2 具体实现步骤
cpp复制class Solution {
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
if (preorder.empty()) return nullptr;
TreeNode* root = new TreeNode(preorder[0]);
stack<TreeNode*> nodeStack;
nodeStack.push(root);
int inorderIndex = 0;
for (int i = 1; i < preorder.size(); i++) {
TreeNode* currentNode = nodeStack.top();
int currentVal = preorder[i];
// 当前节点不是中序指针指向的节点
if (currentNode->val != inorder[inorderIndex]) {
currentNode->left = new TreeNode(currentVal);
nodeStack.push(currentNode->left);
}
// 当前节点是中序指针指向的节点
else {
// 弹出所有已经处理完左子树的节点
while (!nodeStack.empty() && nodeStack.top()->val == inorder[inorderIndex]) {
currentNode = nodeStack.top();
nodeStack.pop();
inorderIndex++;
}
// 当前元素是最后一个弹出节点的右孩子
currentNode->right = new TreeNode(currentVal);
nodeStack.push(currentNode->right);
}
}
return root;
}
};
2.3 代码逐行解析
-
初始化阶段:
- 检查前序序列是否为空,为空则直接返回nullptr
- 创建根节点并压入栈
- 初始化中序指针为0
-
遍历前序序列:
- 对于每个元素,检查它与栈顶节点的关系
- 如果栈顶节点的值不等于当前中序指针指向的值,说明当前元素是栈顶节点的左孩子
- 否则,说明栈顶节点的左子树已经处理完毕,当前元素是某个祖先节点的右孩子
-
处理右子树:
- 弹出所有已经处理完左子树的节点
- 当前元素就是最后一个弹出节点的右孩子
- 将右孩子压入栈继续处理
3. 算法正确性验证
为了验证这个算法的正确性,让我们用一个具体的例子来跟踪执行过程。
3.1 示例分析
给定:
- 前序序列:[3,9,8,5,4,10,20,15,7]
- 中序序列:[4,5,8,10,9,3,15,20,7]
执行过程:
-
初始状态:
- 栈:[3]
- 中序指针:0(指向4)
- 当前前序元素:9
-
处理元素9:
- 3 ≠ 4 → 9是3的左孩子
- 栈:[3,9]
-
处理元素8:
- 9 ≠ 4 → 8是9的左孩子
- 栈:[3,9,8]
-
处理元素5:
- 8 ≠ 4 → 5是8的左孩子
- 栈:[3,9,8,5]
-
处理元素4:
- 5 ≠ 4 → 4是5的左孩子
- 栈:[3,9,8,5,4]
-
处理元素10:
- 4 == 4 → 弹出4,中序指针++
- 5 == 5 → 弹出5,中序指针++
- 8 == 8 → 弹出8,中序指针++
- 9 ≠ 10 → 停止弹出
- 10是8的右孩子
- 栈:[3,9,10]
-
继续处理剩余元素...
3.2 边界条件考虑
-
空输入:
- 当输入序列为空时,直接返回nullptr
-
单节点树:
- 前序和中序都只有一个元素
- 直接返回该节点
-
只有左子树或右子树:
- 测试树完全向左倾斜或向右倾斜的情况
4. 复杂度分析与优化
4.1 时间复杂度
- 每个节点恰好被压入和弹出栈一次
- 总体时间复杂度为O(n),其中n是节点数量
4.2 空间复杂度
- 栈的最大深度等于树的高度
- 最坏情况下(树退化为链表)为O(n)
- 平均情况下为O(logn)
4.3 优化建议
-
预处理中序索引:
- 可以先用哈希表存储中序序列的值和索引的映射
- 这样可以将查找操作从O(n)降到O(1)
-
尾递归优化:
- 递归实现时可以考虑尾递归优化
- 减少栈空间的使用
5. 常见问题与调试技巧
5.1 常见错误
-
索引越界:
- 忘记检查栈是否为空就调用top()
- 中序指针超出范围
-
逻辑错误:
- 混淆前序和中序指针
- 错误判断左右子树的关系
-
内存泄漏:
- 忘记释放创建的节点(在C++中)
5.2 调试技巧
-
打印中间状态:
- 在关键步骤打印栈内容和中序指针
- 帮助理解算法执行流程
-
小规模测试:
- 先用小的树结构测试
- 确保基本逻辑正确后再处理复杂情况
-
边界测试:
- 测试空树、单节点树、完全左倾/右倾树等特殊情况
5.3 实际应用中的注意事项
-
输入验证:
- 确保前序和中序序列长度相同
- 检查序列是否确实对应同一棵树
-
重复元素处理:
- 如果树节点值允许重复,需要额外处理
- 可能需要使用节点地址而非值来区分节点
-
性能考量:
- 对于特别大的树,递归实现可能导致栈溢出
- 迭代实现更为安全
6. 扩展与变种问题
掌握了这个基础算法后,我们可以解决一系列相关的二叉树构建问题:
6.1 从中序和后序构建二叉树
LeetCode 106题是类似的题目,只是改用中序和后序序列构建树。核心思路类似:
- 后序序列的最后一个元素是根节点
- 在中序序列中找到根节点位置
- 递归构建左右子树
6.2 从前序和后序构建二叉树
这个问题相对复杂,因为前序和后序无法唯一确定一棵二叉树。需要额外的条件才能解决。
6.3 序列化与反序列化
将二叉树转换为字符串表示(序列化),以及从字符串重建二叉树(反序列化)是实际开发中常见的需求。理解遍历序列与树结构的关系对解决这类问题很有帮助。
在实际开发中,这类算法常用于:
- 数据库存储树结构
- 网络传输树形数据
- 配置文件解析等场景
理解这些基础算法不仅能帮助我们通过面试,更能提升我们解决实际工程问题的能力。
