【算法-LeetCode】105. 从前序与中序遍历序列构造二叉树(建二叉树;递归;前序遍历;中序遍历)

2021年9月9日 11点热度 0条评论 来源: 赖念安

105. 从前序与中序遍历序列构造二叉树 - 力扣(LeetCode)

发布:2021年9月9日15:13:50

问题描述及示例

给定一棵树的前序遍历 preorder 与中序遍历 inorder。请构造二叉树并返回其根节点。

示例 1:

Input: preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
Output: [3,9,20,null,null,15,7]

示例2:
Input: preorder = [-1], inorder = [-1]
Output: [-1]

提示:
1 <= preorder.length <= 3000
inorder.length == preorder.length
-3000 <= preorder[i], inorder[i] <= 3000
preorder 和 inorder 均无重复元素
inorder 均出现在 preorder
preorder 保证为二叉树的前序遍历序列
inorder 保证为二叉树的中序遍历序列

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/construct-binary-tree-from-preorder-and-inorder-traversal
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

我的题解

涉及到二叉树遍历的问题,往往最容易想到的就是使用递归。我觉得递归的思路中有以下几点需要特别注意。

  • ①首先是确定递归的终止条件。
    如果没有明确递归什么时候该结束,那么就会使得递归函数占用大量的内存,最后就有可能导致溢出,从而报错。当达到终止条件时,往往会用一个 return 语句来结束本次递归。

  • ②其次是确定递归的返回值。
    注意这里要和上面的终止条件中的 return 区分开来,虽然有时候这两个 return 会重合,但是要明确其中的代表的意义是差别的。递归的返回值往往是保存着我们想要往上一层递归传递的处理结果。

  • ③注意对递归的抽象理解。
    理解递归流程时,如果用脑子一步步顺着代码往下走,很容易就会陷入混乱,毕竟人脑还是更加擅长处理结构清晰的东西。这是就要学会将递归的某个步骤抽象化为一个整体,我们只需要关注这个整体在程序中的作用是什么就行了,而不用深入到这个整体的内部细节(我们只需要记得这个整体内部也是用着同一套递归逻辑就可以了)。

    如果想要一步步观察递归的细节,更加推荐的做法是借助浏览器的开发者工具来进行逐步调试并同时观察参数或变量的值以及最重要的调用栈的变化情况。

  • ④注意递归传入的参数应该是逐层变化的。
    如果每一层递归的参数都完全一样,那么这个递归很可能就会陷入死循环。

我的题解1(递归)

首先是老规矩,把递归的逻辑都封装在一个函数中。因为LeetCode的在线判题系统中是会测试所有用例的,所以递归的操作往往需要我们单独封装一个函数来完成,否则可能会出现一些奇怪的结果累加的现象。

注意返回值是一个 TreeNode 类型的对象。且这个对象是代表了整个二叉树。

接下来就是分析由线性序列得到二叉树的过程:

以下内容来自《数据结构——用C语言描述(第二版)》P179~P180

由遍历顺序得到二叉树1

由遍历顺序得到二叉树2

以上内容来自《数据结构——用C语言描述(第二版)》P179~P180

通过上面的描述,我们可以清楚地知道由线性序列创建二叉树的原理和步骤。同时,在题目的【提示】中有一条比较重要的信息:preorder 和 inorder 均无重复元素。也就是说,我们可以放心大胆的用 Array.indexOf() 这个方法来获取对应根节点(值)在数组中的下标从而确定左右子树所对应的序列。

设想一下我们创建二叉树的过程:首先是通过 TreeNode(val) 构造函数创建一个根节点 root ,然后再往这个 root 身上添加左右子节点。而添加的两个左右子节点其实也分别是 root 的左右子树的根节点。如果开始有套娃的感觉了那就说明递归就要来了。于是乎,整个创建二叉树的过程就可以抽象为不断寻找子树的根节点并给该根节点插入左右子节点的过程。也就是:

function getRootOfSubTree(...){ 
  let root = new TreeNode(val);
  // ...
  root.left = getRootOfSubTree(...);
  root.right = getRootOfSubTree(...);
  return root;
}

注意,整棵二叉树其实也可以看做是一棵子树。只不过它的根节点没有对应的父节点。

接下来就是确认该传入什么参数了。题目给我们提供的两个参数都是数组,而这两个数组就可以看做是最大的那棵子树的遍历序列。由上面的原理分析,我们知道我们完全可以通过这两个数组确定一个根节点和它的左右子节点。于是乎,我们也可以把递归函数 getRootOfSubTree() 的参数设置为两个数组,一个数组是当前子树的前序遍历序列,一个数组是当前子树的中序遍历序列。

function getRootOfSubTree(preSeq, inSeq){ 
  let root = new TreeNode(val);
  // ...
  // 注意下面的参数和一开始传入的参数是不一样的
  root.left = getRootOfSubTree(leftSubPreSeq, leftSubInSeq);
  root.right = getRootOfSubTree(rightSubPreSeq, rightSubInSeq);
  return root;
}

参数确定好了,那么就要开始考虑如何在递归函数内适当地处理要传入下一层递归的参数了。也就是: leftSubPreSeq、leftSubInSeqrightSubPreSeq, rightSubInSeq 是怎么来的?

  • 先由 inSeqpreSeq[0]得到 leftSubInSeqrightSubInSeq
// 注意这里我们之所以可以用indexOf来定位根元素在inSeq中的位置,
// 是因为题目中说了preorder 和 inorder 均无重复元素
let leftSubInSeq = inSeq.slice(0, inSeq.indexOf(preSeq[0]));
let rightSubInSeq = inSeq.slice(inSeq.indexOf(preSeq[0]) + 1);

由 inSeq 和 preSeq[0]得到 leftSubInSeq 和 rightSubInSeq

  • 然后由preSeqleftSubInSeq 得到 leftSubPreSeq、rightSubpreSeq
let leftSubPreSeq = preSeq.slice(1, leftSubInSeq.length + 1);
let rightSubPreSeq = preSeq.slice(leftSubInSeq.length + 1);

由preSeq 和 leftSubInSeq 得到 leftSubPreSeq、rightSubpreSeq

最后就是确定终止条件了。当子树(子序列)中没有可供插入的新节点时,就需要结束当前层的递归,并且返回一个 null 来代表叶子节点下的空节点。也就是当 preSeqinSeq 数组的长度为零时返回,但是这两个数组的长度适中都是相等的,所以只要判断其中一个的长度是否为零即可。

最后的最后,千万不要忘记在单层递归逻辑完成后,将当前层的递归结果返回给上一层。也就是我们生成的那个子树的根节点 root

/** * Definition for a binary tree node. * function TreeNode(val, left, right) { * this.val = (val===undefined ? 0 : val) * this.left = (left===undefined ? null : left) * this.right = (right===undefined ? null : right) * } */
/** * @param {number[]} preorder * @param {number[]} inorder * @return {TreeNode} */
var buildTree = function(preorder, inorder) { 
  return getRootOfSubTree(preorder, inorder);

  // getRootOfSubTree用来完成递归逻辑,其功能是由一个子树的先序遍历和中序遍历来获取该
  // 子树的根节点及其左右两个子节点(或是左右子树,这里可以把左右子树分别抽象为一个节点)
  function getRootOfSubTree(preSeq, inSeq) { 
    // 递归终止条件,当当前子树的先序遍历序列的数组长度为0时,终止当前层的递归
    // 并且返回一个空的子节点作为结果
    if(!preSeq.length) { 
      return null;
    }
    // root是当前子树的根节点,其val值一定是对应的先序遍历序列数组中的第一个元素
    let root = new TreeNode(preSeq[0]);
    // 分别获取当前子树的左右子树的中序遍历序列,并放在相应的数组中
    // leftSubInSeq是当前子树的左子树的中序遍历序列数组
    // rightSubInSeq是当前子树的右子树的中序遍历序列数组
    // 先获取中序遍历序列数组是因为下面获取先序遍历序列时需要用到leftSubInSeq的数组长度
    let leftSubInSeq = inSeq.slice(0, inSeq.indexOf(preSeq[0]));
    let rightSubInSeq = inSeq.slice(inSeq.indexOf(preSeq[0]) + 1);
    
    // 分别获取当前子树的左右子树的先序遍历序列,并放在相应的数组中
    let leftSubPreSeq = preSeq.slice(1, leftSubInSeq.length + 1);
    let rightSubPreSeq = preSeq.slice(leftSubInSeq.length + 1);

	// 获取到当前子树的左右子树的中序和先序遍历序列数组后,
	// 分别将其当做相应的参数放入下一层递归函数的参数
	// 下面两句相当于是获取了当前子树的根节点的左右子节点(或者说是左右子树)
    root.left = getRootOfSubTree(leftSubPreSeq, leftSubInSeq);
    root.right = getRootOfSubTree(rightSubPreSeq, rightSubInSeq);
	
	// 最后一定要将当前层的递归结果以返回值的方式返回到上一层,
	// 每进行一层递归,root就是包含当前子树的根节点和左右子节点的一种结构
	// 当所有递归都逐层返回到最外层时,root就代表了一整棵二叉树
    return root;
  }
};


提交记录
203 / 203 个通过测试用例
状态:通过
执行用时:160 ms, 在所有 JavaScript 提交中击败了43.32%的用户
内存消耗:110.3 MB, 在所有 JavaScript 提交中击败了43.56%的用户
时间:2021/09/09 15:15	

可以看到这种解法的时间表现和空间表现都不怎么好。我觉得主要还是因为递归的性能太差了,没办法。

其实在递归的参数方面,可以有一些针对空间的优化,因为四个子序列其实都是由传入的 preSeqinSeq 两个数组参数截取的,也就是一个窗口模型。这样的话就可以用两对指针来完成这个对这个窗口的维护。这样在一定程度上可以节省空间消耗。但是我感觉这种方法稍稍有点难理解递归过程,所以用数组来作为窗口的容器。用指针的方式具体可以参考下面的博客:

参考:根据先序遍历和中序遍历建立二叉树 - 特务依昂 - 博客园

官方题解

更新:2021年7月29日18:43:21

因为我考虑到著作权归属问题,所以【官方题解】部分我不再粘贴具体的代码了,可到下方的链接中查看。

更新:2021年9月9日15:17:30

参考:从前序与中序遍历序列构造二叉树 - 从前序与中序遍历序列构造二叉树 - 力扣(LeetCode)

【更新结束】

有关参考

更新:2021年9月9日15:18:07
参考:JavaScript Array slice() 方法 | 菜鸟教程
参考:JavaScript Array indexOf() 方法 | 菜鸟教程
参考:根据先序遍历和中序遍历建立二叉树 - 特务依昂 - 博客园
参考:《数据结构——用C语言描述(第二版)》P179~P180 耿国华、张德同、周明全等著 高等教育出版社

    原文作者:赖念安
    原文地址: https://blog.csdn.net/qq_44879358/article/details/120201946
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系管理员进行删除。