二叉树真题归纳与解读|算法篇
二叉树在面试实战中,花样非常多。本节只是个开头,在后面几个专题、包括最后的大厂真题实战环节中,我们都不会停止对二叉树相关考点的学习和探讨。
在本节,有以下三个命题方向需要大家重点掌握:
- 迭代法实现二叉树的先、中、后序遍历
- 二叉树层序遍历的衍生问题
- 翻转二叉树
这三个方向对应的考题都比较经典。与此同时,解决这些问题涉及到的思路和编码细节,也会成为各位日后解决更加复杂的问题的基石。因此,虽然本节篇幅略长,但还是希望各位能够倾注耐心,给自己充分的时间去理解和消化这些知识。
# “遍历三兄弟”的迭代实现
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)
后序遍历可以沿用前序遍历的栈框架,但要从结果数组的头部插入结点,最终得到“左、右、根”的顺序。 因为先弹出的结点会被后续元素不断向后推,所以根结点最终能落到结果末尾。入栈时先放左孩子、再放右孩子,利用后进先出让右孩子先弹出,头插后左孩子仍位于右孩子之前。空树是边界情况,直接返回空数组即可。
