- 题 7:重建二叉树
- 题干
- 测试用例
- 递归法
- 样例模拟
- 序列切分
- 题解代码(Python)
- 时空复杂度
- 迭代法
- 样例模拟
- 算法思路
- 题解代码
- 时空复杂度
- 参考资料
题 7:重建二叉树
题干
输入某二叉树的前序遍历和中序遍历的结果,请重建出该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。例如输入前序遍历序列{1, 2, 4, 7, 3, 5, 6, 8}和中序遍历序列{4, 7, 2, 1, 5, 3, 8, 6},则重建出图所示的二叉树并输出它的头结点。——《剑指 Offer》P62
测试用例
二叉树的类定义如下(python):
# Definition for a binary tree node. class TreeNode: def __init__(self, x): self.val = x self.left = None self.right = None
输入前序、中序遍历结果,返回二叉树。
前序遍历 preorder = [3,9,20,15,7] 中序遍历 inorder = [9,3,15,20,7]
建立的二叉树为:
3 / \ 9 20 / \ 15 7递归法
样例模拟
假设我有如下遍历序列:
ABDFGHIEC //前序遍历 FDHGIBEAC //中序遍历
我们来尝试一下用这两个遍历结果反向建立一棵二叉树。首先根据前序遍历的特点,对于一棵树来说,在前序遍历时根结点会被先输出,在中序遍历时根结点会在左子树结点输出完毕之后输出,因此我们可以知道这棵二叉树的根结点的值为 “A”,而在中序遍历中“A”结点又把序列分为了左右子树,分别是“FDHGIBE”和“C”,如图所示。
我的根结点安排明白了,这个时候在我眼里,前序遍历只剩下了“BDFGHIEC”,而对于左子树的中序遍历是“FDHGIBE”,右子树的中序遍历是“C”。
对于二叉树来说,可以看做由两颗子树构成的森林重新组合的树结构,因此在我眼里根据前序遍历的结构,左子树的根结点是“B”,该结点把二叉树分成了左右子树分别是“FDHGI”和“E”,如图所示。
重复上述切片操作,就能够建立一棵二叉树。
我们发现了,反向建树的方式还是渗透了分治法的思想,通过分治把一个序列不断分支成左右子树,知道分治到叶结点。因此我们可以总结出建树的算法思路:在递归过程中,如果当前先序序列的区间为 [idx_f1,idx_f2],中序序列的区间为 [idx_m1,idx_m2],设前序序列的第一个元素在中序序列中的下标为 k,那么左子树的结点个数为 num = (k − idx_m1) 。这样左子树的先序序列区间就是 [idx_f1 + 1, idx_f1 + num],左子树的中序序列区间是 [idx_m1,k − 1];右子树的先序序列区间是 [idx_f1 + num + 1,idx_f1],右子树的中序序列区间是 [k + 1,idx_m2],由于我按照先序序列的顺序安排结点,因此当先序序列的 idx_f1 > idx_f2 时,就是递归的结束条件。
序列切分
最后还需要明确一个问题,传入的前序遍历和中序遍历是以数组的形式传入,因此需要明确每一次将数组分割成左右子树部分的切分方式。如图所示,以 preorder[0] 为根结点,该结点用橙色标出,结点的左子树中的元素用绿色标出,右子树的元素用蓝色标出。前序遍历的属性 A 将中序遍历分割为左右子树 2 部分,同时 idx 和前序序列中属于左子树的数据元素也是对齐的。
也就是说对于前序序列 preorder[max] 中,preorder[0] 在中序序列 inorder 中的下标为 idx,则 preorder[0] 的左子树中的元素为 preorder[1] ~ preorder[idx]、inorder[0] ~ inorder[idx - 1],preorder[0] 的右子树中的元素为 preorder[idx + 1] ~ preorder[-1]、inorder[idx + 1] ~ inorder[-1]。
题解代码(Python)
class Solution: def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode: #传入的前序序列为空,说明该结点没有子树 if len(preorder) == 0: return None node = TreeNode(preorder[0]) #index() 方法返回元素在 list 中的下标,且效率远高于 for 循环遍历 index = inorder.index(preorder[0]) node.left = self.buildTree(preorder[1:idx + 1], inorder[:idx]) node.right = self.buildTree(preorder[idx + 1:], inorder[idx + 1:]) return node
时空复杂度
设树的结点为 n 个,因为需要建立 n 个结点,所以时间复杂度:O(n)。
返回的二叉树的根结点需要 O(n) 的空间,同时需要 O(h)(h 是树的高度)的空间表示递归时栈空间,总空间复杂度为 O(n)。
这个方法是从 leetcode 学到的,充分利用了二叉树前中后序遍历的特性。
样例模拟
对于前序遍历序列,相邻 2 个元素 i 和 i + 1 的关系有 2 种,其一是 i + 1 为 i 的左孩子,其二是 i + 1 为 i 某个祖先的右子树。例如对于如图二叉树,其先序遍历是 “ABDFGHIEC”,则对于元素 “A” 和 “B”,元素 “B” 为 “A” 的左孩子。而对于元素 “F” 和 “G”,“G” 为 “F” 的祖先 “D” 的右孩子。
ABDFGHIEC //前序遍历 FDHGIBEAC //中序遍历
对于上述 2 种情况而言,要确定 i + 1 为 i 的左孩子还是比较简单的。因为先序遍历的顺序是“根结点-左孩子-右孩子”,中序遍历的顺序是“左孩子-根结点-右孩子”,根据 2 种关系即可确定。难题就在第二种关系如何确定,以及应该找到何种方法可以区分 2 种关系,是使用这种方法较难的地方。
观察中序序列的特性,对于一个中序遍历序列而言,序列的左侧数据元素在二叉树的空间分布上也是在左侧。例如在上述例子中,结点 F 就是二叉树的最左的结点,同时在中序遍历序列中也是第一个元素。也可以发现,前序遍历在 F 之前的所有元素,都依次是前一个元素的左孩子,也就是 “B” 是 “A” 的左孩子、“D” 是 “B” 的左孩子、“F” 是 “D” 的左孩子。
ABDFGHIEC //前序遍历 FDHGIBEAC //中序遍历
但是接下来情况有些不同,因为没有比 “F” 在空间上更加左边的结点了,此时就必须回溯。由于需要回溯,所以考虑使用一个栈结构来进行搜索,为了回溯到之前的所有状态,可以令每一个新生成的结点都入栈。也就是说,假设推进到上述情况,现在二叉树的建立情况和栈的情况如图所示。这就说明了前序序列的下一个元素 “G” 不可能是 “F” 的左孩子,可能是 “F” 或其祖先的右孩子。这个问题可以反过来考虑,如果 “G” 是 “F” 的左孩子,那么中序遍历序列前 2 个元素应该是 [G,F],这显然不成立。
接下来就需要退栈,先回到结点 “D” 的位置。由于中序序列中我们已经生成了最左的结点——元素 “F”,因此接下来考虑中序序列的下一个元素 “D”。因为栈顶的元素是 “D” 结点,也就是说在不考虑 “F” 的情况下,“D” 结点是最左的结点。这就说明了前序序列的下一个元素 “G” 不可能是 “F” 的右孩子,可能只能是 “F” 祖先的右孩子。这个问题可以反过来考虑,如果 “G” 是 “F” 的右孩子,那么中序遍历前 2 个元素应该是 [F,G],这显然不成立。
继续退栈回到结点 “B” 的位置,接下来考虑中序序列的下一个元素 “H”。注意此时不考虑 “F” 和 “D” 的情况下最左的结点应该是 “H”,但是根据前序遍历序列的下一个元素是 “G”,也就是说 “G” 的左子树中还有结点才能满足最左的结点是 “H”。换句话说,想要满足中序遍历序列为 [F,D,H,G],结点 “H” 就必须在结点 “G” 的左子树中。结合栈的状态可知,此时结点 “G” 必须是 “D” 结点的右子树才能满足条件。
因此将 “G” 结点加入二叉树中成为 “D” 的右孩子,并且结点 “G” 入栈。
接下来考虑前序序列的下一个元素 “H”,由于中序序列当前考虑的元素也是 “H”,所以我们可知结点 “H” 是 “G” 的左孩子。
接下来考虑中序序列的下一个元素 “G”,也就是说排除之前已经退栈过的结点,“G” 是最左结点,因此 “H” 结点不会有左孩子,退栈到结点 “G”。此时由于栈顶和中序序列都遍历到 “G” 结点,没有比 “G” 更左的结点了,“G” 的左子树不会有变化,继续退栈。回到结点 “B”(黄色标出),但是此时中序序列的下一个元素 “I” 才是出去出栈后的元素(蓝色表示)中最左的结点,所以结点 “I” 会是上一个出栈结点 “G” (紫色标出)的右孩子。加入新结点后,结点 “I” 入栈。
接下来考虑中序序列的下一个元素 “I”,和栈顶元素相等,说明结点 “I” 不会再有左子树,退栈。
接下来考虑中序序列的下一个元素 “B”,和栈顶元素相等,说明结点 “B” 不会再有左子树,退栈。
接下来考虑中序序列的下一个元素 “E”,和栈顶元素不相等,说明结点 “A” 不是出栈的结点除外的最左结点,则前序遍历序列的下一个元素 “E” 为上一次出栈结点 “B” 的右孩子。加入新结点后,结点 “E” 入栈。
此时中序序列中的元素 “E” 和栈顶元素相等,说明结点 “E” 不会再有左子树,退栈。
接下来考虑中序序列的下一个元素 “A”,和栈顶元素相等,说明结点 “A” 不会再有左子树,退栈。
最后考虑前序序列最后一个元素 “C”,这个结点就不可能出现在 “A” 的左子树了,只能是 “A” 的右孩子,这也符合中序遍历序列的结果。加入新结点后,结点 “C” 入栈,不过到此为止 2 个序列都遍历完毕,算法结束。
算法思路
其实上面说了这么多,都是在重复下述算法的 3 个步骤。可以先读一下 LeetCode 提供的题解算法,回到上面的例子再看看,就能更具体的明白算法的工作流程。
我们归纳出上述例子中的算法流程:
1、我们用一个栈和一个指针辅助进行二叉树的构造。初始时栈中存放了根节点(前序遍历的第一个节点),指针指向中序遍历的第一个节点;
2、我们依次枚举前序遍历中除了第一个节点以外的每个节点。如果 index 恰好指向栈顶节点,那么我们不断地弹出栈顶节点并向右移动 index,并将当前节点作为最后一个弹出的节点的右儿子;如果 index 和栈顶节点不同,我们将当前节点作为栈顶节点的左儿子;
3、无论是哪一种情况,我们最后都将当前的节点入栈。
最后得到的二叉树即为答案。——LeetCode-Solution
题解代码
class Solution: def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode: #传入的序列为空 if not preorder: return None root = TreeNode(preorder[0]) stack = [] stack.append(root) #根结点 root 入队列 idx = 0 #idx 指向 inorder 的第一个元素 for i in range(1, len(preorder)): top = stack[-1] if top.val != inorder[idx]: #栈顶元素不是当前最左的结点 node = TreeNode(preorder[i]) top.left = node #先序遍历序列的下一个元素是栈顶元素的左孩子 stack.append(node) else: while stack and inorder[idx] == stack[-1].val: #回溯,直到栈顶栈顶元素不是当前最左的结点 top = stack.pop() idx = idx + 1 #idx 指向 inorder 的下一个元素,也就是下一个最左的结点 node = TreeNode(preorder[i]) top.right = node #先序遍历序列的下一个元素是上一次出栈的元素的右孩子 stack.append(node) return root #返回根结点
时空复杂度
使用迭代法只需要一重循环,循环的次数是结点数 n-1。同时最坏情况下所有结点都入栈然后出栈,因此出栈循环的次数为 n 次。综上所述算法 T(n) = 2n - 1,得出时间复杂度:O(n)。
返回二叉树需要 O(n) 空间,同时还需要 O(h)(h 是树的高度)的空间为栈结构的空间。由于 h < n,所以空间复杂度为 O(n)。
《剑指 Offer(第2版)》,何海涛 著,电子工业出版社
力扣官方题解:重建二叉树