二叉树的《狂飙》

119 阅读6分钟

孙子兵法-【始计篇】

孙子曰:兵者,国之大事,死生之地,存亡之道,不可不察也。

二叉树

我也想写一篇让大家能快速拿下二叉树算法的文章,但本文也仅是个人学习和做题后的归纳总结,不能让零基础和没做题的同学快速掌握二叉树。

基本概念

深度

从根节点到某个结点经过的结点数,根的深度为 1。

高度

从某个结点到叶子结点经过的结点数。

二叉树的遍历

树的遍历有两种方式,深度优先和广度优先,下面会具体讲解。

深度优先搜索 DFS

深度优先顾名思义,就是从根结点一直往下遍历直到叶子结点后再向树的别的分支继续深度搜索。

在写法上有两种方式,一种是递归,一种是迭代。我个人推荐先掌握递归,二叉树的题都能用递归的思路做。

递归遍历

递归三步法

    1. 确定递归函数的参数和返回值
    1. 确定终止条件
    1. 确定单层递归的逻辑

二叉树的 前序遍历(中左右)、中序遍历(左中右)、后序遍历(左右中)

前序遍历,如下:

// 1. 参数 root vec,返回值 vec 存储遍历的元素
function traverse(root, vec) {
  // 2. 终止条件
  if (root == null) return;

  // 3. 确定单层递归逻辑

  vec.push(root.val);

  // 递归
  traverse(root.left);

  traverse(root.right);
}

写递归前,先按照'递归三步法'想清楚每一步的代码怎么写。做题多了你会发现二叉树的终止条件基本都是遇到叶子结点就终止递归。

递归,先递后归,触底(终)后归, 归后(如果不是终)再递,不能无限递归。

下面我画了个递归流程图辅助理解:

image.png

终止条件可以是 node == null结点,也可以是 node->left == null && node->right == null

迭代遍历

核心:利用 结构 + 中结点标记法 模拟 递归 的 过程

遍历方式正常顺序栈顺序
前序:中左右右左中
中序:左中右右中左
后序:左右中中右左

编码时利用栈,按栈顺序入栈结点,后面出栈的顺序就是要得到的正常顺序。

代码具体如下,

注意:我把三种顺序都写出来了没有注释,正常写的时候只会用到一种顺序,这里只是方便大家对比。

function traverse(root) {
  const res = [],
    stack = [];
  if (root != null) stack.push(root);

  while (stack.length) {
    const cur = stack.pop();

    if (cur != null) {
      // 前序
      if (cur.right) stack.push(cur.right);
      if (cur.left) stack.push(cur.left);
      stack.push(cur); // 上面弹出了再入栈
      stack.push(null);

      // 中序
      if (cur.right) stack.push(cur.right);
      stack.push(cur);
      stack.push(null);

      // 后序
      stack.push(cur);
      stack.push(null);
      if (cur.right) stack.push(cur.right);
      if (cur.left) stack.push(cur.left);
    } else {
      // 为空时
      const node = stack.pop();
      res.push(node.val);
    }
  }

  return res;
}

广度优先搜索 BFS

二叉树广度优先搜索就是在搜索时在同一层把所有结点都访问到再去访问下一层,也就是层序遍历

层序遍历

核心:利用 队列 和 记录队列的 size,每一层出队 size 个数,再把每个出队结点的子结点入队。

代码如下:

function leverOrder(root) {
  if (!root) return [];
  // 利用 queue
  const queue = [],
    res = [];

  queue.push(root);

  // 队列不为空说明还没遍历完
  while (queue.length) {
    // 每轮(层)开始 利用 size 标记当前层的个数
    let size = queue.length;

    // 开始从队列里取出当前层的元素,放入临时变量temp里
    const temp = [];
    while (size--) {
      // 从队列取出第一个元素
      const cur = queue.shift();
      temp.push(cur.val);

      // 将当前结点的子结点入队
      if (cur.left) {
        queue.push(cur.left);
      }

      if (cur.right) {
        queue.push(cur.right);
      }
    }
    // 一轮(层)结束,将 temp 放入 res 里
    res.push(temp);
  }

  return res;
}

这个代码建议大家跟着敲一下,然后理解它的过程。

二叉搜索树 Binary Search Tree

概念:左子树所有结点都小于根结点,右子树所有结点都大于根结点。

特性:按 中序遍历(左中右)是有序的,单调递增的。

image.png

左:(1 -> 3 ->) 中:[5] -> 右:(6 -> 10 -> 11)

做二叉搜索树,一定要用上它的特性。

完全二叉树

概念:若二叉树的深度为 h,除第 h 层外,其它各层的结点数都达到最大个数,第 h 层所有的叶子结点都连续集中在最左边,这就是完全二叉树。

image.png

特点:层序遍历 完全二叉树,不会在中途遇到叶子结点,如果中途遇到了叶子结点就不是完全二叉树。

所以,判断一颗树是不是完全二叉树,就可以根据这个特点来判断。

平衡二叉树

特性:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树

在这里,我只写一段求树高度的代码,剩余的留给你:

function treeHeight(node) {
  // 终止条件 因为要计算高度,所有对返回值有要求,不能再返回null了
  if (!node) return 0;

  return Math.max(treeHeight(node.left), treeHeight(node.right)) + 1;
}

总结

  • 做二叉树的题目,有一些题会以数组的形式作为输入条件,那么我们首先要采用链式存储去思考问题,这能大大简化难度,容易使用递归。
  • 除了简单的遍历外,要多考虑 队列哨兵标记 三者辅助我们解题的思路,一般 DFS 考虑栈,BFS 考虑队列,标记只要有用则可用于任一。
  • 二叉树除了普通二叉树外,剩余的几种都有自己独特的特性,在解题过程中不妨结合特性来思考。
  • 熟记数组的操作这会很有用,由于 JavaScript 的特性栈和队列等数据结构其实都是数组:
    • length, 根据下标索引访问要清楚是从 0 开始到 length - 1,还要注意题目给出的 k 下标是否在数组的有效范围内,不要因为数组越界而出错。
    • push(), 尾部插入元素。
    • unshift(), 头部插入元素。
    • pop(), 从数组尾部删除元素,并返回被删除的元素。
    • shift(), 从数组头部删除元素,并返回被删除的元素。
    • slice(startIndex, endIndex), 从数组中截取 [startIndex, endIndex) 左闭右开区间内的元素,返回新数组,不改变原数组。
    • splice(index, deleteCount, addEle), 在原数组上从 index 位置的元素(下标从 0 开始)开始,删除 deleteCount 个元素,如果有第三个及以上的元素,就追加到删完后 index 的后面。
    • 最后一点也是最重要的一点,那就是做题,每种类型的题都要至少做两遍以上,牛客上 Top100 + 剑指 offer 二叉树类型的题刷两遍过后,相信你对二叉树一定有了更深的理解。

最后的话

  • 先是感谢 Carl老师,我也购买了老师的代码随想录这本书,在自己学习的过程中对我理解算法起了很大的帮助。大家如果读到了我这篇文章,还不知道代码随想录的话,可以去搜索一下,肯定会有收获的;
  • 这边文章本来是自己学习过程中的总结笔记,可能过于简单,发出来让大伙看看,大家有任何建议或问题,请随时在评论区提出,我后续会及时改进;
  • 牛客网我也写了一些题解,大家做题的时候没思路也可以看看,搜应该可以搜到我;
  • 最后最后,大家随手点个赞吧,后续尽量保持一周一更新,和大家分享我的学习。