前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
  • 前端算法面试

    • 数组基础
    • 栈、队列与链表
    • 树与二叉树
    • 二叉树递归遍历
    • 时间与空间复杂度
    • 数组高频题
    • 字符串高频题
    • 链表基础题
    • 链表双指针
    • 环形链表
    • 栈的经典应用
    • 队列与双端队列
    • DFS 与 BFS
    • 递归与回溯
    • 二叉树高频题
      • “遍历三兄弟”的迭代实现
        • 异曲同工的后序遍历迭代实现
        • 编码复盘
      • 层序遍历的衍生问题
      • 翻转二叉树
    • 二叉搜索树
    • 平衡二叉树
    • 堆与堆排序
    • 基础排序算法
    • 归并与快速排序
    • 动态规划入门
    • 动态规划进阶
完整面试题地址:
作者:程序员poetry
扫码关注作者公众号:「前端进阶之旅」 每天分享技术干货
前端进阶之旅公众号二维码

二叉树真题归纳与解读|算法篇

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

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

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

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

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

30 秒速记

  • 递归隐含使用调用栈;改迭代就是显式保存“稍后还要处理”的节点。
  • 前序 根-左-右:弹出根后先压右、再压左。
  • 中序:一路压左,栈空前弹出访问,再转向右子树。
  • 后序最容易错,可用 根-右-左 结果反转,或栈中保存 visited 状态。

三种遍历的迭代实现,本质上都是用栈安排结点的处理顺序,只是根结点被访问的时机不同。 前序遍历要得到“根、左、右”,弹出当前结点后应先压入右孩子,再压入左孩子,因为栈是后进先出。中序遍历则要沿左孩子一路入栈,走到最左侧后再逐个弹出并转向右子树。空树直接返回空数组,结果中保存的是结点值,而不是结点对象。

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

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评论区有人说中序遍历也能像前序一样“根节点出栈就立刻输出”,你拿页面上的 左 -> 根 -> 右 规则怎么构造反例?
参考回答

只要根节点存在左孩子,根一出栈就输出便会早于左子树,直接违反 左 -> 根 -> 右。中序迭代必须先沿 left 一路压栈,直到没有左节点,再弹出并访问根;根的输出时机不能照搬前序框架。

追问 2你在白板上写页面里的 inorder(root),面试官追问 stack 中的节点究竟代表什么,你会怎样结合两个 while 解释?
参考回答

stack 保存的是沿左链经过、但尚未输出的祖先节点,相当于显式记录递归返回点。内层 while 持续压入左侧路径,外层循环弹出最近祖先并访问,再把 current 转向其右子树;两部分共同完成回溯。

追问 3组件树从普通深度变成一条只有右孩子的链,current || stack.length 这个循环条件还成立吗,流程会怎样变化?
参考回答

条件仍然成立,每轮内层循环只压入当前节点一次,随后立即弹出、输出并转向右孩子。即使 stack 暂时为空,只要 current 指向下一个节点,外层循环就必须继续;若只判断 stack.length,遍历会提前终止。

追问 4线上埋点发现中序结果漏掉右子树,而左侧节点顺序正常;你会优先检查页面示例中的哪两行状态迁移?
参考回答

先检查弹栈输出之后是否执行了 current = current.right,以及外层条件是否保留 current || stack.length。前者缺失会永远不进入右子树,后者若只看栈可能在转向右孩子时提前结束;可用根节点带单个右孩子的最小用例复现。

追问 5代码评审中,一方主张分别维护三套遍历代码,另一方要求统一成 { node, visited } 模板;就当前中序页面你如何取舍?
参考回答

页面代码直接把中序的“沿左链下探—弹栈访问—转向右侧”表达出来,状态含义清晰,适合单独维护和讲解。标记模板能统一形式,却引入额外栈帧状态;若团队更看重可读性,不必为了统一而隐藏中序的游标逻辑。

经过第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)
      // 若当前子树根结点有右孩子,则将右孩子入栈
      if(cur.right) {
          stack.push(cur.right)
      }
      // 若当前子树根结点有左孩子,则将左孩子入栈
      if(cur.left) {
          stack.push(cur.left)
      }
  }
  // 返回结果数组
  return res
};
@前端进阶之旅: 代码已经复制到剪贴板

# 异曲同工的后序遍历迭代实现

30 秒速记

  • 后序要求左右子树都处理完才访问根,迭代时必须记录右子树是否已经访问
  • 简洁方案是按“根 → 右 → 左”收集后整体反转,得到“左 → 右 → 根”;直接单栈方案则用 lastVisited
  • 每个结点有限次入栈出栈,时间 O(n),栈空间 O(h) 到 O(n)

后序遍历可以沿用前序遍历的栈框架,但要从结果数组的头部插入结点,最终得到“左、右、根”的顺序。 因为先弹出的结点会被后续元素不断向后推,所以根结点最终能落到结果末尾。入栈时先放左孩子、再放右孩子,利用后进先出让右孩子先弹出,头插后左孩子仍位于右孩子之前。空树是边界情况,直接返回空数组即可。

← 递归与回溯二叉搜索树 →

fe
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
  • 前端算法面试

    • 数组基础
    • 栈、队列与链表
    • 树与二叉树
    • 二叉树递归遍历
    • 时间与空间复杂度
    • 数组高频题
    • 字符串高频题
    • 链表基础题
    • 链表双指针
    • 环形链表
    • 栈的经典应用
    • 队列与双端队列
    • DFS 与 BFS
    • 递归与回溯
    • 二叉树高频题
      • “遍历三兄弟”的迭代实现
        • 异曲同工的后序遍历迭代实现
        • 编码复盘
      • 层序遍历的衍生问题
      • 翻转二叉树
    • 二叉搜索树
    • 平衡二叉树
    • 堆与堆排序
    • 基础排序算法
    • 归并与快速排序
    • 动态规划入门
    • 动态规划进阶