从 DFS 中恢复二叉树

Liu Bowen

Liu Bowen / 2020, 九月, 26

本文阐述了如何从二叉树的前中后遍历中的任意两种遍历结果来恢复原始二叉树。本文阅读需要读者具有一定的二叉树的遍历的算法基础。对于本文所述场景下的二叉树恢复可分为两种子场景:

  1. 一种可快速定位左右子树切分点的场景;

  2. 一种需要额外找到左右子树的切分点的场景;

在详细阐述恢复思考路径前,首先简要回顾树的遍历:

对于前序遍历,总有先遍历根节点,后遍历左子节点,后遍历右子节点;对于中序遍历,总有先遍历左子节点,后遍历根节点,后遍历右子节点;对于后序遍历,总有先遍历左子节点,后遍历右子节点,后遍历根节点。

那么,反推在前序遍历的结果中,首项一定是根节点,即结果数组中左右子树的切分点;在后序遍历的结果,尾项一定是根节点,即结果数组中左右子树的切分点。

本文所指 DFS 均特前中后序遍历,且二叉树中不含相同值的节点。

如给定一个二叉树的层序遍历为 [1,2,3,4,5,6,7],那么其前序遍历结果为 [1,2,4,5,3,6,7],中序遍历结果为 [4,2,5,1,6,3,7],后序遍历结果为 [4,5,2,6,7,3,1]

从前中序遍历中恢复二叉树

leetcode medium 105

js
const preorder = [root, ...left0, ...right0]

结合前文分析,在前中序遍历的恢复中,首先可确定前序遍历结果的首项为根节点,此时前序遍历除首项后剩余的子数组为左右子树的结合体子数组。

js
const preorder = [root, ...left0AndRight0]

此时中序遍历的结果还没有使用,那么中序遍历的结果数组可以为我们额外带来前序数组无法带来的信息?

js
const inorder = [...left1, root, ...right1]

由上结构可知,当我们得到树的根节点后,我们可以据此得到左右子树所在的子数组的切分点。即可以得到左右子树的遍历结果。在得到左右子树所在的子数组后,我们再次回到前序遍历的结果中。因为存在以下结构:

js
const preorder = [root, ...left0, ...right0]

且因为不论是二叉树的何种遍历,左右子树的结果数组的 顺序不一致,但 长度一定一致。那么我们可以根据由中序遍历得到的左右子树所在子数组的长度得到左右子树在前序遍历中的子数组。进而最终在前序数组中得到与中序遍历的左右子树不同顺序的在前序遍历结果中的左右子树。在进一步递归分别构建左右子树,最终得到完成的二叉树。

最终,得到以下实现代码:

go
func buildTree(preorder, inorder *[]int) *TreeNode {
  if len(preorder) < 1 || len(inorder) < 1 {
    return nil
  }
  val := preorder[0]
  var i int
  for ;i < len(inorder); i++ {
    if inorder[i] == val {
      break
    }
  }
  return &TreeNode{
    Val: val,
    Left: buildTree(preorder[1:i+1], inorder[:i]),
    Right: buildTree(preorder[i+1:], inorder[i+1:]),
  }
}

此递归解法因为需要进行 N 个树节点的构造操作,那么时间复杂度的 worse case 为 O(N);另外需要额外 N 个空间存储新建的树节点,所以空间复杂度的 worse case 为 O(N)。

从中后序遍历中恢复二叉树

leetcode medium 106

从前中序遍历中恢复二叉树 同理,下文递归解法时间复杂度和空间复杂度同为 O(N)。不同之处在于由后序遍历的结果数组的尾项确定为中序遍历的切分节点值。

js
const inorder = [...left0, root, ...right0]
const postorder = [...left1, ...right1, root]

最终得到以下实现代码:

go
func buildTree(inorder, postorder *[]int) *TreeNode {
  if len(inorder) < 1 || len(postorder) < 1 {
    return nil
  }
  val := postorder[len(postorder)-1]
  var i int
  for ;i < len(inorder); i++ {
    if inorder[i] == val {
      break
    }
  }
  return &TreeNode{
    Val: val,
    Left: buildTree(inorder[:i], postorder[:i]),
    Right: buildTree(inorder[i+1:], postorder[i:len(postorder)-1]),
  }
}

归纳直接找到左右子树切分点的场景

在前文中,对于可直接找到左右子树切分点的场景可归纳如下:

  1. 通过前序或后序遍历的结果数组,且根据前序遍历和后序遍历的定义,取前序遍历结果的首项或后序遍历结果的尾项,即为二叉树当前层的根节点;

  2. 基于上步,遍历中序遍历结果,找到在中序遍历中的根节点,进而在中序遍历中划分左右子树所在的子数组;

  3. 基于上步,且基于左右子树在 DFS 类型遍历中,仅有顺序不同,结果数组项数始终一致(因为左右子树的节点数量在 DFS 的过程中恒定)的前提,那么可基于中序遍历结果的左右子树的子数组得到在前序或后序遍历结果数组中的左右子数组;

  4. 基于上步,构造当前根节点和左右子树链接,并根据左右子树的数组递归构造左右子树。

go
func buildTree(preorderOrPostorder, inorder *[]int) *TreeNode {
  if len(preorderOrPostorder) < 1 || len(inorder) < 1 {
    return nil
  }
  val := getNodeValFromPreorderOrPostorder(preorderOrPostorder)
  var i int
  for ; i < len(inorder); i++ {
    if inorder[i] == val {
      break
    }
  }
  return &TreeNode{
    Val: val,
    // make sure two sub slice should have same length
    Left: buildTree(leftSubTreeInPreorderOrPostorder, leftSubTreeInInorder),
    // make sure two sub slice should have same length
    Right: buildTree(rightSubTreeInPreorderOrPostorder, rightSubTreeInInorder)
  }
}

特别注意的是,在最后递归生成左右子树时,传入的子数组 一定 要保证长度相等,且如果前文执行了正确的左右子树切分,递归时传入的子数组长度也 一定 会相等(因为在 DFS 中左右子树在遍历的整个过程中节点数量恒定,那么遍历的结果长度相等,此处未考虑原始二叉树中存在重复值的场景)。

从前后序遍历中恢复二叉树

leetcode medium 889

与前后序和中序遍历搭配不同的是,在仅有前后序遍历的场景下,虽然能够得到根节点的值,但无法立即得到左右子树的切分点。回到对前后序遍历的定义中,有:

js
const preorder = [root, ...left0, ...right0]
const postorder = [...left1, ...right1, root]

借助以上结构,我们转换一种思路,由原先根据根节点来确定左右子树的切分点的思考路径转换为由 左子树的第一项 来确定左右子树的切分点,即:

js
const preorder = [root, firstInLeftSubTree, ...otherLeft0, ...right0]
const postorder = [...otherLeft1, firstInLeftSubTree, ...right1, root]

根据前后序遍历的定义,可以得到的一个明确的推论是:在前序遍历中的左子树 第一个 节点 一定 是在后序遍历中的左子树的 最后 一个节点(读者若对此推论不理解,可以画图理解)。据此,我们得到在后序遍历结果中的左右子树的切分点。

go
func constructFromPrePost(pre, post *[]int) *TreeNode {
  if len(pre) < 1 || len(post) < 1 {
    return nil
  }
  node := &TreeNode{Val: pre[0]}
  if len(pre) == 1 || len(post) == 1 {
    return node
  }
  var i int
  for i = range post {
    // compare with the first element of leftSubTree
    if post[i] == pre[1] {
      // shift to the first element of rightSubTreeInPost
      i++
      break
    }
  }
  node.Left = constructFromPrePost(pre[1:i+1], post[:i])
  node.Right = constructFromPrePost(pre[i+1:], post[i:len(post)-1])
  return node
}

在递归构建左右子树时,首先在后序遍历的结果数组中根据先前得到的切分点,该切分点为前序遍历中左子树的第一项 firstInLeftSubTree 的值对应在后序遍历中的对应值的索引项的 下一项。切分得到左右子树所在的子数组,再根据得到的子数组在前序遍历的结果数组中得到左右子树的前序遍历结果,进而递归生成左右子树。

上文切分点为什么是 firstInLeftSubTree 在后序遍历中对应索引项的下一项?因为不像之前能直接根据根节点的值来得到完整的左右子树所在子数组,根据前序遍历结果中的左子树第一项 firstInLeftSubTree 找到其在后序遍历中的索引时,保证的是在后序遍历中首项到第 i 项为左子树,那么第 i + 1 项才为右子树。

此递归解法的时间复杂度的 worse case 为 O(N2),空间复杂度的 worse case 为 O(N2)。

归纳生成二叉树的关键点

前文笔者大致将根据任意两种 DFS 二叉树遍历的结果生成二叉树的场景分为以下两种子场景:

  1. 直接根据根节点的值切分得到左右子树所在的子数组,根据子数组递归生成左右子树;

  2. 无法直接得到切分点的场景下,即根据前后遍历恢复二叉树的场景下,根据前序遍历的左子树的第一项是后序遍历的右子树的最后一项的事实,得到切分左右子树的切分点。并根据得到的子数组生成左右子树。

从上文归纳可以得到,不论是在何种场景下,根据 DFS 二叉树遍历结果恢复二叉树的 关键点 在于:确定左右子树的切分点,进而得到左右子树的遍历结果,进而递归恢复左右子树