二叉树在面试实战中,花样非常多。本节只是个开头,在后面几个专题、包括最后的大厂真题实战环节中,我们都不会停止对二叉树相关考点的学习和探讨。

在本节,有以下三个命题方向需要大家重点掌握:

  • 迭代法实现二叉树的先、中、后序遍历
  • 二叉树层序遍历的衍生问题
  • 翻转二叉树

这三个方向对应的考题都比较经典。与此同时,解决这些问题涉及到的思路和编码细节,也会成为各位日后解决更加复杂的问题的基石。因此,虽然本节篇幅略长,但还是希望各位能够倾注耐心,给自己充分的时间去理解和消化这些知识。

# “遍历三兄弟”的迭代实现

30 秒速记

  • 递归背后是调用栈;改迭代就是自己用栈记住「之后还要回来处理」的节点
  • 先序(根左右):弹出就记录,先压右再压左
  • 中序(左根右):一路向左压栈到底 → 弹出记录 → 转向右子树
  • 后序(左右根):按「根右左」收集后整体反转,或者用 lastVisited 标记
  • 中序外层条件是 cur || stack.length,两个都不能少

三种遍历改成迭代,本质都是用栈模拟递归,区别只在根节点什么时候被记录。 先序最简单:弹出一个节点就记录,然后先压右孩子再压左孩子,因为栈是后进先出,左边要先处理就得后压。中序不一样,根不能一弹出就记,要先沿着 left 一路压栈走到最左边,弹出来记录,再把游标转到它的右子树重复这个过程。后序常用的技巧是先按「根右左」得到序列,最后整个反转成「左右根」。

function inorder(root) {
  const res = [], stack = []
  let cur = root
  while (cur || stack.length) {
    while (cur) { stack.push(cur); cur = cur.left } // 一路向左
    cur = stack.pop()
    res.push(cur.val)                               // 左边处理完,记录根
    cur = cur.right                                 // 转向右子树
  }
  return res
}
@前端进阶之旅: 代码已经复制到剪贴板

回答参考:“三种遍历的区别只是访问根节点的时机。迭代实现要把递归栈里的返回点显式化,我会先说栈内节点代表什么,再写循环。”

function inorder(root) {
  const result = []
  const stack = []
  let current = root
  while (current || stack.length) {
    while (current) {
      stack.push(current)
      current = current.left
    }
    current = stack.pop()
    result.push(current.val)
    current = current.right
  }
  return result
}
@前端进阶之旅: 代码已经复制到剪贴板

面试官追问

追问 1中序能不能像先序一样,节点一出栈就记录、再压左右孩子?
参考回答

不行。只要根有左孩子,根一出栈就记录,它就排在左子树前面了,违反「左根右」。中序必须先一路向左压栈,左边处理完才轮到根。

追问 2中序迭代里,stack 里的节点到底代表什么?
参考回答

代表「左子树还没处理完、自己还没输出」的祖先节点,相当于递归里那些在等左子树返回的调用帧。弹出一个就等于左递归结束了,接着处理根、进入右子树。

追问 3树是一条只有右孩子的链,外层只写 while (stack.length) 会怎样?
参考回答

弹出根、转向右孩子时栈刚好是空的,循环就提前结束了,只输出第一个节点。必须写 cur || stack.length,cur 不为空说明还有右子树要处理。

追问 4中序结果漏掉了所有右子树,左边顺序是对的,先看哪一行?
参考回答

看弹栈记录后有没有 cur = cur.right。漏了这行,游标永远不会进入右子树。拿「根只带一个右孩子」的三行用例就能复现。

经过第5节的学习,相信各位已经将二叉树先、中、后序遍历的递归实现吃得透透的了。在使用递归实现遍历的过程中,我们明显察觉到,“遍历三兄弟”的编码实现也宛如孪生兄弟一样,彼此之间只有代码顺序上的不同,整体内容基本是一致的。这正是递归思想的一个重要的优点——简单。

这里的“简单”并不是说学起来简单。相反,结合笔者早期的读者调研来看,大部分同学都认为递归学起来让人很难受(这也是正常的)。 初学递归的人排斥递归,大部分是出于对“函数调用自身”这种骚操作的不适应。但只要你能克服这种不适应,并且通过反复的演练去吸收这种解题方法,你就会发现递归真的是个好东西。因为通过使用递归,我们可以把原本复杂的东西,拆解成非常简单的、符合人类惯用脑回路的逻辑。

这样说可能还是有点抽象,不过没关系,接下来我会讲解“遍历三兄弟”对应的迭代解法。等我们学完这坨东西之后,心怀疑惑的同学不妨拿迭代法的代码和第5节中递归法的代码比较一下,相信你会毫不犹豫地回头对递归说上一句“真香!”。

从先序遍历说起

题目描述:给定一个二叉树,返回它的前序(先序)遍历序列。

示例:

输入: [1,null,2,3]

1
 \
  2
 /
3
输出: [1,2,3]
@前端进阶之旅: 代码已经复制到剪贴板

进阶: 递归算法很简单,你可以通过迭代算法完成吗?

思路分析

注意最后那一行小字:“递归算法很简单,你可以通过迭代算法完成吗?”,对递归算法有疑问的同学,趁这个机会赶紧复习下第五小节,本节我们只讲迭代法。 前面两个小节,我们一直在强调,递归和栈有着脱不开的干系。当一道本可以用递归做出来的题,突然不许你用递归了,此时我们本能的反应,就应该是往栈上想。

在基于栈来解决掉这个题之前,我要先跟平时用 leetcode 刷题的各位强调一个常识。

现在大家回头看这道题目给我们的输入和输出:输入看似是一个数组,实则不是。大家谨记,二叉树题目的输入只要没有额外强调,那么一般来说它都是基于这样的一个对象结构嵌套而来的:

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"
    }
  }
};
@前端进阶之旅: 代码已经复制到剪贴板

话说回来,为啥题上给的不是对象,而是这样的一个数组呢:

[1,null,2,3]
@前端进阶之旅: 代码已经复制到剪贴板

这其实是一种简化的写法,性质跟咱们写伪代码差不多。它的作用主要是描述二叉树的值,至于二叉树的结构,我们以题中给出的树形结构为准:

1
  \
   2
  /
3
@前端进阶之旅: 代码已经复制到剪贴板

OK,了解了输入内容,现在再来看输出:

[1,2,3]
@前端进阶之旅: 代码已经复制到剪贴板

这里这个输出就简单多了,它是一个真数组。为什么可以判断它是一个真数组呢?因为题目中要求你输出的是一个遍历序列,而不是一个二叉树。因此大家最后需要塞入结果数组的不是结点对象,而是结点的值。

注意:

  • 以上的出参入参规律,是针对 leetcode 及其周边 OJ 来说的。OJ 中这样编写题目描述,是情理之中,因为 OJ 本身是支持多语言的,它只能对最通用的一部分信息进行透出。在面试场景下,不排除一些公司可能会贴心地把 JS 版本的出参和入参给出来,但更多的还是会直接复制粘贴 leetcode 或者其它一些算法书中的原题。如果你对题目的出参和入参有疑问,请大胆地对面试官说出你的困惑——没有一个正常面试官会在题目描述上为难你, 他比你更急切地想看到你刷刷写代码的英姿。
  • 回到题目上来。我们接着栈往下说,题目中的出参是一个数组,大家仔细看这个数组,它像不像是一个栈的出栈序列?实际上,做这道题的一个根本思路,就是通过合理地安排入栈和出栈的时机、使栈的出栈序列符合二叉树的前序遍历规则。

前序遍历的规则是,先遍历根结点、然后遍历左孩子、最后遍历右孩子——这正是我们所期望的出栈序列。按道理,入栈序列和出栈序列相反,我们似乎应该按照 右->左->根 这样的顺序将结点入栈。不过需要注意的是,我们遍历的起点就是根结点,难道我们要假装没看到这个根结点、一鼓作气找到最右侧结点之后才开始进行入栈操作吗?答案当然是否定的,我们的出入栈顺序应该是这样的:

  • 将根结点入栈
  • 取出栈顶结点,将结点值 push 进结果数组
  • 若栈顶结点有右孩子,则将右孩子入栈
  • 若栈顶结点有左孩子,则将左孩子入栈

这整个过程,本质上是将当前子树的根结点入栈、出栈,随后再将其对应左右子树入栈、出栈的过程。

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

编码实现

/**
 * @param {TreeNode} root
 * @return {number[]}
 */
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)