剑指 Offer 07. 重建二叉树 - leetcode 剑指offer系列

题目难度: 中等

原题链接

今天继续更新剑指 offer 系列, 这道题应该是树里面相当经典且质量很高的一道题目了, 也有递归和迭代两种做法: 递归方法比较直观易懂; 迭代方法可能难度较大, 属于进阶内容, 大家感兴趣的话可以自己结合画图模拟来思考

若无意外, 每天晚上 6 点 45 分准时更新, 中间可能会穿插一些周赛题解. 大家在我的公众号"每日精选算法题"中的聊天框中回复 offer 就能看到该系列当前已经更新的文章了

大家有什么想法建议和反馈的话欢迎随时交流, 包括但不限于公众号聊天框/知乎私信评论等等~

题目描述

输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。

0 <= 节点个数 <= 5000

题目样例

示例

输入

  • 前序遍历 preorder = [3,9,20,15,7]
  • 中序遍历 inorder = [9,3,15,20,7]

输出

    3
   / \
  9  20
    /  \
   15   7

题目思考

(思考题答案在最后)

  1. 如果利用前序和中序遍历的性质?
  2. 假如题目变为中序和后序遍历能否重建出唯一的二叉树? 前序和后序呢?
  3. 假如题目中节点值有重复的, 还能重建出唯一的二叉树吗? 为什么?

解决方案

方案 1

思路

分析
  • 回忆前序和中序遍历的性质: 前序是根=>左子树部分=>右子树部分, 中序是左子树部分=>根=>右子树部分
  • 所以对于同一个树的前序和中序遍历, 其前序遍历的第一个元素一定是根, 而这个根在中序遍历中就是左子树和右子树的分界点
实现
  • 根据上面分析, 一个直观思路就是我们将子树部分看做一个整体, 递归调用得出左右子树的根节点, 然后将当前根节点的左右儿子指向它们即可, 这就是典型的分治思想, 将大问题分解为小问题进行解决
  1. 先设计递归参数, 这里需要传入前序和中序遍历当前的起点和终点, 用于确定当前的树的部分, 初始传入自然就是整个前序和中序数组的起点和终点了.
  2. 然后定义递归出口, 如果起点大于终点, 就说明这个子树不存在(比如当前节点缺少左子树等), 返回空; 如果起点恰好等于终点, 则表示当前子树节点数目只有 1, 就是根节点, 直接返回该节点即可
  3. 参数和出口都确定了, 接下来就是通常情况下的处理(即当前子树节点数目多于 1 个)
    1. 根据前序起点, 确定根的值
    2. 找到该根在中序遍历中的位置 im (唯一, 因为每个节点值都不同)
    3. im 为分界点, 将中序划分为左子树和右子树部分
    4. 对于前序而言, 其左右子树的节点数目和中序的左右子树节点数目相同, 利用这一点就能确定前序的左右子树区间的起点和终点
    5. 递归传入左右子树各自的前序和中序区间, 得到左右子树根节点
    6. 将当前根节点的左右儿子指向它们, 返回当前根节点
  • 优化: 在第 3.2 步中, 如果我们每次都从头开始遍历中序数组, 找根的值对应的中序下标的话, 效率太低. 注意到每个节点值都不同, 所以我们完全可以定义一个节点值到中序下标的字典, 预处理得到映射关系, 这样只需要一次查表就能得到中序下标位置, 大大降低了时间复杂度

复杂度

  • 时间复杂度 O(N)
    • 前序和中序数组的每个元素只需要访问一遍
  • 空间复杂度 O(N)
    • 字典以及递归栈的消耗

代码

class Solution:
    def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode:
        # 方法1: 递归分治法
        if not preorder:
            return None
        # 使用一个value=>inorder index的字典加速运算, 为了使得查找中序下标的操作变为O(1), 不需要扫描整个中序数组
        valueToInorderIndex = {}
        # 前序和中序的长度必然相等, 此处表示为n
        n = len(preorder)
        for i in range(n):
            # 由于值都不相等, 所以每个值都唯一对应一个中序遍历中的下标
            valueToInorderIndex[inorder[i]] = i

        def build(pb, pe, ib, ie):
            # (pb, pe)是当前前序遍历的起点和终点
            # (ib, ie)是当前中序遍历的起点和终点
            if pb > pe:
                # 递归出口
                return None
            # 前序遍历的当前第一个元素即为当前的根节点
            root = TreeNode(preorder[pb])
            if pb == pe:
                # 当前只有一个值, 自然就返回该节点本身
                return root
            # 找出根节点的值对应的中序遍历的下标, 利用valueToInorderIndex字典就能做到O(1)时间内找到中序下标
            # 以此作为分界点, 分成左右两个子问题解决即可
            im = valueToInorderIndex[root.val]
            # 由于子树节点数目固定, 所以对应部分的前序和中序长度应该相等, 即im-ib=pm-pb, 所以pm=pb+im-ib
            pm = pb + im - ib
            # 左子树部分: 前序就是(pb + 1, pm) (pb已经作为根节点用掉了), 中序就是(ib, im-1) (im用过了, 这里对应im左边部分)
            root.left = build(pb + 1, pm, ib, im - 1)
            # 右子树部分: 前序就是(pm + 1, pe) (pm + 1开始, 就是右子树的根), 中序就是(im+1, ie) (im用过了, 这里对应im右边部分)
            root.right = build(pm + 1, pe, im + 1, ie)
            return root

        # 初始传入整个前序和中序的范围
        return build(0, n - 1, 0, n - 1)

方案 2

思路

分析
  • 如果此时题目多了个限制, 只能用迭代来做, 思考过程就不像递归方案那么直观了
  • 这时候我们就要更加充分利用前序和中序的性质了
  • 以上面的示例为例来分析一下:
前序遍历 preorder = [3,9,20,15,7]
中序遍历 inorder = [9,3,15,20,7]

    3
   / \
  9  20
    /  \
   15   7
  1. 遍历前序数组, 第一个是 3, 代表根节点
  2. 第二个是 9, 可能在根节点的左子树或者右子树, 具体在哪边则要看中序数组
  3. 由于中序第一个是 9, 而不是 3, 所以 9 一定在左子树, 这里可以使用反证法: 假设 9 在右子树, 那么意味着 3 的左子树没有节点, 所以中序第一个应该是 3, 与这里的序列矛盾
  4. 注意此时前序和中序遍历到的节点相同, 都是 9, 这就意味着前序的下一个节点 20 一定不可能是 9 的左子树了. 因为如果是的话, 那么中序开头就应该是 20, 而不是 9 了.
  5. 所以 20 在右子树上, 但单纯根据前序序列的话, 20 有可能是 3 的右子节点, 也有可能是 9 的右子节点
    • 假设 20 是 3 的右子节点, 那么中序就应该是[9,3,...,20] (因为 20 可能有左节点, 不一定正好挨着 3)
    • 假设 20 是 9 的右子节点, 那么中序就是[9,...,20,...,3], 和此处的中序序列有矛盾
    • 注意此时前序遍历的节点是[3,9], 倒过来就是[9,3], 根据上面两种情况, 20 就是中序和倒置前序最后一次相等的节点的右节点
    • 第一种情况, 3 是最后相等的节点, 所以 20 是 3 的右子节点
    • 第二种情况, 9 是最后相等的节点, 所以 20 是 9 的右子节点
  6. 而根据实际中序序列, 20 一定是 3 的右子节点
  7. 继续遍历, 此时遇到了 15, 和第 2+3 步的分析一样, 它只能是上一个节点 20 的左节点, 因为当前中序遍历到的节点还是 15, 不是 20
  8. 此时 15 又是前序和中序相同了, 结合第 4+5 步的分析, 下一个节点 7 一定是 20 的右子节点
实现
  • 基于上面的分析, 由于我们需要倒置前序遍历过的节点, 所以可以使用栈来实现
  • 迭代做法的核心就在于判断当前节点在前面节点的左还是右子树上, 以及倒置前序
  • 具体做法如下:
  1. 遍历前序序列, 使用栈保存当前前序已经遍历的节点
  2. 然后使用一个下标记录当前中序节点位置
  3. 最后根据栈顶节点和当前中序节点的值是否相等来判断节点关系
    1. 不相等: 表示当前节点一定是栈顶节点的左子节点
    2. 相等: 则需要弹出栈并向后移中序下标, 直到值不相等或者栈为空位置, 记录最后一个值相等的节点, 其右子节点就是当前节点了
  • 下面代码中对每一步也有相应注释, 方便大家理解

复杂度

  • 时间复杂度 O(N)
    • 只需要访问前序序列的元素两遍(入栈和出栈), 所以是 O(N)
  • 空间复杂度 O(N)
    • 栈存所有节点

代码

class Solution:
    def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode:
        # 方法2: 迭代, 利用stack和前序中序性质
        if not preorder:
            return None
        # 初始化root和stack
        root = TreeNode(preorder[0])
        stack = [root]
        # ii表示当前的中序下标
        ii = 0
        for pi in range(1, len(preorder)):
            # 记录上一个节点(栈顶节点), 注意stack初始化为root, 而且每次循环都会append一个值, 所以stack永远不是空, 这里直接拿栈顶是安全的
            prenode = stack[-1]
            # 根据前序性质, 当前节点curnode位于上一个节点的左子树或者右子树中
            curnode = TreeNode(preorder[pi])
            if prenode.val != inorder[ii]:
                # 上一个节点不是当前中序节点, 意味着现在还没到上一个节点的右边部分, 所以当前节点位于左子树, 且只能是上一个节点的直接左儿子
                prenode.left = curnode
            else:
                # 此时中序节点恰好等于上一个节点, 意味着当前节点在右子树上
                while stack and ii < len(inorder) and stack[-1].val == inorder[ii]:
                    # 找最上层一个(也即倒置前序序列最后一个)与当前中序节点相同的节点
                    prenode = stack.pop()
                    ii += 1
                # 那个节点的右儿子就是当前节点
                prenode.right = curnode
            # 将当前节点加入stack中, 作为下次循环的上一个节点
            stack.append(curnode)
        return root

思考题答案

  1. 如果利用前序和中序遍历的性质?
    1. 方案 1 已经解释了, 方案 2 也进一步利用了它们的性质
  2. 假如题目变为中序和后序遍历能否重建出唯一的二叉树? 前序和后序呢?
    1. 中序和后序可以, 后序的最后一个元素是根, 利用这一点就可以用同样的思路
    2. 前序和后序不行, 因为前序和后序相当于镜像, 一个根在开头, 一个在末尾, 无法找出左右子树的分界点, 也就没办法划分成左右子树部分进行处理了
  3. 假如题目中节点值有重复的, 还能重建出唯一的二叉树吗? 为什么?
    1. 不能
    2. 举个很简单的例子, 前序和中序都是[1,1], 可能是根+左儿子, 也可能是根+右儿子

大家可以在下面这些地方找到我~

我的知乎专栏

我的 CSDN

我的 Leetcode

我的牛客网博客

我的公众号: 每日精选算法题, 欢迎大家扫码关注~

每日精选算法题 - 微信扫一扫关注我

你可能感兴趣的:(Leetcode)