简介
二叉树(Binary tree)是树形结构的一个重要类型。许多实际问题抽象出来的数据结构往往是二叉树形式,即使是一般的树也能简单地转换为二叉树,而且二叉树的存储结构及其算法都较为简单。
树形结构在实际开发中有着较为广泛的应用,在前端开发中,常见的业务场景 如:
组织架构(公司->子公司->部门->组->成员);
文件树(文件夹->文件);
设备树(IT 设备->PC->MacBook);
前端路由表;
如果你不能好好的掌握树的知识点的话,开发这类业务一定会是一个不小的挑战。
这些业务其实都是对二叉树知识点的扩展,正所谓千里之行始于足下,我们就从简单的二叉树开始,掌握其中的知识点,那么,学完本文之后,你对这些业务将会游刃有余啦~
接下来,就让我们开始吧。
对于二叉树,我们常常这样定义:
interface TreeNode<T> {
//左儿子节点
left: TreeNode<T> | null;
// 值域
value: T;
// 右儿子节点
right: TreeNode<T> | null;
}
2、二叉树的遍历
二叉树的遍历有两种方式,一种是递归遍历,另外一种是非递归遍历。
我们在前端业务场景中编写 Vue 的递归组件其实就是对二叉树递归遍历的一种应用场景。
对于二叉树的遍历也是各种大厂的高频考点,因为递归遍历比较简单,为防止求职者钻空子,一般面试官喜欢考察求职者对层序遍历的掌握。
2.1、二叉树的递归遍历
2.1.1、前序遍历
前序遍历,输出顺序是根 左 右的顺序
算法流程:
算法实现:
/**
* 二叉树的递归先序遍历
* @param {TreeNode} tree 二叉树的根节点
*/
function preOrderTraverse(tree) {
// 如果树空
if (!tree) {
console.warn("empty tree");
}
// 率先输出根节点的值
console.log(tree.value);
// 如果树存在左子树,递归左子树
if (tree.left) {
preOrderTraverse(tree.left);
}
// 如果树存在右子树,递归右子树
if (tree.right) {
preOrderTraverse(tree.right);
}
}
2.1.2、中序遍历
中序遍历,输出顺序是左 根 右的顺序
算法流程:
算法实现:
/**
* 二叉树的递归中序遍历
* @param {TreeNode} tree 二叉树的根节点
*/
function inOrderTraverse(tree) {
// 如果树空
if (!tree) {
console.warn("empty tree");
}
// 如果树存在左子树,率先递归左子树
if (tree.left) {
inOrderTraverse(tree.left);
}
// 再输出根节点的值
console.log(tree.value);
// 如果树存在右子树,递归右子树
if (tree.right) {
inOrderTraverse(tree.right);
}
}
2.1.3、后序遍历
后续遍历,输出顺序是左 右 根的顺序
算法流程:
算法实现:
/**
* 二叉树的递归后序遍历
* @param {TreeNode} tree 二叉树的根节点
*/
function postOrderTraverse(tree) {
// 如果树空
if (!tree) {
console.warn("empty tree");
}
// 如果树存在左子树,率先递归左子树
if (tree.left) {
postOrderTraverse(tree.left);
}
// 如果树存在右子树,然后再递归右子树
if (tree.right) {
postOrderTraverse(tree.right);
}
// 最后再输出根节点的值
console.log(tree.value);
}
可以看到,使用递归遍历,先序、中序、后序遍历的差异仅仅体现在输出值的时机不同,因为递归是利用了系统的调用堆栈,让我们的代码变得简单。如果说我们要实现非递归遍历,我们需要利用栈。虽然递归遍历的代码实现比较简单,在面试中的考察较少,但是在实际开发中却有着相当大的用途(是前端在实现文章开头所说的几类业务的银弹),因此,这也是每个前端程序员必须掌握的知识点。
2.2 二叉树的非递归遍历
在非递归遍历中,我们用到了深度优先(先序)和广度优先(层序)的思想,我们需要借助之前学过的线性数据结构队列和栈,如果有不清楚的读者,可自行查阅资料,本文不过多的介绍队列和栈。
2.2.1、前序非递归遍历
算法流程:
算法实现:
/**
* 二叉树的先序遍历
* @param {TreeNode} tree 二叉树的根节点
*/
function preOrderTraverse(tree) {
// 树空,则不进行任何操作。
if (!tree) {
console.warn("empty tree");
return;
}
// 定义一个辅助栈,用于记录遍历的轨迹
let stack = [];
let node = tree;
// 如果栈不空,或者当前节点还有值,需要循环遍历
while (stack.length > 0 || node) {
// 一直沿着树的左子树迭代,直到到头位置
while (node) {
console.log(node.value);
// 将当前节点压栈
stack.push(node);
node = node.left;
}
// 如果当前栈内还存在元素的话,则取出一个元素
if (stack.length) {
node = stack.pop();
// 因为当前弹出的节点已经是处理过的了,仅需沿着右子树迭代即可(若存在)
node = node.right;
}
}
}
2.2.2、中序非递归遍历
中序遍历的非递归实现可先序非递归遍历实现大同小异,仅仅体现在节点输出的时机不同。
算法流程:
算法实现:
/**
* 二叉树的中序非递归遍历
* @param {TreeNode} tree 二叉树的根节点
*/
function inOrderTraverse(tree) {
// 树空,则不进行任何操作。
if (!tree) {
console.warn("empty tree");
return;
}
// 定义一个辅助栈,用于记录遍历的轨迹
let stack = [];
let node = tree;
// 如果栈不空,或者当前节点还有值,需要循环遍历
while (stack.length > 0 || node) {
// 一直沿着树的左子树迭代,直到到头位置
while (node) {
// 将当前节点压栈,不急于输出节点值
stack.push(node);
node = node.left;
}
// 如果当前栈内还存在元素的话,则取出一个元素
if (stack.length) {
node = stack.pop();
// 因为上面的while循环退出的时候,一定是遍历到了最左边的叶节点了,此刻可以输出取出节点的值进行输出
console.log(node.value);
// 沿着右子树迭代(若存在)
node = node.right;
}
}
}
2.2.3、后序非递归遍历
二叉树的后序非递归遍历相对来说比较麻烦一些,本文采取的是双栈法进行的遍历。而且,双栈法在遍历的时候,和层序遍历的代码看起来相当相似, 但是因为栈和队列性质不同,所以程序运行效果差别较大。
算法流程:
算法实现:
/**
* 二叉树的后序非递归遍历
* @param {TreeNode} tree 二叉树的根节点
*/
function postOrderTraverse(tree) {
// 树空,终止遍历
if (!tree) {
console.warn("empty tree");
return;
}
// 辅助栈,用于控制遍历的轨迹
let stack1 = [];
// 辅助栈,用于存储遍历过程中所遇到的节点
let stack2 = [];
stack1.push(tree);
while (stack1.length) {
// 从栈1中弹出一个元素
const node = stack1.pop();
// 此刻节点还不能输出,先将节点压到辅助栈2中记录下来,稍后再输出
stack2.push(node);
// 如果左子节点存在,将左子节点压到栈中,
if (node.left) {
stack1.push(node.left);
}
// 如果右子节点存在,将右子节点压到栈中,
if (node.right) {
stack1.push(node.right);
}
}
// 将辅助栈2中的内容退栈,直到元素全部清空
while (stack2.length) {
const node = stack2.pop();
console.log(node.value);
}
}
上述算法的实现,遍历过程中对于左右子节点的顺序不可颠倒,因为栈的先入后出的性质,此刻必须先处理左子节点;先处理左子节点的话,在栈 1 里面右子节点会滞后一些压入栈 1,但是右子节点出栈 1 的时候就会提前,等一会儿压入到栈 2 的时候便会提前,但是从栈 2 中弹出的时候却滞后了,这样才能保持后序遍历左 右 根的顺序。
2.2.4、层序遍历
算法流程:
算法实现:
/**
* 二叉树的层序遍历
* @param {TreeNode} tree 二叉树的根节点
*/
function levelTraverse(tree) {
if (!tree) {
console.warn("empty tree");
return;
}
// 定义一个辅助队列
let queue = [];
// 将根节点入队
queue.push(tree);
while (queue.length > 0) {
// 从数组的头部,出队一个元素
const node = queue.shift();
console.log(node.value);
// 如果当前节点有左儿子节点,则左儿子节点进队
if (node.left) {
queue.push(node.left);
}
// 如果当前节点有右儿子节点,则右儿子节点进度
if (node.right) {
queue.push(node.right);
}
}
}
层序遍历个人感觉在面试中考察频率相当高,笔者在去年面试滴滴和美团过程中都考察到了这道题。对于想进入大厂的同学,这是一个必须掌握的知识点。
3、二叉树的构造
先序序列+中序序列唯一确定一颗二叉树;中序序列和后序序列能唯一确定一颗二叉树;
3.1、从先序遍历序列和中序遍历序列构造二叉树
这个问题是一道 LeetCode 的原题,见 105 题。
假设我们有先序序列:
preorder = [3,9,20,15,7], 中序序列:inorder = [9,3,15,20,7]
算法思路:
如果两个序列分别为空的话,说明树为空树,否则根据先序遍历的根 左 右性质,我们可以确定数组第一个元素就是根节点,根据中序遍历的左 根 右性质,我们可以在中序遍历中先找到根节点所在的位置,那么这个位置之前的元素都是左子树节点,这个位置之后的元素都是右子树的节点。我们从中序序列中找到了左右子树序列对应的长度后,那我们就可以分别计算出其在先序遍历中的左右子树序列的位置。那么,我们我们递归这个过程,就可以还原这颗二叉树。
算法实现:
/**
* @param {number[]} preorder
* @param {number[]} inorder
* @return {TreeNode}
*/
var buildTree = function (preorder, inorder) {
// 递归的终止条件,数组为空,则返回空树
if (
!Array.isArray(preorder) ||
preorder.length === 0 ||
!Array.isArray(inorder) ||
inorder.length === 0 ||
preorder.length != inorder.length
) {
return null;
}
// 根据先序序列确定根节点所在的位置
let rootVal = preorder[0];
// 在中序遍历的结果中找到根节点所在的位置,则【0,idx】的是左子树序列,【idx+1,length】的是右子树序列
let rootNodeIdx = inorder.findIndex((x) => x === rootVal);
// 得到左子树序列
let inLeftSubtreeNodes = inorder.slice(0, rootNodeIdx);
// 得到右子树序列
let inRightSubtreeNodes = inorder.slice(rootNodeIdx + 1);
// 在先序遍历的结果中提取对应长度的的子集 可以得到对应的左子树序列
let preLeftSubtreeNodes = preorder.slice(1, inLeftSubtreeNodes.length + 1);
// 在先序遍历的结果中提取对应长度的子集,可以得到对应右子树序列
let preRightSubtreeNodes = preorder.slice(1 + inLeftSubtreeNodes.length);
return {
value: rootVal,
// 递归的构建左子树
left: buildTree(preLeftSubtreeNodes, inLeftSubtreeNodes),
// 递归的构建右子树
right: buildTree(preRightSubtreeNodes, inRightSubtreeNodes),
};
};
3.2、从中序遍历序列和后序遍历序列构造二叉树
假设我们有先序序列:
中序序列:inorder = [9,3,15,20,7],后序序列:postorder = [9,15,7,20,3]
算法思路:
根据后序遍历左 右 根的性质,后序遍历的最后一个元素是根节点,那么,根据中序遍历左 根 右的性质,可以在中序序列中找到根节点的位置, 这个位置之前的子序列是左子树序列的在中序序列中的位置,之后的子序列是右子树序列在中序序列中的位置;在确定了中序序列的左右子树序列之后,我们可以根据对应的长度确定左右子树在后序序列中的位置,递归这个操作,则可以恢复这颗二叉树。
算法实现:略
总结
以上内容是笔者在 5 年前端开发职业生涯中所遇到的一些实际场景的一些体会,如果有遗漏的部分,欢迎大家补充。
由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。