前端高频算法面试题-从前序与中序遍历序列构造二叉树超详解!(递归+迭代 JavaScript篇)

307 阅读5分钟

前言

算法高频面试题中有一道二叉树的题,经常在各大面试题集中被提及。他就是#### 105. 从前序与中序遍历序列构造二叉树

照例先贴题目:

image.png

读题

  • 二叉树前序遍历的顺序为:

    • 先遍历根节点;
    • 随后递归地遍历左子树;
    • 最后递归地遍历右子树。
  • 二叉树中序遍历的顺序为:

    • 先递归地遍历左子树;
    • 随后遍历根节点;
    • 最后递归地遍历右子树。

image.png

解题

递归一

二叉树的题目,一般我们都是用递归去求解,递归具体步骤如下:

  1. 数学归纳法 -> 结构归纳法
  2. 赋予递归函数一个明确的意义
  3. 思考边界条件
  4. 实现递归过程 递归一般我们只求解某一具体节点的操作,不需要考虑整个过程。
  • 定义递归函数的含义为根据树的前序遍历和中序遍历求当前节点
  • 我们先找到当前前序遍历的root节点(即第一个节点)
  • 根据root节点值去中序遍历中找到root节点的位置,这样中序遍历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) {
    // 边界条件,如果前序遍历没值,说明没有节点
    if (preorder.length === 0) {
        return null;
    }
    // 当前的节点,前序遍历的第一个值即使root的值
    const root = new TreeNode(preorder[0]);
    
    // 循环查找中序遍历的root的位置
    let inorder_root_idx = 0;
    for (let i = 0; i < inorder.length; i++) {
        if (inorder[i] === root.val) {
            inorder_root_idx = i;
            break;
        }
    }
    // 定义root的左子树的前序遍历
    const left_preorder = preorder.slice(1, inorder_root_idx + 1);
    // 定义root的左子树的中序遍历
    const letf_inorder = inorder.slice(0, inorder_root_idx);
    // 定义root的右子树的前序遍历
    const right_preorder = preorder.slice(inorder_root_idx + 1);
    // 定义root的右子树的中序遍历
    const right_inorder = inorder.slice(inorder_root_idx + 1);
    // root左节点为左子树的前序遍历和中序遍历的递归求得
    root.left = buildTree(left_preorder, letf_inorder);
    // root右节点为右子树的前序遍历和中序遍历的递归求得
    root.right = buildTree(right_preorder, right_inorder);
    return root;
};

image.png 做是做出来了,时间和空间复杂度双高。

时间复杂度高是因为我们每次在中序遍历中查找root位置都要做循环。

空间复杂度高是因为找左右子树的前/中序遍历都是新建的数组,十分浪费空间。

所以我们需要优化!!!

image.png

递归二

空间复杂度想优化就一个点,我们不新建数组,而是通过原始preorderinorder的下标去定义左右子树的前/中序遍历的边界,一样等同。

时间复杂度优化是建立在上述空间复杂度优化的基础上,我们不去循环中序遍历,而是将inorder的值和下标都存于map中,key值是节点值,value值是节点值所在的下标,空间换时间。

image.png

代码如下: 代码结合上图理解位置关系

var buildTree = function (preorder, inorder) {
    if (!preorder.length) return null;
    const map = new Map();
    const n = inorder.length;
    // 初始遍历一次,中序遍历值和下标放于map中
    for (let i = 0; i < inorder.length; i++) {
        map.set(inorder[i], i)
    }
    // 递归函数定义改为根据前序遍历的起点,前序遍历终点,中序遍历起点,中序遍历终点返回当前节点的树
    var _buildTree = function (preorder_start, preorder_end, inorder_start, inorder_end) {
        // 边界条件,任意遍历的起点在终点之后即为越界
        if (preorder_start > preorder_end || inorder_start > inorder_end) return null;
        // 当前节点的值即为前序遍历的起点的值
        const rootVal = preorder[preorder_start];
        const node = new TreeNode(rootVal);
        // 根据当前节点值从map获取在中序遍历的下标
        const ind = map.get(rootVal);
        
        // 位置图可以参考上图
        // 定义左子树前序遍历起点,左子树前序遍历起始位置在当前root节点后一位
        const left_preorder_start = preorder_start + 1;
        // 左子树中序遍历起始位置即为当前中序遍历的起始位置
        const left_inorder_start = inorder_start;
        // 左子树中序遍历终点为node的前一个位置
        const left_inorder_end = ind - 1;
        // 左子树前序遍历的终点为左子树前序遍历起点+前序遍历的数组长度
        // 即 preorder_start + 1 + (ind - 1 - inorder_start)
        const left_preorder_end = preorder_start + ind - inorder_start;

        // 右子树前序遍历的起点为左子树前序遍历终点+1
        const right_preorder_start = left_preorder_end + 1;
        // 右子树前序遍历的终点为当前前序遍历的终点
        const right_preorder_end = preorder_end;
        // 右子树中序遍历的起点为node下标+1
        const right_inorder_start = ind + 1;
        // 右子树中序遍历的终点为当前中序遍历的终点
        const right_inorder_end = inorder_end;
        // 左子树
        node.left = _buildTree(left_preorder_start, left_preorder_end, left_inorder_start, left_inorder_end);
        // 右子树
        node.right = _buildTree(right_preorder_start, right_preorder_end, right_inorder_start, right_inorder_end);
        return node
    }

    return _buildTree(0, n - 1, 0, n - 1)
}

image.png

质的飞跃!!!不过我们还可以优化,毕竟递归总是伴随的时间复杂度的提升。

image.png

迭代

面试官下一句肯定是:“还有没有其他的解法?” 你要说“没有”也行~~~拦不住你。不过一般递归的解法都伴随着迭代的解法。

图1: image.png

图2:

image.png 如上图所示(看不清图里写的啥玩意的放大下网页~),我们将前序遍历的左子树进行向下拆解,发现前序遍历依次往下遍历永远是左节点root -> left-root1 -> left-root2,直到没有左节点,这时候往后一位的肯定是[root, left-root1, left-root2]中某一个节点的右节点(图1中为left-root2的右节点,图2中为left-root1的右节点)。如图一,我们发现中序遍历的首位就是left-root2,根据中序遍历的定义,永远是左节点在第一位,即中序遍历的首位肯定是没有左节点某一个根节点,它的后一位一定是首位left-root2的右节点。于是我们可以得出,如果将前序遍历的值依次放入到某一个栈中,表示树的依次的左节点,直到栈顶的值与中序遍历首位值相等,说明再没有左子树了。例如:

当前栈:[root, left-root1, left-root2],此时left-root2为最左边节点,将其出栈,栈变为:[root, left-root1]

若图1情况:

中序遍历中,我们将left-root2后一节点值表示为node, node!==left-root1,说明nodeleft-root2的右节点,node入栈,作为新的左节点,重复上述步骤,直到前序遍历走完。

若图2情况:

node===left-root1,出栈,当前栈为:[root]node下标+1,表示为node1,此时的node1!==root,说明node1left-root1的右节点,node1入栈,作为新的左节点,重复上述步骤。

细节点:

若栈空了,说明root节点被出栈了,中序遍历的root后一位一定是root的右节点,需要将其入栈。

总结:

  • 我们维护ind表示中序遍历的下标,我们通过循环前序遍历节点,将节点入栈。
  • 通过栈顶元素与下标为ind的值比较,若相等,将其出栈并记为topind++,此时继续比较栈顶节点与ind下标的值,若相等继续出栈并标记为top,若不等则表示当前前序遍历节点的值为top的右节点,将其入栈,继续找它的左节点。
  • 走完前序遍历,树也构造完成了。

文字我写的也有点晕,咱们结合代码理解:

var buildTree = function (preorder, inorder) {
    // 边界条件
    if (preorder.length === 0) return null;

    const stack = new Array();
    // 根节点入栈
    const root = new TreeNode(preorder[0])
    stack[0] = root;
    // pre从1开始
    let pre = 1, ind = 0;
    while (pre < preorder.length) {
        // 栈顶节点
        let node = stack[stack.length - 1];
        if (node.val !== inorder[ind]) {
            // 栈顶节点不等于中序遍历ind指向节点,表示栈顶节点还有左节点,即当前的pre指向值,将当前节点作为栈顶的左节点,并入栈
            node.left = new TreeNode(preorder[pre]);
            stack.push(node.left);
        } else {
            // 栈顶节点等于中序遍历ind指向节点,表示栈顶节点没有左节点,此时需要出栈,向上查找到当前节点为栈内哪个节点的右节点
            let top = stack[stack.length - 1];
            while (stack.length > 0 && stack[stack.length - 1].val === inorder[ind]) {
                top = stack.pop();
                ind++;
            }
            // 当前节点作为新的节点入栈查找是否有左节点
            top.right = new TreeNode(preorder[pre]);
            stack.push(top.right)
        }
        pre++;
    }
    return root;
}

image.png

效果美滋滋,迭代的方法需要找规律加模拟才能想到。 这边建议亲们再自己画个图画个栈模拟下流程~

本文的代码已收录Github,仓库中包括之前的文章链接和代码收录,后续代码也会陆续更新,欢迎大家不吝赐教。