✅✅代码随想录算法训练营Day13 || 二叉树的遍历

114 阅读13分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第6天,点击查看活动详情🚀🚀

前言

先搞懂二叉树的遍历问题,是我们后面解决二叉树问题的基础。

这里还是以修言老师算法小册为主。

以一定的顺序规则,逐个访问二叉树的所有结点,这个过程就是二叉树的遍历。按照顺序规则的不同,遍历方式有以下四种:

  • 先序遍历
  • 中序遍历
  • 后序遍历
  • 层次遍历

按照实现方式的不同,遍历方式又可以分为以下两种:

  • 递归遍历(先、中、后序遍历)
  • 迭代遍历(层次遍历)

递归遍历

Tip:

在理解的基础上记忆

如果你真的暂时理解不了,背也要先给你自己背下来,然后带着对正确思路的记忆,重新去看解析部分里的图文(尤其是图)、反复去理解,这么整下来你不可能学不会。
面试时见到二叉树的遍历,你不能再去想太多——没有那么多时间给你现场推理,这么熟悉的题目你没必要现场推理,你要做的是默写!默写啊!老哥们!!(捶胸顿足)

二叉树的结构

image.png 注:上面这个二叉树的结构,大家可以试着用我们前面学过的知识编码实现一把。:

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

递归函数的编写要点

编写一个递归函数之前,大家首先要明确两样东西:

  • 递归式
  • 递归边界

递归式,它指的是你每一次重复的内容是什么。在这里,我们要做先序遍历,那么每一次重复的其实就是 根结点 -> 左子树 -> 右子树 这个旅行路线。

递归边界,它指的是你什么时候停下来
在遍历的场景下,当我们发现遍历的目标树为空的时候,就意味着旅途已达终点、需要画上句号了。这个“画句号”的方式,在编码实现里对应着一个 return 语句——这就是二叉树遍历的递归边界。

三种遍历实现

后面就是熟悉的部分了

先序遍历

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

中序遍历

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

后序遍历

// 所有遍历函数的入参都是树的根结点对象
function preorder(root) {
    // 递归边界,root 为空
    if(!root) {
        return 
    }
     
    // 递归遍历左子树 
    preorder(root.left)  
    // 递归遍历右子树
    preorder(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 的头部,改造后的代码会变成这样:

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
};

中序遍历

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
};

两个 while :内层的 while 的作用是在寻找最左叶子结点的过程中,把途径的所有结点都记录到 stack 里。记录工作完成后,才会走到外层 while 的剩余逻辑里——这部分逻辑的作用是从最左的叶子结点开始,一层层回溯遍历左孩子的父结点和右侧兄弟结点,进而完成整个中序遍历任务。

层次遍历

先从DFS和BFS谈起

深度优先搜索的本质——栈结构

image.png 图中蓝色的是入口,灰色的是岔路口,黑色的是死胡同,绿色的是出口。

基于眼前的这个迷宫结构,我们来一步一步模拟一下深度优先搜索的具体过程:

  1. 从 A 出发,沿着唯一的一条道路往下走,遇到了第1个岔路口B。眼前有三个选择:CDE。这里我按照从上到下的顺序来走(你也可以按照其它顺序),先走C
  2. 发现 C是死胡同,后退到最近的岔路口 B,尝试往D方向走。
  3. 发现D 是死胡同,,后退到最近的岔路口 B,尝试往E方向走。
  4. E 是一个岔路口,眼前有两个选择:F 和 G。按照从上到下的顺序来走,先走F
  5. 发现F 是死胡同,后退到最近的岔路口 E,尝试往G方向走。
  6. G 是一个岔路口,眼前有两个选择:H 和 I。按照从上到下的顺序来走,先走H
  7. 发现 H 是死胡同,后退到最近的岔路口 G,尝试往I方向走。
  8. I 就是出口,成功走出迷宫。

大家观察一下这个过程,会不会觉得这些前进、后退的操作,其实和栈结构的入栈、出栈过程非常相似呢?
现在我们把迷宫中的每一个坐标看做是栈里的一个元素,用栈来模拟这个过程:

  1. 从 A 出发(A入栈),经过了BB入栈),接下来面临 CDE三条路。这里按照从上到下的顺序来走(你也可以选择其它顺序),先走CC入栈)。
  2. 发现 C是死胡同,后退到最近的岔路口 BC出栈),尝试往D方向走(D入栈)。
  3. 发现D 是死胡同,,后退到最近的岔路口 BD出栈),尝试往E方向走(E入栈)。
  4. E 是一个岔路口,眼前有两个选择:F 和 G。按照从上到下的顺序来走,先走FF入栈)。
  5. 发现F 是死胡同,后退到最近的岔路口 EF出栈),尝试往G方向走(G入栈)。
  6. G 是一个岔路口,眼前有两个选择:H 和 I。按照从上到下的顺序来走,先走HH入栈)。
  7. 发现 H 是死胡同,后退到最近的岔路口 GH出栈),尝试往I方向走(I入栈)。
  8. I 就是出口,成功走出迷宫。

此时栈里面的内容就是ABEGI,因此 A->B->E->G->I 就是走出迷宫的路径。通过深度优先搜索,我们不仅可以定位到迷宫的出口,还可以记录下相关的路径信息。

广度优先搜索与和队列有着密不可分的关系

image.png 按照 BFS 的遍历规则,具体的访问步骤会变成下面这样:

  1. 站在入口A处(第一层),发现直接能抵达的坐标只有B,于是接下来需要访问的就是 B
  2. 入口A访问完毕,走到 B 处(第二层),发现直接能抵达的坐标变成了CDE,于是把这三个坐标记为下一层的访问对象。
  3. B访问完毕,访问第三层。这里我按照从上到下的顺序(你也可以按照其它顺序),先访问 CD,然后访问E。站在C处和D处都没有见到新的可以直接抵达的坐标,所以不做额外的动作。但是在E处见到了可以直接抵达的FG,因此把FG记为下一层(第四层)需要访问的对象。
  4. 第三层访问完毕,访问第四层。第四层按照从上到下的顺序,先访问的是 F。从F出发没有可以直接触达的坐标,因此不做额外的操作。接着访问G,发现从G出发可以直接抵达HI,因此把HI记为下一层(第五层)需要访问的对象。
  5. 第四层访问完毕,访问第五层。第五层按照从上到下的顺序,先访问的是H,发现从H出发没有可以直接抵达的坐标,因此不作额外的操作。接着访问I,发现I就是出口,问题得解。

当然啦,这个问题若采用 BFS 的思路来解,那么它其实已经不能说是一个严格的迷宫游戏了——在一个真正的迷宫游戏里,大概率并不会允许我们如此顺利地逐个访问身在同一层次的所有坐标(比如CD之间可能就会隔了厚厚的一堵墙,导致你无法在访问C后直接去访问D)。这里我们基于迷宫游戏,抽象出来的其实是一个更为简单的模型。大家不必拘泥于游戏本身,而应该着重理解这个分层遍历的过程。

在分层遍历的过程中,大家会发现两个规律:

  1. 每访问完毕一个坐标,这个坐标在后续的遍历中都不会再被用到了,也就是说它可以被丢弃掉。
  2. 站在某个确定坐标的位置上,我们所观察到的可直接抵达的坐标,是需要被记录下来的,因为后续的遍历还要用到它们。

丢弃已访问的坐标、记录新观察到的坐标,这个顺序毫无疑问符合了“先进先出”的原则,因此整个 BFS 算法的实现过程,和队列有着密不可分的关系
下面我用一个队列 queue 来模拟一下上面的过程:

  1. 初始化,先将入口A入队(queue里现在只有A)。
  2. 访问入口A(第一层),访问完毕后将A出队。发现直接能抵达的坐标只有B,于是将B入队(queue里现在只有B)。
  3. 访问B(第二层),访问完毕后将B出队。发现直接能抵达的坐标变成了CDE,于是把这三个坐标记为下一层的访问对象,也就是把它们全部入队(queue里现在是CDE
  4. 访问第三层。这里我按照从上到下的顺序(你也可以按照其它顺序),先访问 C(访问完毕后C出队)和D(访问完毕后D出队),然后访问E(访问完毕后E出队)。访问C处和D处都没有见到新的可以直接抵达的坐标,所以不做额外的动作。但是在E处我们见到了可以直接抵达的FG,因此把FG记为下一层(第四层)需要访问的对象,FG依次入队(queue里现在是 FG)。
  5. 访问第五层。第五层按照从上到下的顺序,先访问的是H(访问完毕后H出队),发现从H出发没有可以直接抵达的坐标,因此不作额外的操作。接着访问I(访问完毕后I出队),发现I就是出口,问题得解(此时 queue 队列已经被清空)。

在这个过程里,我们其实循环往复地做了以下事情:
依次访问队列里已经有的坐标,将其出队;记录从当前坐标出发可直接抵达的所有坐标,将其入队。

代码实现

image.png

function BFS(root) {
    const queue = [] // 初始化队列queue
    // 根结点首先入队
    queue.push(root)
    // 队列不为空,说明没有遍历完全
    while(queue.length) {
        const top = queue[0] // 取出队头元素  
        // 访问 top
        console.log(top.val)
        // 如果左子树存在,左子树入队
        if(top.left) {
            queue.push(top.left)
        }
        // 如果右子树存在,右子树入队
        if(top.right) {
            queue.push(top.right)
        }
        queue.shift() // 访问完毕,队头元素出队
    }
}

结尾

参考:前端算法与数据结构面试:底层逻辑解读与大厂真题训练 - 修言 - 掘金课程 (juejin.cn)

我这次也就是整理了一些小册的内容,搭建了自己对二叉树的理解。 后面就要进入到实战的环节了!!