学以致用——二叉树必知必会的知识点(基于JavaScript实现)

476 阅读10分钟

简介

二叉树(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、前序遍历

前序遍历,输出顺序是根 左 右的顺序

算法流程: 递归前序.gif 算法实现:

/**
 * 二叉树的递归先序遍历
 * @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、中序遍历

中序遍历,输出顺序是左 根 右的顺序

算法流程: 递归中序.gif 算法实现:

/**
 * 二叉树的递归中序遍历
 * @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、后序遍历

后续遍历,输出顺序是左 右 根的顺序

算法流程: 递归后序.gif 算法实现:

/**
 * 二叉树的递归后序遍历
 * @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、前序非递归遍历

算法流程: 非递归先序.gif

算法实现:

/**
 * 二叉树的先序遍历
 * @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、中序非递归遍历

中序遍历的非递归实现可先序非递归遍历实现大同小异,仅仅体现在节点输出的时机不同。

算法流程: 非递归中序.gif 算法实现:

/**
 * 二叉树的中序非递归遍历
 * @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、后序非递归遍历

二叉树的后序非递归遍历相对来说比较麻烦一些,本文采取的是双栈法进行的遍历。而且,双栈法在遍历的时候,和层序遍历的代码看起来相当相似, 但是因为栈和队列性质不同,所以程序运行效果差别较大。

算法流程: 非递归后序.gif 算法实现:

/**
 * 二叉树的后序非递归遍历
 * @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、层序遍历

算法流程: 层序遍历.gif 算法实现:

/**
 * 二叉树的层序遍历
 * @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,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。