前言
算法高频面试题中有一道二叉树的题,经常在各大面试题集中被提及。他就是#### 105. 从前序与中序遍历序列构造二叉树
照例先贴题目:
读题
-
二叉树前序遍历的顺序为:
- 先遍历根节点;
- 随后递归地遍历左子树;
- 最后递归地遍历右子树。
-
二叉树中序遍历的顺序为:
- 先递归地遍历左子树;
- 随后遍历根节点;
- 最后递归地遍历右子树。
解题
递归一
二叉树的题目,一般我们都是用递归去求解,递归具体步骤如下:
- 数学归纳法 -> 结构归纳法
- 赋予递归函数一个明确的意义
- 思考边界条件
- 实现递归过程 递归一般我们只求解某一具体节点的操作,不需要考虑整个过程。
- 定义递归函数的含义为
根据树的前序遍历和中序遍历求当前节点 - 我们先找到当前前序遍历的
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;
};
做是做出来了,时间和空间复杂度双高。
时间复杂度高是因为我们每次在中序遍历中查找root位置都要做循环。
空间复杂度高是因为找左右子树的前/中序遍历都是新建的数组,十分浪费空间。
所以我们需要优化!!!
递归二
空间复杂度想优化就一个点,我们不新建数组,而是通过原始preorder和inorder的下标去定义左右子树的前/中序遍历的边界,一样等同。
时间复杂度优化是建立在上述空间复杂度优化的基础上,我们不去循环中序遍历,而是将inorder的值和下标都存于map中,key值是节点值,value值是节点值所在的下标,空间换时间。
代码如下: 代码结合上图理解位置关系
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)
}
质的飞跃!!!不过我们还可以优化,毕竟递归总是伴随的时间复杂度的提升。
迭代
面试官下一句肯定是:“还有没有其他的解法?” 你要说“没有”也行~~~拦不住你。不过一般递归的解法都伴随着迭代的解法。
图1:
图2:
如上图所示(
看不清图里写的啥玩意的放大下网页~),我们将前序遍历的左子树进行向下拆解,发现前序遍历依次往下遍历永远是左节点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,说明node是left-root2的右节点,node入栈,作为新的左节点,重复上述步骤,直到前序遍历走完。
若图2情况:
node===left-root1,出栈,当前栈为:[root],node下标+1,表示为node1,此时的node1!==root,说明node1是left-root1的右节点,node1入栈,作为新的左节点,重复上述步骤。
细节点:
若栈空了,说明root节点被出栈了,中序遍历的root后一位一定是root的右节点,需要将其入栈。
总结:
- 我们维护
ind表示中序遍历的下标,我们通过循环前序遍历节点,将节点入栈。 - 通过栈顶元素与下标为
ind的值比较,若相等,将其出栈并记为top,ind++,此时继续比较栈顶节点与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;
}
效果美滋滋,迭代的方法需要找规律加模拟才能想到。 这边建议亲们再自己画个图画个栈模拟下流程~
本文的代码已收录Github,仓库中包括之前的文章链接和代码收录,后续代码也会陆续更新,欢迎大家不吝赐教。