前端算法彻底搞懂系列 - 二叉树遍历

712 阅读8分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

在前端数据结构中,树是非常重要的概念。在普通的前端编码中使用的情况较少,但是对于权限树和菜单树的处理过程中会用到其中的原理。
二叉树是树的一种,在前端中属于基础的树,对理解深度遍历和广度遍历很重要,这在面试中往往会问到。

有些人抱怨前端学习算法没必要,我赞成一部分,枯燥的背题真的没必要,别人换个问法或者换个题型就白费了,重要的是对于思想的理解,这些思想是面对复杂问题,尤其是在组件的提取和一些高级组件和库的建设和优化上是很有益处的!!!

一、二叉树的结构

在这里我们就不过多讲述树的概念和性质,关注今天的主角二叉树

ecs.webp

这种数据形状的结构就是二叉树,拥有一个根节点,每个结点最多有两颗子树,左子树和右子树
一般来说二叉树都是有序的,次序不能任意颠倒,即使树中某结点只有一棵子树,也要区分它是左子树还是右子树,下面来看一下js的对这种解构的实现

编码实现

每个节点是最小的数据单元,我们只需要保存节点的数据、左子树和右子树的引用就可以实现

  • 数据域
  • 左侧子结点(左子树根结点)的引用
  • 右侧子结点(右子树根结点)的引用
// 二叉树结点的构造函数
function TreeNode(val) {
    this.val = val;
    this.left = this.right = null;
}

二、二叉树遍历

二叉树的遍历有两种方式,一种是递归,一种是迭代,递归可以实现先序遍历中序遍历后序遍历,迭代遍历不仅仅能实现递归的这三种遍历,还可以实现层序遍历

遍历的基本概念

从二叉树的根结点出发,按照某种次序依次访问二叉树中的所有结点,使得每个结点被访问一次,且仅被访问一次

  • 先序遍历 : 根结点 -> 左子树 -> 右子树
  • 中序遍历 : 左子树 -> 根结点 -> 右子树
  • 后序遍历 : 左子树 -> 右子树 -> 根结点

在这三种顺序中,根结点的遍历分别被安排在了首要位置、中间位置和最后位置。所谓的“先序”、“中序”和“后序”,“先”、“中”、“后”其实就是指根结点的遍历时机。

首先定义好一个二叉树结构

const root = {
  val: "A",
  left: {
    val: "B",
    left: {val: "D" },
    right: {val: "E"}
  },
  right: {
    val: "C",
    right: {val: "F"}
  }
};

递归遍历

递归版本很符合逻辑直觉,直接拿需要遍历的节点递归就可以了

前序遍历

// 所有遍历函数的入参都是树的根结点对象
function preorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
    // 递归遍历左子树 
    preorder(root.left)  
    // 递归遍历右子树  
    preorder(root.right)
}

中序遍历

// 所有遍历函数的入参都是树的根结点对象
function inorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 递归遍历左子树 
    inorder(root.left)  
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
    // 递归遍历右子树  
    inorder(root.right)
}

后序遍历

function postorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 递归遍历左子树 
    postorder(root.left)  
    // 递归遍历右子树  
    postorder(root.right)
    // 输出当前遍历的结点值
    console.log('当前遍历的结点值是:', root.val)  
}
 

迭代版本

前序遍历

  • 递归上符合咱们的直觉、可以用递归做出来的题,突然不许用递归了,此时我们本能的反应,就应该是往栈上想

  • 合理地安排入栈和出栈的时机、使栈的出栈序列符合二叉树的前序遍历规则。

  • 我们的出入栈顺序应该是这样的:

    1. 将根结点入栈
    2. 取出栈顶结点,将结点值 push 进结果数组
    3. 若栈顶结点有右孩子,则将右孩子入栈
    4. 若栈顶结点有左孩子,则将左孩子入栈
  • 这整个过程,本质上是将当前子树的根结点入栈、出栈,随后再将其对应左右子树入栈、出栈的过程。

  • 重复 2、3、4 步骤,直至栈空,我们就能得到一个先序遍历序列。

const preorderTraversal = function(root) {
  // 定义结果数组
  const res = []  
  // 处理边界条件
  if(!root) {
      return res
  }
  // 初始化栈结构
  const stack = [] 
  // 首先将根结点入栈
  stack.push(root)  
  // 若栈不为空,则重复出栈、入栈操作
  while(stack.length) {
      // 将栈顶结点记为当前结点
      const cur = stack.pop() 
      // 当前结点就是当前子树的根结点,把这个结点放在结果数组的尾部
      res.push(cur.val)
      // 若当前子树根结点有右孩子,则将右孩子入栈
      if(cur.right) {
          stack.push(cur.right)
      }
      // 若当前子树根结点有左孩子,则将左孩子入栈
      if(cur.left) {
          stack.push(cur.left)
      }
  }
  // 返回结果数组
  return res
};

后序遍历

  • 后序遍历的出栈序列,按照规则应该是 左 -> 右 -> 根 。这个顺序相对于先序遍历,最明显的变化就是根结点的位置从第一个变成了倒数第一个。
    如何做到这一点呢?与其从 stack 这个栈结构上入手,不如从 res 结果数组上入手:我们可以直接把 pop 出来的当前结点 unshift 进 res 的头部
/**
 * @param {TreeNode} root
 * @return {number[]}
 */
const postorderTraversal = function(root) {
  // 定义结果数组
  const res = []  
  // 处理边界条件
  if(!root) {
      return res
  }
  // 初始化栈结构
  const stack = [] 
  // 首先将根结点入栈
  stack.push(root)  
  // 若栈不为空,则重复出栈、入栈操作
  while(stack.length) {
      // 将栈顶结点记为当前结点
      const cur = stack.pop() 
      // 当前结点就是当前子树的根结点,把这个结点放在结果数组的头部
      res.unshift(cur.val)
      // 若当前子树根结点有左孩子,则将左孩子入栈
      if(cur.left) {
        stack.push(cur.left)
      }  
      // 若当前子树根结点有右孩子,则将右孩子入栈
      if(cur.right) {
        stack.push(cur.right)
      }
  }
  // 返回结果数组
  return res
};

中序遍历

  • 经过上面的讲解,大家会发现先序遍历和后序遍历的编码实现其实是非常相似的,它们遵循的都是同一套基本框架。那么我们能否通过对这个基本框架进行微调、从而同样轻松地实现中序遍历呢?
  • 答案是不能,为啥不能?因为先序遍历和后序遍历之所以可以用同一套代码框架来实现,本质上是因为两者的出栈、入栈逻辑差别不大——都是先处理根结点,然后处理孩子结点。
  • 而中序遍历中,根结点不再出现在遍历序列的边界、而是出现在遍历序列的中间。这就意味着无论如何我们不能再将根结点作为第一个被 pop 出来的元素来处理了——出栈的时机被改变了,这意味着入栈的逻辑也需要调整。
  • 这一次我们不能再通过对 res 动手脚来解决问题,而是需要和 stack 面对面 battle。
  • 一路向左
/**
 * @param {TreeNode} root
 * @return {number[]}
 */
const inorderTraversal = function(root) {
  // 定义结果数组
  const res = []  
  // 初始化栈结构
  const stack = []   
  // 用一个 cur 结点充当游标
  let cur = root  
  // 当 cur 不为空、或者 stack 不为空时,重复以下逻辑
  while(cur || stack.length) {
      // 这个 while 的作用是把寻找最左叶子结点的过程中,途径的所有结点都记录下来 
      while(cur) {
          // 将途径的结点入栈
          stack.push(cur)  
          // 继续搜索当前结点的左孩子
          cur = cur.left  
      }
      // 取出栈顶元素
      cur = stack.pop()  
      // 将栈顶元素入栈
      res.push(cur.val)  
      // 尝试读取 cur 结点的右孩子
      cur = cur.right
  }
  // 返回结果数组
  return res
};

层序遍历

  • 大家看到层序遍历就应该条件反射出 BFS+队列。
/**
 * @param {TreeNode} root
 * @return {number[][]}
 */
const levelOrder = function(root) {
    // 初始化结果数组
    const res = []  
    // 处理边界条件
    if(!root) {
        return res
    }  
    // 初始化队列
    const queue = []   
    // 队列第一个元素是根结点
    queue.push(root)  
    // 当队列不为空时,反复执行以下逻辑
    while(queue.length) {
        // level 用来存储当前层的结点
        const level = []  
        // 缓存刚进入循环时的队列长度,这一步很关键,因为队列长度后面会发生改变
        const len = queue.length  
        // 循环遍历当前层级的结点
        for(let i=0;i<len;i++) {
            // 取出队列的头部元素
            const top = queue.shift()  
            // 将头部元素的值推入 level 数组
            level.push(top.val)
            // 如果当前结点有左孩子,则推入下一层级
            if(top.left) {
                queue.push(top.left)
            }
            // 如果当前结点有右孩子,则推入下一层级
            if(top.right) {
                queue.push(top.right)
            }
        }
        // 将 level 推入结果数组
        res.push(level)
    }
    // 返回结果数组
    return res
};

leetcode实践

94.二叉树的中序遍历

144.二叉树的前序遍历

102.二叉树层序遍历

200.岛屿数量

547.朋友圈

130.被围绕的区域

208. 实现 Trie (前缀树))

212. 单词搜索 II