文章目录
- 题目
- 递归
- 思路
- 细节
- 易错
- 代码
- 复杂度分析
- 迭代
- 思路
- 细节
- 易错
- 代码
- 复杂度分析
题目
输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
例如,给出
前序遍历 preorder = [3,9,20,15,7]
中序遍历 inorder = [9,3,15,20,7]
返回如下的二叉树:
限制:
0 <= 节点个数 <= 5000
递归
思路
首先要明确最重要的一个知识:
对于任意一颗树而言,前序遍历的形式总是
[ 根节点, [左子树的前序遍历结果], [右子树的前序遍历结果] ]
即根节点总是前序遍历中的第一个节点。而中序遍历的形式总是
[ [左子树的中序遍历结果], 根节点, [右子树的中序遍历结果] ]
显然:
- 对前序遍历来讲,找到左右子树的遍历结果分界线是困难的,找到根节点是简单的
- 对中序遍历来讲,找到根节点是困难的,但找到根节点之后,左右两侧自然分成左右两棵子树
根据上面的特性,我们可以做出互补:
- 通过前序遍历的结果数组的首元素确定根节点
- 根据找到的根节点结合中序遍历数组确定左右子树的节点数目
重复上述过程,我们也就可以通过将每个节点视作根节点,不断递归生成左右子树,无法再生成左右子树。很显然生成左右子树的过程可以用递归思想来实现。
细节
思路有了,仍需解决几个问题:
- 即使通过前序遍历找到根节点,怎样确定根节点在中序遍历中的位置?
- 递归生成左右子树的细节操作是什么?
先解决第一个问题:
普通的方法当然是拿着根节点的值,从中序遍历结果数组inorder [0]
开始遍历,但是每次在生成根节点时都进行遍历的话,时间复杂度较高(O(N))。因此可以使用哈希表来建设中序遍历数组值到下标的键值对映射。
在构造二叉树的过程之前,我们可以对中序遍历的列表进行一遍扫描(O(N)),就可以构造出这个哈希映射。
在此后构造二叉树的过程中,我们就只需要 O(1)的时间对根节点进行定位了。(一次O(N),N次O(1));否则我们必须每次都遍历一遍中序遍历结果数组定位根节点(N次O(N))。
再来说第二个问题:
递归生成左右子树这种说法听起来还是太“模糊”了,其实我们实际做的操作是不停的生成根节点,再进入这个根节点的左右子树,在每个子树中生成当前子树的根节点,直到这个”根节点“没有子树为止。
易错
写代码的时候没有子树应该返回空指针——return nullptr;
,粗心大意写成了return null;
,null和nullptr是有区别的。
代码
class Solution {
private:map<int, int> index; // 映射值给定值对应的下标
public:TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {int num = inorder.size();for (int i = 0; i < num; i++){index[inorder[i]] = i; // 建立中序遍历数组 值到下标 的键值对映射,快速定位根节点}return buildRoot(preorder, inorder, 0, num - 1, 0, num - 1);}// 把每个节点都当作它自身的“根节点”,进入到每个节点遍历生成它的左右子树、及根节点本身 TreeNode* buildRoot(vector<int>& pre, vector<int>& in, int pre_left, int pre_right, int in_left, int in_right) {if (pre_left > pre_right) { // 没有子树return nullptr;}int pre_root = pre_left; // 前序遍历的根节点就是左边界int in_root = index[pre[pre_left]]; // 根据映射关系确定中序遍历中的根节点TreeNode* root = new TreeNode(in[in_root]); // 建立根节点// 等价于TreeNode root = TreeNode(in[in_root]);// TreeNode *proot = &root;// 但没必要这样写,可能便于理解但是过于繁琐int lefttree_num = in_root - in_left; // 确定左子树中节点数目// 前序遍历 根节点(左边界)+1 到 根节点+左子树数量 的范围为左子树// 中序遍历根节点左侧为左子树root->left = buildRoot(pre, in, pre_left + 1, pre_left + lefttree_num, in_left, in_root - 1); // 前序遍历 根节点+左子树数量+1 到 右边界 的范围为右子树// 中序遍历根节点右侧为右子树root->right = buildRoot(pre, in, pre_left + 1 + lefttree_num, pre_right, in_root + 1, in_right);return root;}
};
复杂度分析
时间复杂度:O(n),其中 n 是树中的节点个数。
空间复杂度:O(n),除去返回的答案需要的 O(n)空间之外,我们还需要使用 O(n) 的空间存储哈希映射,以及 O(h)(其中 h 是树的高度)的空间表示递归时栈空间。这里 h < n,所以总空间复杂度为 O(n)。
迭代
思路
前序遍历的相邻节点 u 和 v 有如下关系:
- v 是 u 的左儿子;
- u 没有左儿子。则 v 是 u 或者 u 祖先节点的右儿子。
以此树为例:
它的前序遍历和中序遍历分别为
preorder = [3, 9, 8, 5, 4, 10, 20, 15, 7]
inorder = [4, 5, 8, 10, 9, 3, 15, 20, 7]
可以看到,对于3,9,8,5,4它们之间满足第一种关系(例如:8是9的左儿子),对于4,10它们满足第二种关系,10是4祖父节点的右儿子。
也就是前序遍历会
- 从根节点开始,一直遍历左子树
- 直到左子树遍历完了,开始遍历右子树
- 如果当前的节点没有右子树,则会回溯遍历祖先节点的右子树。
那么我们可以根据这一特性,我们可以用一个栈来存储祖先节点和左子树,直到左子树被遍历完,(本例中,将3,9,8,5,4依次入栈,直到遇到10)此时开始寻找当前节点(10)是谁的右儿子。
细节
思路有了,仍需解决几个问题:
- 当开始遍历右子树,怎么确定当前节点是谁的右儿子呢?
这时来看中序遍历,我们可以发现,中序遍历结果数组的首元素是——根节点不断往左走达到的最终节点。 根据这一特性,我们可以创建一个指针 index 指向当前的最左子树。
首先我们将根节点 3 入栈,再初始化 index 指向的节点为 4,随后对于前序遍历中的每个节点,我们依次判断它是栈顶节点的左儿子,还是栈中某个节点的右儿子。
- 我们遍历 9。9 一定是栈顶节点 3 的左儿子。我们使用反证法,假设 9 是 3 的右儿子,那么 3 没有左儿子,index 应该恰好指向 3,但实际上为 4,因此产生了矛盾。所以我们将 9 作为 3 的左儿子,并将 9 入栈。
stack = [3, 9]
index -> inorder[0] = 4
- 我们遍历 8,5 和 4。同理可得它们都是上一个节点(栈顶节点)的左儿子,所以它们会依次入栈。
stack = [3, 9, 8, 5, 4]
index -> inorder[0] = 4
- 当我们遍历到 10,这时情况就不一样了。我们发现此时 index 指向的节点和当前的栈顶节点一样,都为 4,也就是说 4 没有左儿子,那么 10 必须为栈中某个节点的右儿子。 那么如何找到这个节点呢?栈中的节点的顺序和它们在前序遍历中出现的顺序是一致的,而且每一个节点的右儿子都还没有被遍历过, 那么这些节点的顺序和它们在中序遍历中出现的顺序一定是相反的(原因如下)。
这是因为栈中的任意两个相邻的节点,前者都是后者的某个祖先。并且我们知道,栈中的任意一个节点的右儿子还没有被遍历过(前序遍历顺序——中左右),说明后者一定是前者左儿子,那么后者就先于前者出现在中序遍历中(中序遍历顺序——左中右)。
因此我们可以先把此时的栈顶元素保存并弹出, 然后把 index 不断向右移动,并与栈顶节点进行比较。如果 index 对应的元素恰好等于栈顶节点,那么说明上一个被弹出的节点没有右子树,且其本身是当前节点的左子树, 所以重复将栈顶节点保存并弹出,然后将 index 增加 1 的过程,直到 index 对应的元素不等于栈顶节点,此时 index 对应的元素就是上一个被保存且弹出的栈顶节点的右子树。按照这样的过程,我们弹出的最后一个节点 x 就是 10 的父节点,这是因为 10 出现在了
x
与x在栈中的下一个节点
的中序遍历之间,因此 10 就是 x 的右儿子(根据中序遍历顺序——左中右,x是中,10是右,x在栈中的下一个节点
是x
的父节点)。
回到我们的例子,我们会依次从栈顶弹出 4,5 和 8,并且将 index 向右移动了三次。我们将 10 作为最后弹出的节点 8 的右儿子,并将 10 入栈。
stack = [3, 9, 10]
index -> inorder[3] = 10
- 我们遍历 20时。index 恰好指向当前栈顶节点 10,那么我们会依次从栈顶弹出 10,9 和 3,并且将 index 向右移动了三次。我们将 20 作为最后弹出的节点 3 的右儿子,并将 20 入栈。
stack = [20]
index -> inorder[6] = 15
- 我们遍历 15,将 15 作为栈顶节点 20 的左儿子,并将 15 入栈。
stack = [20, 15]
index -> inorder[6] = 15
- 我们遍历 7。index 恰好指向当前栈顶节点 15,那么我们会依次从栈顶弹出 15 和 20,并且将 index 向右移动了两次。我们将 7 作为最后弹出的节点 20 的右儿子,并将 7 入栈。
stack = [7]
index -> inorder[8] = 7
此时遍历结束,我们构造出了正确的二叉树。
总结来讲就是,遍历前序遍历结果数组并将其压到栈中:
- 当
栈顶元素
不等于index指向的元素
时,将当前元素
作为栈顶元素
的左儿子,然后当前元素入栈成为新栈顶。 - 当
栈顶元素
等于index指向的元素
时,弹出并保存栈顶元素,同时将index递增1,再判断栈顶元素和index指向的元素之间的关系,相等则重复上述操作,不相等则将当前元素
作为最后一个被弹出的栈顶元素
的右儿子,然后将当前元素入栈成为新栈顶。
易错
- 通过判断前序遍历或中序遍历的结果数组是否为空,来确定二叉树是否为空。
- 二叉树不为空时,在建立左右子树的循环操作之前,先将根节点入栈。因为根节点的建立操作与其他左右子树不同,放到循环里面要单独处理,反而繁琐。
- 注意保存弹出的栈顶元素。
- 在生成右子树的时候,栈不为空也应该是重要的循环判定条件之一。
代码
class Solution2 { // 迭代
public:TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {if (!preorder.size()) {return nullptr;}stack<TreeNode*> st;TreeNode* root = new TreeNode(preorder[0]); // 建立根节点st.push(root); // 根节点入栈// 否则无法进行将节点归为左儿子或者右儿子的操作// 因为进行上面的操作需要访问栈顶元素的left或者rightint index = 0;for (size_t i = 1; i < preorder.size(); i++) {int pre = preorder[i];int in = inorder[index];auto node = st.top();if (node->val != in) { // 如果前序遍历i位置的数和中序遍历index位置的数不相等// 说明i位置的数是二叉树的左子树node->left = new TreeNode(pre);st.push(node->left);}else {while (!st.empty() && in == st.top()->val) {in = inorder[++index];node = st.top(); // 保存弹出的节点// 当跳出while时,pre的值即为该节点右子树st.pop();}node->right = new TreeNode(pre);st.push(node->right);}}return root;}
};
复杂度分析
时间复杂度:O(n),其中 n 是树中的节点个数。
空间复杂度:O(n),除去返回的答案需要的 O(n) 空间之外,我们还需要使用 O(h)(其中 h 是树的高度)的空间存储栈。这里 h < n,所以(在最坏情况下)总空间复杂度为 O(n)。