目录
  • 题 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”,如图所示。
《剑指 Offer》学习记录:题 7:重建二叉树_子树
我的根结点安排明白了,这个时候在我眼里,前序遍历只剩下了“BDFGHIEC”,而对于左子树的中序遍历是“FDHGIBE”,右子树的中序遍历是“C”。
对于二叉树来说,可以看做由两颗子树构成的森林重新组合的树结构,因此在我眼里根据前序遍历的结构,左子树的根结点是“B”,该结点把二叉树分成了左右子树分别是“FDHGI”和“E”,如图所示。
《剑指 Offer》学习记录:题 7:重建二叉树_中序遍历_02
重复上述切片操作,就能够建立一棵二叉树。
《剑指 Offer》学习记录:题 7:重建二叉树_中序遍历_03
我们发现了,反向建树的方式还是渗透了分治法的思想,通过分治把一个序列不断分支成左右子树,知道分治到叶结点。因此我们可以总结出建树的算法思路:在递归过程中,如果当前先序序列的区间为 [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 时,就是递归的结束条件。
《剑指 Offer》学习记录:题 7:重建二叉树_结点_04

序列切分

最后还需要明确一个问题,传入的前序遍历和中序遍历是以数组的形式传入,因此需要明确每一次将数组分割成左右子树部分的切分方式。如图所示,以 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]。
《剑指 Offer》学习记录:题 7:重建二叉树_中序遍历_05

题解代码(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)。
《剑指 Offer》学习记录:题 7:重建二叉树_二叉树_06

迭代法

这个方法是从 leetcode 学到的,充分利用了二叉树前中后序遍历的特性。

样例模拟

对于前序遍历序列,相邻 2 个元素 i 和 i + 1 的关系有 2 种,其一是 i + 1 为 i 的左孩子,其二是 i + 1 为 i 某个祖先的右子树。例如对于如图二叉树,其先序遍历是 “ABDFGHIEC”,则对于元素 “A” 和 “B”,元素 “B” 为 “A” 的左孩子。而对于元素 “F” 和 “G”,“G” 为 “F” 的祖先 “D” 的右孩子。

ABDFGHIEC    //前序遍历
FDHGIBEAC    //中序遍历

《剑指 Offer》学习记录:题 7:重建二叉树_二叉树_07
对于上述 2 种情况而言,要确定 i + 1 为 i 的左孩子还是比较简单的。因为先序遍历的顺序是“根结点-左孩子-右孩子”,中序遍历的顺序是“左孩子-根结点-右孩子”,根据 2 种关系即可确定。难题就在第二种关系如何确定,以及应该找到何种方法可以区分 2 种关系,是使用这种方法较难的地方。
观察中序序列的特性,对于一个中序遍历序列而言,序列的左侧数据元素在二叉树的空间分布上也是在左侧。例如在上述例子中,结点 F 就是二叉树的最左的结点,同时在中序遍历序列中也是第一个元素。也可以发现,前序遍历在 F 之前的所有元素,都依次是前一个元素的左孩子,也就是 “B” 是 “A” 的左孩子、“D” 是 “B” 的左孩子、“F” 是 “D” 的左孩子。

ABDFGHIEC    //前序遍历
FDHGIBEAC    //中序遍历

《剑指 Offer》学习记录:题 7:重建二叉树_二叉树_08
但是接下来情况有些不同,因为没有比 “F” 在空间上更加左边的结点了,此时就必须回溯。由于需要回溯,所以考虑使用一个栈结构来进行搜索,为了回溯到之前的所有状态,可以令每一个新生成的结点都入栈。也就是说,假设推进到上述情况,现在二叉树的建立情况和栈的情况如图所示。这就说明了前序序列的下一个元素 “G” 不可能是 “F” 的左孩子,可能是 “F” 或其祖先的右孩子。这个问题可以反过来考虑,如果 “G” 是 “F” 的左孩子,那么中序遍历序列前 2 个元素应该是 [G,F],这显然不成立。
《剑指 Offer》学习记录:题 7:重建二叉树_二叉树_09
接下来就需要退栈,先回到结点 “D” 的位置。由于中序序列中我们已经生成了最左的结点——元素 “F”,因此接下来考虑中序序列的下一个元素 “D”。因为栈顶的元素是 “D” 结点,也就是说在不考虑 “F” 的情况下,“D” 结点是最左的结点。这就说明了前序序列的下一个元素 “G” 不可能是 “F” 的右孩子,可能只能是 “F” 祖先的右孩子。这个问题可以反过来考虑,如果 “G” 是 “F” 的右孩子,那么中序遍历前 2 个元素应该是 [F,G],这显然不成立。
《剑指 Offer》学习记录:题 7:重建二叉树_中序遍历_10
继续退栈回到结点 “B” 的位置,接下来考虑中序序列的下一个元素 “H”。注意此时不考虑 “F” 和 “D” 的情况下最左的结点应该是 “H”,但是根据前序遍历序列的下一个元素是 “G”,也就是说 “G” 的左子树中还有结点才能满足最左的结点是 “H”。换句话说,想要满足中序遍历序列为 [F,D,H,G],结点 “H” 就必须在结点 “G” 的左子树中。结合栈的状态可知,此时结点 “G” 必须是 “D” 结点的右子树才能满足条件。
因此将 “G” 结点加入二叉树中成为 “D” 的右孩子,并且结点 “G” 入栈。
《剑指 Offer》学习记录:题 7:重建二叉树_结点_11
接下来考虑前序序列的下一个元素 “H”,由于中序序列当前考虑的元素也是 “H”,所以我们可知结点 “H” 是 “G” 的左孩子。
《剑指 Offer》学习记录:题 7:重建二叉树_前序遍历_12
接下来考虑中序序列的下一个元素 “G”,也就是说排除之前已经退栈过的结点,“G” 是最左结点,因此 “H” 结点不会有左孩子,退栈到结点 “G”。此时由于栈顶和中序序列都遍历到 “G” 结点,没有比 “G” 更左的结点了,“G” 的左子树不会有变化,继续退栈。回到结点 “B”(黄色标出),但是此时中序序列的下一个元素 “I” 才是出去出栈后的元素(蓝色表示)中最左的结点,所以结点 “I” 会是上一个出栈结点 “G” (紫色标出)的右孩子。加入新结点后,结点 “I” 入栈。
《剑指 Offer》学习记录:题 7:重建二叉树_结点_13
接下来考虑中序序列的下一个元素 “I”,和栈顶元素相等,说明结点 “I” 不会再有左子树,退栈。
《剑指 Offer》学习记录:题 7:重建二叉树_子树_14
接下来考虑中序序列的下一个元素 “B”,和栈顶元素相等,说明结点 “B” 不会再有左子树,退栈。
《剑指 Offer》学习记录:题 7:重建二叉树_中序遍历_15
接下来考虑中序序列的下一个元素 “E”,和栈顶元素不相等,说明结点 “A” 不是出栈的结点除外的最左结点,则前序遍历序列的下一个元素 “E” 为上一次出栈结点 “B” 的右孩子。加入新结点后,结点 “E” 入栈。
《剑指 Offer》学习记录:题 7:重建二叉树_二叉树_16
此时中序序列中的元素 “E” 和栈顶元素相等,说明结点 “E” 不会再有左子树,退栈。
《剑指 Offer》学习记录:题 7:重建二叉树_中序遍历_17
接下来考虑中序序列的下一个元素 “A”,和栈顶元素相等,说明结点 “A” 不会再有左子树,退栈。
《剑指 Offer》学习记录:题 7:重建二叉树_结点_18
最后考虑前序序列最后一个元素 “C”,这个结点就不可能出现在 “A” 的左子树了,只能是 “A” 的右孩子,这也符合中序遍历序列的结果。加入新结点后,结点 “C” 入栈,不过到此为止 2 个序列都遍历完毕,算法结束。
《剑指 Offer》学习记录:题 7:重建二叉树_前序遍历_19

算法思路

其实上面说了这么多,都是在重复下述算法的 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》学习记录:题 7:重建二叉树_结点_20

参考资料

《剑指 Offer(第2版)》,何海涛 著,电子工业出版社
力扣官方题解:重建二叉树