[算法拆解] 一文说透二叉树的遍历套路

1,201 阅读13分钟

二叉树的遍历

系列开篇

为进入前端的你建立清晰、准确、必要概念和这些概念的之间清晰、准确、必要关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。

算法拆解是带你分析算法,掌握算法规律,体会概念与关联的力量。更重要的是让你不害怕算法题,利用分治思维拆解它,你会发现,又是回到了 概念关联 上。下面来看看我们遇到问题,如何去拆解, 并举一反三的吧。

算法是逃不掉的,它是你最直接的实现程序能力的体现。你写的每一句代码,每一种架构思考,每一种优化方式,都是你编程路上的硬核能力,所幸这个能力下'功夫'就能得到。

面试题

  • 手写二叉树的先序/中序/后序遍历
  • 手写二叉树的层序遍历
  • 说说这些遍历方式的不同
  • 你能不用递归用迭代方式实现遍历吗

二叉树的 先序/中序/后序 遍历

给你二叉树的根节点 root ,返回它节点值的 前/中/后 序 遍历。

挂上leetcode链接,可以用来查看更多示例和测试,并最后通过它。

首先我们把这句话拆成以下几个问题

  • 什么是二叉树
  • 什么是先序/中序/后序
  • 什么是遍历

下面一个个分析,这就是拆解,慢慢来不急,有时候慢就是快

什么是二叉树

我们的问题又能拆为

  • 什么是
  • 二叉树是什么树 (二叉树是怎样性质的树)

(这两个问题 后面单独出文章吧 内容比较多)

什么是遍历 (Traversal)

遍历就是可以理解:使每个结点被访问一次仅被访问一次。而且限制了从左到右的习惯方式。

什么是先序/中序/后序

所谓前序、中序、后序,不过是根的顺序,即也可以称为前根遍历、中根遍历、后根遍历

  • 前序 对于二叉树中的任意一个节点,先打印该节点,然后是它的左子树,最后右子树 根 -> 左 -> 右
  • 中序 对于二叉树中的任意一个节点,先打印它的左子树,然后是该节点,最后右子树 左 -> 根 -> 右
  • 后序 对于二叉树中的任意一个节点,先打印它的左子树,然后是右子树,最后该节点 左 -> 右 -> 根

举个例子

树的图例          

       1
         \
           2
         /
       3 
       
输入:root = [1, null, 2, 3]

先序输出:[1, 2, 3]   
中序输出:[1, 3, 2]  
后序输出:[3, 2, 1]

我们举个例子 root = [1, null, 2, 3] 先序遍历 为什么是 [1, 2, 3]按以下步骤思考

  1. 先序任意节点的打印顺序都是 根 -> 左 -> 右
  2. 那么首先看root 为 1 (左) (右) => 1 (null) (2)
  3. null 跳过 右子树 val 是 2 继续 按照 根 -> 左 -> 右 此时 2 为根
  4. 2 (左) (右) => 2 (3) (null)
  5. 合并看 2 , 4 其实就是一个序列 [1, null, 2, 3, null]
  6. 把 null 忽视掉 得到输出 [1, 2, 3]

那么中序也是同理: 我简单列两步

  1. 中序任意节点的打印顺序都是 左 -> 根 -> 右
  2. 那么首先看root 为 (左) 1 (右) => (null) 1 (2)
  3. null 跳过 右子树 val 是 2 继续 按照 左 -> 根 -> 右 此时 2 为根
  4. (左) 2 (右) => (3) 2 (null)
  5. 合并看 2 , 4 其实就是一个序列 [null, 1, 3, 2, null]
  6. 把 null 忽视掉 得到输出 [1, 3, 2]

后序就不细说了,我想你应该会了

所以到此我们清楚了题意:

二叉树的(前/中/后)遍历是指 从根结点出发,按照某种次序(前/中/后)依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。

下面就给出递归解法思路:

前序遍历实现

const preOrderTraversalRecursion = (root) => {
  let resList = []

  const preOrderTraversal = (node) => {
    if (node !== null) {
      // 先访问根节点
      resList.push(node.val)               // ---------> (根)
      // 然后遍历左子树
      preOrderTraversal(node.left)         // ---------> (左)
      // 再遍历右子树
      preOrderTraversal(node.right)        // ---------> (右)
    }
  }
  
  // 根节点作为第一个参数传入
  preOrderTraversal(root)
  return resList
};

中序遍历实现

const midOrderTraversalRecursion = (root) => {
  let resList = []
  const midOrderTraversal = (node) => {
    if(node !== null) {
      // 先遍历左子树
      midOrderTraversal(node.left)           // ---------> (左)
      // 然后访问根节点
      resList.push(node.val)                 // ---------> (根)
      // 再遍历右子树
      midOrderTraversal(node.right)          // ---------> (右)
    }
  }
  midOrderTraversal(root)
  return resList
}

后序遍历 我也就不写了,再明显不过了。

递归写法就只是访问根的顺序变下,其他都遍历一模一样。

解决这方面问题,用【递归】我们的关键思路是

每个节点需要做的事情是一样的,我们只要弄清每个节点该干嘛就行

比如先序遍历,每个节点就是干这几件事

  • 打印自己 (访问根)
  • 对自己左子树的遍历
  • 对自己右子树的遍历 递归出口 就是 当前节点为 null 就没有再左右子树一说了,也不必打印自己了

其实递归就2要素:

  1. 自己调用自己
  2. 有个明确的递归出口 (结束条件)

好了 我想这个递归解决问题思路你已掌握。下一步

迭代解决上面的 二叉树的 先序/中序/后序 遍历

首先迭代算法可能你并不需要掌握,只是用来练练思维和编程实现手法。

前序遍历迭代实现

思路:

  • 建立一个栈(后进先出的数据结构)
  • 首先根入栈
  • 将根节点出栈,将根节点值放入结果数组中
  • 然后遍历左子树、右子树,因为栈是先入后出,所以先右子树入栈,后左子树入栈
  • 继续出栈。。直到栈空

实现:

const preOrderTraversalIteration = (root) => {
  const resList = [];
  // 建立一个栈,注意栈是后进先出的数据结构
  const stack = [];
  
  // 当根节点不为null的时候,将根节点入栈
  if (root !== null) {
    stack.push(root)
  }

  while(stack.length > 0) {
    // 推出栈顶元素为当前访问的节点
    let curNode = stack.pop()

    // 访问当前节点,第一步的时候,访问的是根节点
    resList.push(curNode.val)
    
    // 先序遍历的节点访问顺序是 根->左子树->右子树
    // 而先入栈的后访问,所以先入栈右子树,再入栈左子树,这样就左子树就会先被栈弹出来访问,再右子树

    // 所以先入栈 右子树 这样 右子树就会在后面被 pop() 也就是后访问到
    if(curNode.right !== null) {
      stack.push(curNode.right)
    }

    if(curNode.left !== null) {
      stack.push(curNode.left)
    }
  }
  
  return resList
}

后序遍历迭代实现

为什么这里先写后序遍历,因为有个取巧方法。

二叉树后序遍历迭代实现,其实就是先序遍历2个步骤的逆序,其实就是

  1. 把先序遍历结果先逆序,实现方式也简单: 插入结果列表时 resList.push(node.val) 变更为 resList.unshift(node.val) 就行了,那么遍历顺序就由 根 左 右 变更为 右 左 根
  2. 然后我们仅需将 右 左 根 变更为 左 右 根 即可完成后序遍历。做法就是左右子树入栈顺序改变就行了。 实现
const postOrderTraversalIteration = (root) => {
  const resList = [];
  let stack = [];
  
  if (root !== null) {
    stack.push(root)
  }

  while(stack.length > 0) {
    let curNode = stack.pop()

    // 根 左 右 => 右 左 根
    resList.unshift(curNode.val)

    // 右 左 根 => 左 右 根
    if(curNode.left !== null) {
      stack.push(curNode.left)
    }

    if(curNode.right !== null) {
      stack.push(curNode.right)
    }
  }

  return resList
}

中序遍历迭代实现

const midOrderTraversalIteration = (root) => {
  const resList = []
  let stack = []
  let curNode = null

  if (root !== null) {
    curNode = root
  }

  // 当栈不为空 或者 当前节点不为空时进行循环
  while (stack.length > 0 || curNode !== null) {
    // 中序遍历,是先访问左子树 => 再根 => 再右子树
    while (curNode !== null) {
      // 我们现在当前的是根,得先入栈,把当前节点置为它的左子树
      stack.push(curNode)
      curNode = curNode.left
    }
    if (stack.length > 0) {
      // 从栈中取东西了,把最上面先推出来
      curNode = stack.pop()
      // 进行访问, 输入数据到结果序列
      resList.push(curNode.val)
      // 把当前节点置为它的右子树
      curNode = curNode.right
    }
  }
}

这个看的可能一脸懵逼,简单来说下面几个步骤,尝试理解

  1. 遇到根节点,先入栈,如果它有左节点,那么继续入栈左节点直到当前入栈的节点没有左子树了,那么可以理解为,当前为最左边的节点
  2. 出栈,栈顶是刚入栈的最左节点 并 打印 最左边的节点
  3. 如果这个最左节点有右子树,那么当前应该访问它的右子树了,因为这个节点没有左儿子,现在它自己被打印了当成根看,下面就是右子树的访问了 左 根 右
  4. 所以,当前节点变成了右子树,本次循环结束
  5. 把现在的 右子树 当成第一次进来的根 来进行下一轮循环
  6. 再从第一步开始 入栈,看有没有左子树 继续找这个节点的最左节点 入栈最左节点。。。

二叉树的层序遍历

先了解问题是什么 看下面题目就很清楚了, 层序 逐层地,从左到右访问所有节点。示例:二叉树层序遍历

二叉树:[3,9,20,null,null,15,7] ,

      3
    /   \
  9      20
       /    \
     15       7
返回其层次遍历结果:

[
  [3],
  [9,20],
  [15,7]
]

我们可以看到层序遍历的结果是个二维数组。 似乎我们可以把根作为起点来走,左右子树当成岔道,来进行BFS或DFS查找,查找其实过程就是遍历。 找东西嘛,就是按照某种策略(DFS/BFS),一个个找过去(遍历)。

我们先简单了解 DFS / BFS 到时候查找算法会精说,先留个印象。

DFS(Deep First Search)深度优先搜索。

深度优先搜索的步骤分为

  1. 递归下去
  2. 回溯上来
  • 顾名思义,深度优先,先一条路走到底,直到达到目标。这里称之为递归下去
  • 否则既没有达到目标又无路可走了,那么则退回到上一步的状态走其他路。这便是回溯上来

BFS(Breath First Search)广度优先搜索。

广度优先搜索较之深度优先搜索之不同在于,深度优先搜索旨在不管有多少条岔路,先一条路走到底,不成功就返回上一个路口然后就选择下一条岔路。

而广度优先搜索旨在面临一个路口时,把所有的岔路口都记下来,然后选择其中一个进入,然后将它的分路情况记录下来,然后再返回来进入另外一个岔路,并重复这样的操作

两者不同

  1. 数据结构上的运用 这个结合下面代码理解
  • DFS用递归的形式,用到了结构,先进后出
  • BFS选取状态用队列的形式,先进先出
  1. 复杂度

DFS的复杂度与BFS的复杂度大体一致,不同之处在于遍历的方式与对于问题的解决出发点不同,

  • DFS适合目标明确
  • BFS适合大范围的寻找
  1. 思想 思想上来说这两种方法都是遍历穷举所有的情况

下面来说这跟我们的层次遍历解法之间有啥关系。其实就是说层次遍历可以用2种方案实现。

层次遍历BFS实现

BFS 是按层层推进的方式,遍历每一层的节点。题目要求的是返回每一层的节点值,所以这题用 BFS 来做非常合适。

const levelOrderBFS = function(root) {
  let resList = []
  let queue = []

  if (root !== null) {
    queue.push(root)
  }
  
  while(queue.length > 0) {
    let curLevelList = []
    let levelSize = queue.length

    while(levelSize > 0) {
      // 队列先进先出,用shift从队列头取出node并访问
      let node = queue.shift()
      // 把node推进每层的list
      curLevelList.push(node.val)
      // 当前层list长度 - 1
      levelSize--
      // 这个node的左右子树就是下一层的node节点
      // 所以分别把当前取出的node的左右子树推进一个临时列表,作为下层循环的队列
      // 层次遍历从左到右,先进左子树后进右子树
      if(node.left !== null) {
        queue.push(node.left)
      }
      if(node.right !== null) {
        queue.push(node.right)
      }

    }
    // 把当前层list推到结果列表
    resList.push(curLevelList)
  }
  return resList
};

BFS 需要用队列作为辅助结构,下面是简单说明步骤

  1. 我们先将根节点放到队列中,第一层有一个节点,队列长度为 1 ,这个长度也就表示该层有几个节点
  2. 取出队列头部node 第一次就是根 值放进该层list 并把它左右子树入队列,(其实就是记录这层有多少岔道)
  3. 把该层list 放进结果数组就行了,一层的遍历就完成了,下面第二层同样的操作就行了,继续重复

实现上来说2层循环,外层循环一次代表一层,得到一个列表,放进结果数组,内部循环循环该层队列中的每个节点,并把这层节点的左右子树推到下一层队列中。(因为循环次数为当前层节点个数,所以后面推进去的是下一层循环才会用到)

层次遍历DFS实现

DFS 是沿着树的深度遍历树的节点,尽可能深地搜索树的分支

const levelOrderDFS = function(root) {
    const resList = []
    var depthFirst = function (node, level){
        if(node === null) {
          return
        }
        // 当遍历到一个新的深度 level, 新建一个列表数组
        resList[level] = resList[level] || []

        // 把节点值推进当前它的level对应的list中
        resList[level].push(node.val)

        // 递归调用, 当前节点有左右子树,下级循环level + 1
        depthFirst(node.left, level + 1)
        depthFirst(node.right, level + 1)
    }
    // 0正好代表根节点 level 也是数组的开始 index
    depthFirst(root, 0)
    return resList
};

DFS 不是按照层次遍历的。为了让递归的过程中同一层的节点放到同一个列表中,在递归时要记录每个节点的深度 level 。递归到新节点要把该节点推入 对应 level 的列表的末尾

当遍历到一个新的深度 level,而最终结果 resList 中还没有创建 level 对应的列表时,应该在 resList 中新建一个列表用来保存该 level 的所有节点。

小结

  • 二叉树的遍历是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。
  • 所谓前序、中序、后序,不过是根的顺序。前序(/左/右) 中序(左//右) 后序(左/右/)
  • 前序、中序、后序递归写法核心
  const xOrderTraversal = (node) => {
    if (node !== null) {
      // 前序 resList.push(node.val) 
      
      preOrderTraversal(node.left)

      // 中序 resList.push(node.val) 

      preOrderTraversal(node.right) 

      // 后序 resList.push(node.val) 
    }
  }
  • 写树相关的算法,搞清楚当前 root 节点该做什么,然后根据函数定义递归调用子节点,递归调用说明每个节点执行的方法都是一样的
  • 层序遍历逐层地,从左到右访问所有节点
  • 层序遍历可以用BFS / DFS两种思路解决。

继续下去,你总会有收获。 上面这句话给你们,同样也给我自己前进的动力。

我是摩尔,数学专业,做过互联网研发,测试,产品
致力用技术改变别人的生活,用梦想改变自己的生活
关注我,找到自己的互联网思路,踏实地打牢固自己的技术体系

参考