LeetCode Hot 100 · 二叉树篇
二叉树是 Hot 100 里题量最大的一类(本篇 15 题),也是大厂手撕出现频率最高的数据结构。它的核心套路其实只有两个:遍历(前/中/后/层序,决定”何时处理当前节点”)和后序自底向上聚合(每个节点向上返回一个”单侧贡献”,同时用全局变量更新答案)。吃透这两条主线,15 题几乎可以一把梭。
约定:本文所有代码均使用 LeetCode 标准节点定义,类名
Solution,可直接提交编译:public class TreeNode { int val; TreeNode left; TreeNode right; TreeNode() {} TreeNode(int val) { this.val = val; } TreeNode(int val, TreeNode left, TreeNode right) { this.val = val; this.left = left; this.right = right; } }
#94 二叉树的中序遍历
题意
给定二叉树根节点 root,返回**中序遍历(左-根-右)**的节点值列表。题目同族还包括前序(根-左-右)、后序(左-右-根)。
输入:root = [1,null,2,3]
输出:[1,3,2]
题目本质:前/中/后序只是”处理当前节点值”的时机不同(先处理、中间处理、最后处理),DFS 框架完全相同。现实类比:读一本书——前序是先读标题再读内容,中序是读完左半本再读标题再读右半本,后序是读完全书内容再看标题索引。
难点与易错点
- 三种顺序的差别只有一个
add的位置:中序在左递归之后、右递归之前;前序提到最前,后序放到最后。手撕时一紧张就容易把位置写错。 - 递归出口
node == null不能漏,否则空指针异常。 - 迭代法要手写栈:中序是”一路压左 → 弹栈访问 → 转右”,跟前序/后序的入栈顺序完全不同,极容易混。
- 退化链表(整棵树只有右子树)时递归深度达到 O(n),可能栈溢出,面试可主动提一句。
解法一:递归(直观)
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
dfs(root, result);
return result;
}
private void dfs(TreeNode node, List<Integer> result) {
if (node == null) return; // 递归出口
// 中序:左 → 根 → 右
// 前序把 result.add 移到最前面,后序移到最后面
dfs(node.left, result); // 1. 遍历左子树
result.add(node.val); // 2. 处理当前节点
dfs(node.right, result); // 3. 遍历右子树
}
}
- 时间复杂度:每个节点恰好访问一次 → O(n)。
- 空间复杂度:递归调用栈深度 = 树高 h → O(h),最坏退化链表 O(n)。
- 瓶颈:递归依赖系统调用栈,深度受树高限制,最坏 O(n) 可能栈溢出,且无法”暂停/恢复”遍历。
解法二:迭代(显式栈,最优)
用 Deque 手动模拟递归栈,避免递归深度问题,也便于扩展(如按需暂停)。
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode cur = root;
// 中序:一路压入最左侧链,弹栈访问,再转右子树
while (cur != null || !stack.isEmpty()) {
while (cur != null) { // 1. 一路向左,把左侧链全部压栈
stack.push(cur);
cur = cur.left;
}
cur = stack.pop(); // 2. 弹栈访问(此时左子树已处理完)
result.add(cur.val);
cur = cur.right; // 3. 转向右子树
}
return result;
}
}
复杂度逐步推导:每个节点恰好进栈一次、出栈一次,进出栈合计 2n 次操作,每次 O(1),因此总时间 = 2n · O(1) = O(n)。空间上,栈里最多同时存放”从根到当前节点的最左侧链”,其长度等于树高 h,因此空间 = O(h),最坏退化链表 = O(n)。
进阶:Morris 遍历利用空闲指针线索化,空间可以压到 O(1)(时间仍 O(n)),字节/美团进阶面偶尔追问,了解思路即可。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 递归 | O(n) | O(h) | 面试首选,代码最短、最不易错 |
| 迭代(栈) | O(n) | O(h) | 树很深怕栈溢出,或需要”暂停/恢复”遍历时 |
| Morris | O(n) | O(1) | 极度受限内存、且会破坏树结构(需还原)的场合 |
为什么选递归:本题约束没有栈溢出风险,递归最简洁、最稳,是手撕正解;面试官若追问”不用递归呢”,再上迭代。
CodeTop 变体
- 字节/阿里高频追问:写出前序、后序的迭代写法(后序尤其爱考”单栈双状态”技巧)。
- 美团追问:Morris 遍历实现中序(O(1) 空间),要求讲清楚”线索化 + 还原”。
- 同套路延伸:99. 恢复二叉搜索树(中序找逆序对)、230. 二叉搜索树第 K 小(中序提前终止)、103. 锯齿形层序(前/后序 + 方向标志)。
#104 二叉树的最大深度
题意
给定二叉树根节点,返回其最大深度(根节点到最远叶节点路径上的节点数)。
输入:root = [3,9,20,null,null,15,7]
输出:3
题目本质:BFS 层序计数(每遍历完一层计数加一),或 DFS 递归 max(left, right) + 1。现实类比:统计大楼层数——BFS 像坐电梯逐层数,DFS 像爬楼梯到底再回传高度。
难点与易错点
- 空树要返回 0,这是递归基,漏了会空指针。
- BFS 必须按层处理:先用
levelSize = deque.size()快照当前层节点数,否则会把下一层节点混进本层。 - DFS 是
max(left, right) + 1,不是累加,很多新手误写成left + right + 1。
解法一:递归 DFS(直观)
class Solution {
public int maxDepth(TreeNode root) {
if (root == null) return 0; // 空树深度为 0
int left = maxDepth(root.left); // 左子树深度
int right = maxDepth(root.right); // 右子树深度
return Math.max(left, right) + 1; // 取较大侧 + 当前层
}
}
- 时间复杂度:每个节点访问一次 → O(n)。
- 空间复杂度:递归栈深度 = 树高 → O(h),最坏 O(n)。
- 瓶颈:DFS 单线程递归,退化树时栈深度 O(n),且无法在”中途”提前返回。
解法二:BFS 层序(最优 / 源材料主解)
class Solution {
public int maxDepth(TreeNode root) {
if (root == null) return 0;
Deque<TreeNode> deque = new ArrayDeque<>();
deque.offer(root);
int depth = 0;
while (!deque.isEmpty()) {
int levelSize = deque.size(); // 当前层节点数
// 处理当前层的所有节点
while (levelSize-- > 0) {
TreeNode node = deque.poll();
if (node.left != null) deque.offer(node.left);
if (node.right != null) deque.offer(node.right);
}
depth++; // 每处理完一层,深度加一
}
return depth;
}
}
复杂度逐步推导:BFS 每个节点入队一次、出队一次,进出队合计 2n 次操作,每次 O(1),时间 = O(n)。队列里最多同时存放”最宽一层的所有节点”,其数量为树宽 w(满二叉树最后一层可达 n/2),因此空间 = O(w),最坏 O(n)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 递归 DFS | O(n) | O(h) | 代码最短,面试首选 |
| BFS 层序 | O(n) | O(w) | 宽树比 DFS 省栈;且能顺便输出”每层”信息 |
| DFS 显式栈 | O(n) | O(h) | 怕递归栈溢出时 |
为什么选 BFS:层序遍历是”按层”问题的母模板,本题用它既直观又能直接复用 102/199 的框架,面试讲起来最有体系;DFS 版本作为一行式答案背下来即可。
CodeTop 变体
- 字节高频:111. 二叉树的最小深度——BFS 遇到第一个叶节点立刻返回(DFS 求最小深度有陷阱,不能直接
min(left,right)+1,需处理单侧为空的情况)。 - 阿里追问:110. 平衡二叉树——自底向上求高度时顺便判断是否失衡(返回 -1 标记)。
- 同套路:559. N 叉树的最大深度、662. 二叉树最大宽度。
#226 翻转二叉树
题意
给定二叉树根节点,翻转整棵树(左右子树互换),返回翻转后的根节点。
输入:root = [4,2,7,1,3,6,9]
输出:[4,7,2,9,6,3,1]
题目本质:后序递归——先翻转左右子树,再交换当前节点的左右指针(后序保证子树已翻转完毕)。现实类比:做镜像照片——先把照片左右两半分别做镜像,再把两半互换位置。
难点与易错点
- 后序顺序:先
invertTree(left)、invertTree(right),再交换left/right。若先交换再递归,虽然也能做(前序),但思路绕,容易出错。 - 交换要借临时变量:不能写
root.left = invertTree(root.right); root.right = invertTree(root.left);——此时root.left已被覆盖,第二行拿到的是翻转后的左子树,结果错误。 - 递归出口
root == null返回 null,空树直接返回。
解法一:递归后序(直观 / 源材料主解)
class Solution {
public TreeNode invertTree(TreeNode root) {
if (root == null) return null; // 空树直接返回
// 后序:先递归翻转子树,再交换
invertTree(root.left);
invertTree(root.right);
// 交换当前节点的左右子树
TreeNode tmp = root.left;
root.left = root.right;
root.right = tmp;
return root;
}
}
- 时间复杂度:每个节点访问一次并 O(1) 交换 → O(n)。
- 空间复杂度:递归栈深度 = 树高 → O(h),最坏 O(n)。
- 瓶颈:递归栈深度受限,退化树可能栈溢出。
解法二:迭代 BFS(优化)
用队列广度遍历,边出队边交换,避免递归栈。
class Solution {
public TreeNode invertTree(TreeNode root) {
if (root == null) return null;
Deque<TreeNode> deque = new ArrayDeque<>();
deque.offer(root);
while (!deque.isEmpty()) {
TreeNode node = deque.poll();
// 交换左右子树
TreeNode tmp = node.left;
node.left = node.right;
node.right = tmp;
if (node.left != null) deque.offer(node.left);
if (node.right != null) deque.offer(node.right);
}
return root;
}
}
复杂度逐步推导:每个节点恰好出队一次、交换一次,进出队合计 2n 次 O(1) 操作,时间 = O(n)。队列最多容纳最宽一层的 w 个节点,空间 = O(w),最坏满二叉树最后一层 = O(n)。相比递归版,最坏空间仍 O(n),但不依赖调用栈,退化链式结构下更安全。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 递归后序 | O(n) | O(h) | 面试首选,3 行搞定 |
| 迭代 BFS | O(n) | O(w) | 怕栈溢出 / 树极深时 |
为什么选递归:翻转本质是后序遍历,递归最贴合语义、最不易错;只有树极端深时才考虑迭代。
CodeTop 变体
- 字节超高频(传说 Mac 创始人面试题):原题直接手撕,要求一行式思维(
root.left=invert(root.right); root.right=invert(root.left)版本要能解释为何可行)。 - 变体追问:判断两棵树是否互为镜像(对照 101 题)、951. 翻转等价二叉树(两树通过若干次翻转能相同)。
- 同套路:617. 合并二叉树(两棵树上递归合并)。
#101 对称二叉树
题意
给定二叉树根节点,判断它是否轴对称(镜像对称)。
输入:root = [1,2,2,3,4,4,3]
输出:true
题目本质:双指针递归比较镜像关系——左子树的左 vs 右子树的右(外侧)、左子树的右 vs 右子树的左(内侧)。现实类比:检验蝴蝶翅膀——从中轴线两边同时展开,左翅每个点都要和右翅对应点一模一样。
难点与易错点
- 三个边界判断的顺序:两空 → true;一空 → false;值不等 → false。顺序不能乱,否则空指针。
- 比较的是”镜像位置”:是
left.left vs right.right和left.right vs right.left,不是left.left vs right.left——那是”相同的树”(100 题),不是对称。 - 不能简单判断”左子树结构 == 右子树结构”,对称要求的是”镜像结构”。
解法一:递归双指针(直观 / 源材料主解)
class Solution {
public boolean isSymmetric(TreeNode root) {
return isMirror(root.left, root.right);
}
private boolean isMirror(TreeNode left, TreeNode right) {
if (left == null && right == null) return true; // 都空:对称
if (left == null || right == null) return false; // 一空:不对称
if (left.val != right.val) return false; // 值不等:不对称
// 递归检查镜像位置:
// 外侧:左子树的左孩子 vs 右子树的右孩子
// 内侧:左子树的右孩子 vs 右子树的左孩子
return isMirror(left.left, right.right)
&& isMirror(left.right, right.left);
}
}
- 时间复杂度:每对镜像节点比较一次,共 n/2 对 → O(n)。
- 空间复杂度:递归栈深度 = 树高 → O(h),最坏 O(n)。
- 瓶颈:递归依赖调用栈,极端不对称时深度可达 O(n)。
解法二:迭代队列(优化)
成对入队,逐对比较,避免递归栈。
class Solution {
public boolean isSymmetric(TreeNode root) {
if (root == null) return true;
Deque<TreeNode> deque = new ArrayDeque<>();
deque.offer(root.left);
deque.offer(root.right);
while (!deque.isEmpty()) {
TreeNode left = deque.poll();
TreeNode right = deque.poll();
if (left == null && right == null) continue; // 都空,继续下一对
if (left == null || right == null) return false;
if (left.val != right.val) return false;
// 成对入队:外侧一对、内侧一对
deque.offer(left.left);
deque.offer(right.right);
deque.offer(left.right);
deque.offer(right.left);
}
return true;
}
}
复杂度逐步推导:每对镜像节点恰好入队一次、出队一次,共 n/2 对节点、n 次进出队操作,时间 = O(n)。队列最多同时存放”最宽一层的节点对”,约为树宽 w 的两倍,空间 = O(w),最坏 O(n)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 递归双指针 | O(n) | O(h) | 面试首选,语义最清晰 |
| 迭代队列 | O(n) | O(w) | 树极深时替代递归 |
为什么选递归:对称判断本质就是”两指针递归镜像”,递归写法与”镜像”定义一一对应,最不容易写错。
CodeTop 变体
- 字节/腾讯高频:100. 相同的树——递归同时判空、判值,两指针同步走。
- 阿里追问:572. 另一棵树的子树(一棵树是否包含另一棵结构相同的子树)。
- 同套路:951. 翻转等价二叉树、617. 合并二叉树。
#543 二叉树的直径
题意
给定二叉树,返回其直径——任意两节点间最长路径的边数,路径可以不经过根节点。
输入:root = [1,2,3,4,5]
输出:3 (路径 [4,2,1,3] 或 [5,2,1,3])
题目本质:DFS 后序——每个节点的直径贡献 = 左子树最大深度 + 右子树最大深度,返回值是”单侧最大深度”(供父节点用)。现实类比:找最长公路——每个城市统计”通过自己的最长路 = 左边最长路 + 右边最长路”,同时回报上级”我这一侧可借用的最长路段”。
难点与易错点
- 直径可以不经过根节点:必须用全局变量
ans在每个节点都更新,不能只算根节点的left + right。经典反例:根的一侧子树非常深,直径完全在子树内部。 - 返回值和答案不是一回事:递归函数返回”单侧最大深度”(供父节点拼接),答案记录的是”左深度 + 右深度”。
- 深度与直径的 +1 差异:求深度返回
max(left,right)+1(数节点),而直径用leftDepth + rightDepth直接相加,正好等于边数,别再多加 1。
解法一:朴素双重计算(暴力)
对每个节点都调用一次 depth 求子树高度,再拼左右高度更新直径。depth 本身 O(n),被 n 个节点重复调用。
class Solution {
private int maxDiameter = 0;
public int diameterOfBinaryTree(TreeNode root) {
if (root == null) return 0;
int throughRoot = depth(root.left) + depth(root.right); // 经过根的直径
int left = diameterOfBinaryTree(root.left); // 左子树内部直径
int right = diameterOfBinaryTree(root.right); // 右子树内部直径
return Math.max(throughRoot, Math.max(left, right));
}
// 求子树高度:每个节点都被重复遍历,导致 O(n^2)
private int depth(TreeNode node) {
if (node == null) return 0;
return Math.max(depth(node.left), depth(node.right)) + 1;
}
}
- 时间复杂度:n 个节点各调一次 O(n) 的
depth→ O(n²)。 - 空间复杂度:递归栈 O(h)。
- 瓶颈:每个节点的子树高度被重复计算,深度信息没有复用。
解法二:后序一次遍历(最优 / 源材料主解)
class Solution {
private int maxDiameter = 0; // 全局最大直径
public int diameterOfBinaryTree(TreeNode root) {
maxDepth(root);
return maxDiameter;
}
// 返回以 root 为根的子树最大深度,同时更新全局直径
private int maxDepth(TreeNode root) {
if (root == null) return 0;
int leftDepth = maxDepth(root.left);
int rightDepth = maxDepth(root.right);
// 经过当前节点的最长路径 = 左深度 + 右深度(边数)
maxDiameter = Math.max(maxDiameter, leftDepth + rightDepth);
// 返回给父节点可用的最大深度(单侧)
return Math.max(leftDepth, rightDepth) + 1;
}
}
复杂度逐步推导:一次后序遍历,每个节点恰好访问一次,每个节点内部只做常数次 Math.max 运算,因此总时间 = n · O(1) = O(n)(对比暴力版:把 n 次 O(n) 的深度计算合并进同一次遍历,省掉了 n 倍的重复)。空间 = 递归栈深度 = 树高 h → O(h),最坏退化链表 O(n)。
💭 思考:为什么直径要「递归返回单侧深度 + 全局变量更新答案」?——先看暴力版病在哪:对每个节点都调一次
depth求子树高度,同一个子树的高度被反复算,O(n²)。关键认知是「直径 = 左深度 + 右深度」,而深度本身就是递归能带回来的东西。于是让递归函数只返回「单侧最大深度」供父节点用,同时顺手用leftDepth + rightDepth更新全局答案——一次遍历就把深度复用起来。看到「路径/直径/最大路径和」这类「可以跨过某节点拼接」的题,就该想到这个「返回单侧 + 全局更新」的后序模板。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 朴素双重计算 | O(n²) | O(h) | 仅作思路铺垫,不可用于面试 |
| 后序一次遍历 | O(n) | O(h) | 面试标准解,复用深度返回值 |
为什么选后序一次遍历:本题 n 可达 10⁴,O(n²) 会超时;而”后序返回单侧 + 全局更新答案”是处理所有”路径/直径”类问题的通用模板,一次遍历即可完成。
CodeTop 变体
- 字节/腾讯高频:124. 二叉树最大路径和——完全同一模板,把”深度”换成”路径和”,返回值多一步”负值取 0”。
- 阿里追问:687. 最长同值路径(左右子节点值相等才计入)。
- 同套路:1522. N 叉树的直径、1373. BST 子树最大键值和(后序聚合)。
#102 二叉树的层序遍历
题意
给定二叉树根节点,返回层序遍历结果(从上到下、每层从左到右,逐层分组)。
输入:root = [3,9,20,null,null,15,7]
输出:[[3],[9,20],[15,7]]
题目本质:BFS + 按层分组——每层开始先记录当前层节点数 len,处理完 len 个节点即进入下一层。现实类比:学校按年级点名——先点完一年级所有人,再点二年级,每个年级的名字收集到一个子列表。
难点与易错点
- 必须按层分组:用
int levelSize = deque.size()在每层开始时快照节点数,不能在循环里反复用deque.size()作边界(队列会边出边进,size 一直在变)。 - 空树返回空列表(不是返回 null)。
- 每层要
new ArrayList<>()单独收集,否则所有节点会串到一个列表里。
解法一:BFS 队列(直观 / 源材料主解)
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) return result;
Deque<TreeNode> deque = new ArrayDeque<>();
deque.offer(root);
while (!deque.isEmpty()) {
int levelSize = deque.size(); // 当前层节点数
List<Integer> levelList = new ArrayList<>();
// 处理当前层的所有节点
while (levelSize-- > 0) {
TreeNode node = deque.poll();
levelList.add(node.val);
if (node.left != null) deque.offer(node.left);
if (node.right != null) deque.offer(node.right);
}
result.add(levelList);
}
return result;
}
}
- 时间复杂度:每个节点出入队各一次,共 2n 次 → O(n)。
- 空间复杂度:队列最宽时存树宽 w 个节点 → O(w),最坏 O(n)。
- 瓶颈:BFS 空间取决于树宽,满二叉树最坏 O(n);且无法体现”递归的层次编号”思想。
解法二:DFS 带层号(优化)
递归时携带层号 level,首次到达某层就 new 一个列表,按层号把节点追加进去。
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
dfs(root, 0, result);
return result;
}
private void dfs(TreeNode node, int level, List<List<Integer>> result) {
if (node == null) return;
// 首次到达该层:新建一层的列表
if (result.size() == level) result.add(new ArrayList<>());
result.get(level).add(node.val);
dfs(node.left, level + 1, result);
dfs(node.right, level + 1, result);
}
}
复杂度逐步推导:DFS 每个节点访问一次,时间 = O(n)。空间 = 递归栈深度 = 树高 h → O(h),最坏退化链表 O(n);在”深而窄”的树上,其空间比 BFS 的 O(w) 更省。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| BFS 队列 | O(n) | O(w) | 面试首选,直观且能直接复用到右视图/锯齿形 |
| DFS 带层号 | O(n) | O(h) | 树深而窄时更省空间 |
为什么选 BFS:层序遍历是 BFS 的教科书应用,levelSize 快照技巧是”按层处理”问题的通用钥匙,面试讲 BFS 最贴合题意。
CodeTop 变体
- 字节/阿里高频:103. 二叉树的锯齿形层序遍历——每层加一个方向标志,奇数层逆序输出(或按层从双端队列两头出入)。
- 腾讯追问:107. 二叉树的层序遍历 II(自底向上,最后
Collections.reverse即可)。 - 同套路:199. 右视图、429. N 叉树的层序遍历、515. 每层最大值。
#108 将有序数组转换为二叉搜索树
题意
给定一个升序整数数组 nums,将它转换为一棵高度平衡的二叉搜索树(左右子树高度差不超过 1)。
输入:nums = [-10,-3,0,5,9]
输出:[0,-3,9,-10,null,5] (一种合法答案即可)
题目本质:递归二分建树——每次取中间元素作根,左半段递归建左子树,右半段建右子树。现实类比:二分查找排书架——选书架正中间那本书作”根书”,左边书递归组成左子书架,右边书组成右子书架。
难点与易错点
- 必须取中点作根才能保证高度平衡;取端点会退化成链表(不平衡,不满足题意)。
- 中点计算要防溢出:用
l + ((r - l) >> 1),不要写(l + r) / 2(l、r 很大时相加溢出)。 - 区间边界约定清楚:本解用左闭右开
[l, r),退出条件l >= r;若用左闭右闭[l, r],退出条件就是l > r,别混。
解法一:数组拷贝(暴力 / 直观)
每次用 Arrays.copyOfRange 拷贝左右子数组,代码直观但反复拷贝。
class Solution {
public TreeNode sortedArrayToBST(int[] nums) {
if (nums.length == 0) return null;
int mid = nums.length / 2; // 取中点作根
TreeNode root = new TreeNode(nums[mid]);
// 每次拷贝左右子数组:单层拷贝 O(n),共 log n 层 → O(n log n)
root.left = sortedArrayToBST(Arrays.copyOfRange(nums, 0, mid));
root.right = sortedArrayToBST(Arrays.copyOfRange(nums, mid + 1, nums.length));
return root;
}
}
- 时间复杂度:每层递归总计拷贝 n 个元素,树高 log n 层 → O(n log n)。
- 空间复杂度:每层都新建子数组,累计分配 O(n log n)。
- 瓶颈:反复数组拷贝,既费时间又费空间,纯属”图省事”的写法。
解法二:索引递归(最优 / 源材料主解)
不拷贝数组,只传左右边界索引,直接复用原数组。
class Solution {
public TreeNode sortedArrayToBST(int[] nums) {
return buildBST(nums, 0, nums.length);
}
// 左闭右开区间 [l, r)
private TreeNode buildBST(int[] nums, int l, int r) {
if (l >= r) return null; // 区间为空
// 取中点作根,位运算防整数溢出
int mid = l + ((r - l) >> 1);
TreeNode root = new TreeNode(nums[mid]);
root.left = buildBST(nums, l, mid); // 左半段 [l, mid)
root.right = buildBST(nums, mid + 1, r); // 右半段 [mid+1, r)
return root;
}
}
复杂度逐步推导:每个数组元素恰好被访问一次、恰好建一个节点,n 个元素建 n 个节点,每个节点 O(1) 建树,时间 = n · O(1) = O(n)。空间上只使用递归调用栈,栈深度 = 平衡树高 = O(log n)(因为每次严格二分,树是平衡的,不会退化)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 数组拷贝 | O(n log n) | O(n log n) | 仅直观理解,不可用于面试 |
| 索引递归 | O(n) | O(log n) | 面试标准解,无额外数组 |
为什么选索引递归:题面 n 可达 10⁴,拷贝法的时间和空间都劣化,而索引法既不拷贝又保证平衡,是唯一正解。
CodeTop 变体
- 字节高频追问:109. 有序链表转二叉搜索树——链表不能 O(1) 随机访问中点,要用快慢指针找中点(或”中序填值 + 计算长度”法)。
- 阿里变体:1382. 将二叉搜索树变平衡(先中序得有序数组,再复用本题思路重建)。
- 面试追问:为什么结果不唯一?——中点是偶数时取左中/右中都可,均满足高度平衡。
#98 验证二叉搜索树
题意
给定二叉树根节点,判断它是否是有效的 BST——每个节点左子树所有值严格小于它、右子树所有值严格大于它。
输入:root = [2,1,3]
输出:true
题目本质:上下界递归约束——每个节点都有一个合法值域 (lower, upper),递归时向下收紧边界。现实类比:审查档案——每个文件夹内容必须落在父级设定的范围内,进左子文件夹收紧上界,进右子文件夹收紧下界。
难点与易错点
- 最大坑:不能只比较”当前节点 vs 直接子节点”。经典反例
[5,1,4,null,null,3,6]:3 < 5 但 3 出现在右子树,仍是非法 BST。必须把”整棵子树的上/下界”传下去。 - 上下界用 long:如果节点值恰为
Integer.MIN_VALUE或Integer.MAX_VALUE,用 int 边界会与”严格小于/大于”冲突,用Long.MIN/MAX_VALUE兜底。 - 必须严格:
<= lower || >= upper都要判 false(等于也算非法)。
解法一:中序收集数组(暴力 / 直观)
BST 的中序遍历必然严格升序,收集后逐个检查。
class Solution {
public boolean isValidBST(TreeNode root) {
List<Integer> values = new ArrayList<>();
inorder(root, values);
for (int i = 1; i < values.size(); i++) {
if (values.get(i) <= values.get(i - 1)) return false; // 必须严格升序
}
return true;
}
private void inorder(TreeNode node, List<Integer> values) {
if (node == null) return;
inorder(node.left, values);
values.add(node.val);
inorder(node.right, values);
}
}
- 时间复杂度:中序 O(n) + 检查 O(n) → O(n)。
- 空间复杂度:额外数组 O(n) + 递归栈 O(h)。
- 瓶颈:要额外存下整棵树的中序序列,空间 O(n),且遍历结束后才检查(不能提前失败)。
解法二:递归上下界(最优 / 源材料主解)
class Solution {
public boolean isValidBST(TreeNode root) {
// 初始上下界为 long 极值,防止 int 边界节点的误判
return validate(root, Long.MIN_VALUE, Long.MAX_VALUE);
}
private boolean validate(TreeNode node, long lower, long upper) {
if (node == null) return true;
// 当前节点值必须严格落在 (lower, upper) 内
if (node.val <= lower || node.val >= upper) return false;
// 左子树上界收紧为当前值;右子树下界收紧为当前值
return validate(node.left, lower, node.val)
&& validate(node.right, node.val, upper);
}
}
复杂度逐步推导:每个节点恰好访问一次,每个节点做 O(1) 次比较与两次递归调用,总时间 = n · O(1) = O(n)。空间只依赖递归栈,深度 = 树高 h → O(h),最坏退化链表 O(n)(对比中序数组法的 O(n) 额外数组,这里省掉了整棵树的中序存储)。
💭 思考:为什么验证 BST 必须「传上下界」,而不能只比较当前节点和左右孩子?——看反例
[5,1,4,null,null,3,6]:3 在 5 的右子树里却小于 5,只比直接子节点(3 < 4 是合法的)就漏掉了。BST 的约束是「整棵子树都落在界内」,不是「局部父子关系」。于是把每个节点的合法值域(lower, upper)作为参数向下收紧——进左子树上界收紧为当前值,进右子树下界收紧为当前值。看到「判断整棵树满足某个全局性质」时,就该想到「把约束作为参数一路传下去」,而不是只在局部做比较。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 中序收集数组 | O(n) | O(n) | 思路直观,但额外 O(n) 空间 |
| 递归上下界 | O(n) | O(h) | 面试标准解,空间更优且可提前失败 |
为什么选递归上下界:它精准命中 BST 定义(子树整体受界约束),空间 O(h) 更优,而且能处理 int 边界值,是面试正解。
CodeTop 变体
- 字节高频追问:“不用额外数组,怎么判 BST?“——中序遍历时记录
prev前驱节点,边遍历边比较,空间仍 O(h)。 - 腾讯变体:530. 二叉搜索树的最小绝对差、783. BST 节点最小距离——都是”中序相邻差”套路。
- 同套路:230. 第 K 小、700. 二叉搜索树中的搜索。
#230 二叉搜索树中第 K 小的元素
题意
给定 BST 根节点和整数 k(从 1 计数),返回树中第 k 小的元素。
输入:root = [3,1,4,null,2], k = 1
输出:1
题目本质:BST 中序遍历即升序序列——第 k 次访问的节点就是答案,找到后立即停止。现实类比:翻字典找第 k 个词——按字母序(中序)翻到第 k 个词即停,不必翻完。
难点与易错点
- 核心认知:BST 的中序遍历 = 严格升序,这是本题成立的根基。
- 找到后要提前终止:
--k == 0就 return,别傻乎乎遍历完整棵树。 - 计数逻辑:
--k == 0(先减再判)与k-- == 0语义不同;找到后还要用if (k > 0)决定是否继续递归右子树,避免多余的遍历。 - k 从 1 开始,别按 0 索引去减。
解法一:中序收集全部(暴力 / 直观)
class Solution {
public int kthSmallest(TreeNode root, int k) {
List<Integer> values = new ArrayList<>();
inorder(root, values);
return values.get(k - 1); // 中序升序,第 k 小下标 k-1
}
private void inorder(TreeNode node, List<Integer> values) {
if (node == null) return;
inorder(node.left, values);
values.add(node.val);
inorder(node.right, values);
}
}
- 时间复杂度:遍历完整棵树 → O(n)。
- 空间复杂度:中序数组 O(n) + 递归栈 O(h)。
- 瓶颈:无论 k 多小都要遍历完所有节点,且额外 O(n) 数组。
解法二:中序提前终止(最优 / 源材料主解)
class Solution {
private int result;
private int k;
public int kthSmallest(TreeNode root, int k) {
this.k = k;
dfs(root);
return result;
}
private void dfs(TreeNode node) {
if (node == null) return;
dfs(node.left); // 先遍历左子树(值更小)
// 每访问一个节点计数减 1,减到 0 即第 k 小
if (--k == 0) {
result = node.val;
return; // 找到后不再继续
}
if (k > 0) { // 还未找到才递归右子树
dfs(node.right);
}
}
}
复杂度逐步推导:中序遍历只需走到第 k 个节点即停止,访问的节点数 = 左子树沿路深度 + k,因此时间 = O(h + k);最坏情况 k = n 时退化为 O(n)(仍需访问整棵树)。空间 = 递归栈深度 = O(h),最坏 O(n)。对比解法一,节省了”第 k 个之后的 n−k 个节点”的遍历和 O(n) 数组。
进阶:若题目升级为”多次查询第 k 小”,提前在每个节点记录子树节点数(平衡 BST/AVL),每次查询可做到 O(log n);173. 二叉搜索树迭代器也是同族技巧。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 中序收集 | O(n) | O(n) | 直观,但冗余遍历 + 额外数组 |
| 提前终止 | O(h+k) | O(h) | 面试标准解,找到即停 |
为什么选提前终止:题目只求第 k 小,无需遍历整棵树;提前终止在 k 较小时显著优于 O(n)。
CodeTop 变体
- 阿里高频追问:求第 k 大——镜像中序(右-根-左)提前终止即可。
- 字节变体:173. 二叉搜索树迭代器(next()/hasNext(),用栈模拟中序)。
- 同套路:703. 数据流中的第 K 大元素(用大小为 k 的小顶堆,注意这与 BST 无关但同属”第 k 大”问题家族)。
#199 二叉树的右视图
题意
给定二叉树根节点,返回从右侧看到的节点值(从上到下)。
输入:root = [1,2,3,null,5,null,4]
输出:[1,3,4]
题目本质:BFS 层序遍历取每层最后一个节点。现实类比:站在楼梯右侧拍照——每层只拍最右边那根柱子,从上到下排列。
难点与易错点
- 关键映射:右视图可见节点 = 每层最右侧节点 = 层序遍历每层最后一个。
- 别漏”左子树可见”的情形:如
[1,2,null,5],第 3 层只有左子树的 5,但 5 依然可见——所以不能”只看右子树”,要按层取最后一个。 - 空树返回空列表。
解法一:BFS 层序取最后(直观 / 源材料主解)
class Solution {
public List<Integer> rightSideView(TreeNode root) {
List<Integer> result = new ArrayList<>();
if (root == null) return result;
Deque<TreeNode> deque = new ArrayDeque<>();
deque.offer(root);
while (!deque.isEmpty()) {
int levelSize = deque.size();
for (int i = 0; i < levelSize; i++) {
TreeNode node = deque.poll();
if (node.left != null) deque.offer(node.left);
if (node.right != null) deque.offer(node.right);
// 每层最后一个节点即右视图可见节点
if (i == levelSize - 1) {
result.add(node.val);
}
}
}
return result;
}
}
- 时间复杂度:每个节点出入队各一次 → O(n)。
- 空间复杂度:队列最宽 = 树宽 w → O(w),最坏 O(n)。
- 瓶颈:空间取决于树宽,满二叉树最坏 O(n)。
解法二:DFS 根→右→左(优化)
先右后左递归,每层第一次访问到的节点就是最右节点。
class Solution {
private List<Integer> result = new ArrayList<>();
public List<Integer> rightSideView(TreeNode root) {
dfs(root, 0);
return result;
}
private void dfs(TreeNode node, int depth) {
if (node == null) return;
// 每层第一次访问(根→右→左保证最右先到)即最右节点
if (result.size() == depth) result.add(node.val);
dfs(node.right, depth + 1); // 先右
dfs(node.left, depth + 1); // 后左
}
}
复杂度逐步推导:DFS 每个节点访问一次,时间 = O(n)。空间 = 递归栈深度 = 树高 h → O(h),最坏退化链表 O(n);在”深而窄”的树上比 BFS 的 O(w) 更省。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| BFS 层序 | O(n) | O(w) | 面试首选,直接复用 102 框架 |
| DFS 根右左 | O(n) | O(h) | 深窄树更省空间 |
为什么选 BFS:右视图是层序遍历的直接应用,i == levelSize - 1 一行就能取每层最后一个,与 102 题同框架,最好讲。
CodeTop 变体
- 字节追问:左视图——对称地取每层第一个(BFS 中
i == 0,或 DFS 根→左→右)。 - 腾讯变体:103. 锯齿形层序、102. 层序——“按层”问题统一模板。
- 同套路:515. 每层最大值、637. 每层平均值。
#114 二叉树展开为链表
题意
给定二叉树根节点,将它原地展开为单链表:right 指针指向下一节点、left 始终为 null,展开顺序与前序遍历相同。
输入:root = [1,2,5,3,4,null,6]
输出:[1,null,2,null,3,null,4,null,5,null,6]
题目本质:后序递归——先把左右子树分别展成链表,再把左链表插到 root.right,原右子树拼到左链表末尾,root.left = null。现实类比:书目展平——先把左目录、右目录各自展成直线,再把左目录插到当前条目后,左目录末尾接右目录。
难点与易错点
- 展开顺序是前序,但实现常用后序:先展平左、右子树,再拼接到当前节点。两种顺序容易绕晕。
- 必须先保存原右子树:
root.right会被左链表覆盖,若先覆盖再取右子树就丢了。 - 找左链表末尾:用 while 一路
p = p.right找尾,再拼原右链表。 - 最坏时间陷阱:朴素的 while 找尾写法在退化链式结构上会退化到 O(n²)(见下)。
解法一:先存前序再重建(暴力 / 直观)
class Solution {
public void flatten(TreeNode root) {
if (root == null) return;
List<TreeNode> nodes = new ArrayList<>();
preorder(root, nodes);
for (int i = 1; i < nodes.size(); i++) {
TreeNode prev = nodes.get(i - 1);
TreeNode cur = nodes.get(i);
prev.left = null;
prev.right = cur;
}
// 最后一个节点左右都置空
TreeNode last = nodes.get(nodes.size() - 1);
last.left = null;
last.right = null;
}
private void preorder(TreeNode node, List<TreeNode> nodes) {
if (node == null) return;
nodes.add(node);
preorder(node.left, nodes);
preorder(node.right, nodes);
}
}
- 时间复杂度:前序 O(n) + 重链 O(n) → O(n)。
- 空间复杂度:额外 List 存 n 个节点 → O(n)。
- 瓶颈:额外 O(n) 空间,不是严格意义上的”原地”。
解法二:后序原地(优化;源材料写法)
class Solution {
public void flatten(TreeNode root) {
if (root == null) return;
// 后序:先展开右子树,再展开左子树
flatten(root.right);
flatten(root.left);
// 保存展开后的左右链表头
TreeNode leftList = root.left;
TreeNode rightList = root.right;
// 将左链表移到 root.right,清空左指针
root.left = null;
root.right = leftList;
// 找到左链表末尾节点
TreeNode p = root;
while (p.right != null) {
p = p.right;
}
// 末尾节点连接原右链表
p.right = rightList;
}
}
- 空间复杂度:递归栈 O(h),原地无额外数组。
- 时间复杂度坑:while 找尾在退化结构(如纯左链)下会反复扫描右链,最坏 O(n²)。
解法二·升级:返回尾节点,时间稳定 O(n)(真正最优)
把”找尾”从 while 扫描改为递归直接返回尾节点,一次定位 O(1)。
class Solution {
public void flatten(TreeNode root) {
flattenHelper(root);
}
// 展开以 node 为根的子树,返回展开后链表的尾节点
private TreeNode flattenHelper(TreeNode node) {
if (node == null) return null;
TreeNode leftTail = flattenHelper(node.left); // 左子树展开后的尾
TreeNode rightTail = flattenHelper(node.right); // 右子树展开后的尾
TreeNode rightHead = node.right; // 先保存右子树头
if (node.left != null) {
node.right = node.left; // 左链表接到右侧
node.left = null;
leftTail.right = rightHead; // 左尾 O(1) 接右头
}
// 整棵子树链表的尾:优先右尾,其次左尾,否则自身
if (rightTail != null) return rightTail;
if (leftTail != null) return leftTail;
return node;
}
}
复杂度逐步推导:每个节点恰好访问一次,且”找尾”通过返回值 O(1) 完成,不再有 while 重复扫描,因此总时间 = n · O(1) = O(n)。空间 = 递归栈深度 = 树高 h → O(h),最坏退化链表 O(n)。若再追求极致,可用”迭代找前驱”(Morris 式)把空间压到 O(1),面试提一嘴即可。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 先存前序再重建 | O(n) | O(n) | 直观,但非原地 |
| 后序 while 找尾 | 最坏 O(n²) | O(h) | 常见写法,需警惕退化结构 |
| 后序返回尾节点 | O(n) | O(h) | 面试最优解,时间稳定 |
为什么选返回尾节点版:既要”原地”(O(h) 空间),又要时间稳定 O(n),它同时满足;while 找尾版在退化树上有 O(n²) 隐患,是面试官爱挖的坑。
CodeTop 变体
- 字节高频:原题要求”原地 + O(1) 额外空间”——用迭代找左子树最右前驱(Morris 式)实现。
- 腾讯追问:430. 扁平化多级双向链表(同样的”拼接 + 找尾”思路,换成双向指针)。
- 同套路:109. 有序链表转 BST(递归分治与指针拼接的交叉训练)。
#105 从前序与中序遍历序列构造二叉树
题意
给定前序遍历 preorder 和中序遍历 inorder(无重复元素),构造并返回二叉树。
输入:preorder = [3,9,20,15,7], inorder = [9,3,15,20,7]
输出:[3,9,20,null,null,15,7]
题目本质:递归分治——前序第一个元素是根,在中序中找到根的位置,即可划分左右子树长度,递归构建。现实类比:拼图还原——目录(前序)告诉你第一本书是什么,内容页(中序)告诉你这本书把书架分成左右两堆,左右两堆继续同样操作。
难点与易错点
- 前序首元素是根,中序中根的位置划分左右子树——这是唯一依据,记死这两条。
- 四个边界索引极易写错:左子树前序
[preLeft+1, preLeft+leftSize]、右子树前序[preLeft+leftSize+1, preRight],leftSize = inRoot - inLeft是核心中间量。 - 退出条件
preLeft > preRight(区间为空),对应无节点。 - 中序里找根的位置:暴力 O(n) 会拖累整体复杂度,要用 HashMap 预处理。
解法一:线性查找根位置(暴力)
每次在中序里线性扫描根的位置,划分左右子树。
class Solution {
public TreeNode buildTree(int[] preorder, int[] inorder) {
return build(preorder, inorder, 0, preorder.length - 1, 0, inorder.length - 1);
}
private TreeNode build(int[] pre, int[] in, int preLeft, int preRight, int inLeft, int inRight) {
if (preLeft > preRight) return null;
int rootVal = pre[preLeft];
// 线性扫描中序找根位置:O(n),每个节点都扫一次 → O(n^2)
int inRoot = inLeft;
while (in[inRoot] != rootVal) inRoot++;
int leftSize = inRoot - inLeft;
TreeNode root = new TreeNode(rootVal);
root.left = build(pre, in, preLeft + 1, preLeft + leftSize, inLeft, inRoot - 1);
root.right = build(pre, in, preLeft + leftSize + 1, preRight, inRoot + 1, inRight);
return root;
}
}
- 时间复杂度:每个节点都要线性扫描一次中序定位,n 个节点 × O(n) → O(n²)。
- 空间复杂度:递归栈 O(h)。
- 瓶颈:每次找根位置重复线性扫描中序,没有复用”值→下标”的映射。
解法二:HashMap 预处理(最优 / 源材料主解)
class Solution {
private Map<Integer, Integer> inOrderIndex; // 中序元素 → 下标映射
public TreeNode buildTree(int[] preorder, int[] inorder) {
int n = preorder.length;
inOrderIndex = new HashMap<>();
for (int i = 0; i < n; i++) {
inOrderIndex.put(inorder[i], i); // 预处理,O(1) 查根位置
}
return build(preorder, 0, n - 1, 0, n - 1);
}
private TreeNode build(int[] preorder,
int preLeft, int preRight,
int inLeft, int inRight) {
if (preLeft > preRight) return null;
int rootVal = preorder[preLeft]; // 前序第一个是根
int inRootIdx = inOrderIndex.get(rootVal); // O(1) 查根在中序的位置
int leftSize = inRootIdx - inLeft; // 左子树节点数
TreeNode root = new TreeNode(rootVal);
// 左子树:前序 [preLeft+1, preLeft+leftSize],中序 [inLeft, inRootIdx-1]
root.left = build(preorder, preLeft + 1, preLeft + leftSize, inLeft, inRootIdx - 1);
// 右子树:前序 [preLeft+leftSize+1, preRight],中序 [inRootIdx+1, inRight]
root.right = build(preorder, preLeft + leftSize + 1, preRight, inRootIdx + 1, inRight);
return root;
}
}
复杂度逐步推导:预处理建 HashMap 遍历 n 个元素 O(n);递归阶段每个节点恰好被创建一次,且查根位置从 O(n) 降到 O(1),总时间 = O(n) 建表 + n · O(1) 建树 = O(n)。空间 = HashMap 存 n 个映射 O(n) + 递归栈 O(h)(最坏 O(n))→ O(n)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 线性查找根 | O(n²) | O(h) | 仅作思路铺垫 |
| HashMap 预处理 | O(n) | O(n) | 面试标准解 |
为什么选 HashMap:n 可达 10⁴,O(n²) 会超时;用空间换时间,把定位根的开销从 O(n) 降到 O(1),整体降为 O(n)。
CodeTop 变体
- 字节高频:106. 从中序与后序构造二叉树——对称地,后序最后一个元素是根,其余划分逻辑完全一致。
- 阿里追问:889. 从前序与后序构造二叉树(结果可能不唯一,需说明歧义来源)。
- 同套路:297. 二叉树的序列化与反序列化(用前序 + null 占位符,无需中序)。
#437 路径总和 III
题意
给定二叉树根节点和整数 targetSum,求路径和等于 targetSum 的路径数目。路径不要求从根开始、也不要求在叶子结束,但方向必须向下(从祖先到后代)。
输入:root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8
输出:3
题目本质:前缀和 + DFS 回溯(同 560 题思路)——用 map 记录根到当前节点路径上每个前缀和的出现次数,查 currentSum - targetSum 的个数即得路径数,回溯时撤销。现实类比:营业额统计——从总店(根)到分店(当前节点)的累计营业额是前缀和,找某段路径正好符合目标营业额,提前建好”账本”(map)快速查询。
难点与易错点
- 路径方向必须向下:不能中途拐弯,所以只能用”前缀和相减”统计”某祖先到当前节点”这一段。
- 前缀和要回溯撤销:DFS 离开当前分支时要
merge(currentSum, -1)撤销,否则同一路径的前缀和会污染兄弟分支。 - 初始化
(0, 1):处理”从根开始的整段路径”恰好等于 target 的情形,漏了会少算。 - 用 long 存前缀和:节点值可为负且累计可能溢出 int。
解法一:双重递归(暴力 / 直观)
对每个节点都作为起点,向下统计路径和,再递归左右子树。
class Solution {
public int pathSum(TreeNode root, int targetSum) {
if (root == null) return 0;
// 以 root 为起点的路径数 + 左右子树各自内部的路径数
return pathFrom(root, targetSum)
+ pathSum(root.left, targetSum)
+ pathSum(root.right, targetSum);
}
// 以 node 为起点向下延伸、和等于 sum 的路径条数
private int pathFrom(TreeNode node, long sum) {
if (node == null) return 0;
int count = 0;
if (node.val == sum) count++;
count += pathFrom(node.left, sum - node.val);
count += pathFrom(node.right, sum - node.val);
return count;
}
}
- 时间复杂度:每个节点都要以它为起点做一次 O(n) 的向下统计,n 个节点 → O(n²)。
- 空间复杂度:递归栈 O(h)。
- 瓶颈:每个起点都重复向下扫描,路径信息没有复用。
解法二:前缀和 + 回溯(最优 / 源材料主解)
class Solution {
private int answer = 0;
public int pathSum(TreeNode root, int targetSum) {
Map<Long, Integer> prefixCount = new HashMap<>();
// 前缀和为 0 出现 1 次,处理从根开始的路径
prefixCount.put(0L, 1);
dfs(root, 0L, targetSum, prefixCount);
return answer;
}
private void dfs(TreeNode node, long currentSum, int targetSum,
Map<Long, Integer> prefixCount) {
if (node == null) return;
currentSum += node.val;
// 存在前缀 currentSum - targetSum,则它到当前节点的路径和 = targetSum
answer += prefixCount.getOrDefault(currentSum - targetSum, 0);
// 将当前前缀和加入计数(merge 简化 Java 8 写法)
prefixCount.merge(currentSum, 1, Integer::sum);
dfs(node.left, currentSum, targetSum, prefixCount);
dfs(node.right, currentSum, targetSum, prefixCount);
// 回溯:离开当前节点时撤销前缀和记录,避免影响其他分支
prefixCount.merge(currentSum, -1, Integer::sum);
}
}
复杂度逐步推导:每个节点恰好访问一次,每次做 O(1) 的 HashMap 查询和两次 merge,总时间 = n · O(1) = O(n)(对比双重递归的 O(n²),省掉了”每个起点重复扫描”的 n 倍开销)。空间 = 前缀和 map(最坏存 n 个不同前缀和)O(n) + 递归栈 O(h) → O(n)。
💭 思考:为什么「任意祖先到当前节点」的区间和能用前缀和相减 O(1) 求?——路径只能向下走,所以任何合法路径都是一段「从某个祖先到当前节点」的连续链,路径和 =
currentSum - 祖先处的前缀和。要找和等于targetSum的路径,就等价于「当前前缀和currentSum时,前面是否出现过currentSum - targetSum」。于是用 map 记录路径上每个前缀和的次数,走到哪查到哪,回溯时merge(currentSum, -1)撤销。这个信号就是「路径必须向下 + 求区间和」——看到这两个条件组合,就该从「每个节点都当起点扫一遍」(双重递归 O(n²))升级成「前缀和 + map」的一次遍历。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 双重递归 | O(n²) | O(h) | 直观但会超时 |
| 前缀和 + 回溯 | O(n) | O(n) | 面试标准解,一次遍历 |
为什么选前缀和:n 可达 10³~10⁴ 且节点值可正可负(不能靠剪枝),必须用前缀和把”任意祖先到当前节点”的区间和查询降到 O(1)。
CodeTop 变体
- 字节高频:560. 和为 K 的子数组——同一”前缀和 + map”套路在数组上的原型,面试常先问这题再引申到树上。
- 腾讯追问:113. 路径总和 II(要求输出具体路径,DFS 回溯记录 path)、112. 路径总和(是否存在)。
- 同套路:666. 路径和 IV(非标准树的路径和)。
#236 二叉树的最近公共祖先
题意
给定二叉树和两个节点 p、q,返回它们的最近公共祖先(LCA)——深度最大且同时是二者祖先的节点(节点可以是自己的祖先)。
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出:3
题目本质:后序递归分情况——左右子树都找到则当前节点是 LCA;只有一侧找到就返回那一侧;都没找到返回 null。现实类比:家谱查共同祖先——分别往左右两支找目标人,两支都找到则当前节点就是最近公共祖先,只有一支找到就是那支的结果。
难点与易错点
- 递归出口是
node == null || node == p || node == q:找到即停,向上回传。 - 三种情况的返回值:左右都非空 → 当前节点;左非空右空 → 返回左;左空右非空 → 返回右;都空 → 返回 null。别把返回逻辑写反。
- 这是普通二叉树,不是 BST:不能用”p.val < node.val < q.val 则向左/右”的比较法(那是 235 二叉搜索树的 LCA)。
解法一:记录父节点(暴力 / 直观)
先遍历建父指针表,收集 p 的祖先集合,再让 q 向上跳找第一个公共祖先。
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
Map<TreeNode, TreeNode> parent = new HashMap<>();
Deque<TreeNode> stack = new ArrayDeque<>();
parent.put(root, null);
stack.push(root);
// 遍历直到 p 和 q 的父指针都记录完毕
while (!parent.containsKey(p) || !parent.containsKey(q)) {
TreeNode node = stack.pop();
if (node.left != null) { parent.put(node.left, node); stack.push(node.left); }
if (node.right != null) { parent.put(node.right, node); stack.push(node.right); }
}
// 收集 p 的所有祖先
Set<TreeNode> ancestors = new HashSet<>();
while (p != null) { ancestors.add(p); p = parent.get(p); }
// q 向上跳,找到第一个出现在 p 祖先集合中的节点
while (!ancestors.contains(q)) { q = parent.get(q); }
return q;
}
}
- 时间复杂度:遍历建表 O(n) + p 祖先链 O(h) + q 上跳 O(h) → O(n)。
- 空间复杂度:父指针 map O(n) + 祖先集合 O(h) + 栈 O(h) → O(n)。
- 瓶颈:需要额外 O(n) 的父指针表和 O(n) 的祖先集合,空间开销大。
解法二:后序递归(最优 / 源材料主解)
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
return dfs(root, p, q);
}
private TreeNode dfs(TreeNode node, TreeNode p, TreeNode q) {
// 递归出口:遇到 null、p 或 q 就返回(找到即停)
if (node == null || node == p || node == q) return node;
TreeNode leftResult = dfs(node.left, p, q);
TreeNode rightResult = dfs(node.right, p, q);
// 左右都找到:当前节点是 LCA
if (leftResult != null && rightResult != null) return node;
// 只有左边找到:LCA 在左子树中
if (leftResult != null) return leftResult;
// 只有右边找到(或都没找到返回 null)
return rightResult;
}
}
复杂度逐步推导:后序遍历每个节点访问一次,每节点 O(1) 判断,时间 = n · O(1) = O(n)。空间 = 递归栈深度 = 树高 h → O(h),最坏退化链表 O(n)。对比解法一,省掉了 O(n) 的父指针表和祖先集合,空间从 O(n) 降到 O(h)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 记录父节点 | O(n) | O(n) | 思路直观,但空间开销大 |
| 后序递归 | O(n) | O(h) | 面试标准解,空间更优 |
为什么选后序递归:LCA 的定义天然是”自底向上汇聚”,后序递归把结果一路回传,既 O(n) 时间又 O(h) 空间,是本题最优雅的正解。
CodeTop 变体
- 字节/腾讯高频追问:235. 二叉搜索树的最近公共祖先——BST 可以用值比较走单向(
p,q都在左则左,都在右则右,否则当前即答案),空间 O(1)。 - 阿里变体:1676. 二叉树的最近公共祖先 IV(多个节点的 LCA)、1644. 二叉树的 LCA II(节点可能不在树中,需额外校验存在性)。
- 同套路:1123. 最深叶节点的最近公共祖先。
#124 二叉树中的最大路径和
题意
路径是节点序列,相邻节点有边相连,同一节点至多出现一次,至少包含一个节点,且不一定经过根节点。返回最大路径和。
输入:root = [1,2,3]
输出:6 (路径 2 → 1 → 3)
题目本质:DFS 后序算”单边最大贡献” + 全局更新——每个节点返回”可向上贡献的最大单侧路径和”(负则返回 0),同时用左贡献 + 右贡献 + 自身更新全局最大路径和。现实类比:最高收益旅行路线——每个城市统计”通过我的最长收益路线 = 左边最优 + 右边最优 + 我自己”,同时告诉上级”我这一侧能给你的最大收益”。
难点与易错点
- 负值处理:左右贡献为负时应”舍弃”(取 0),否则会把负路径加进来拉低答案;但全局答案初值要
Integer.MIN_VALUE,因为路径可能全为负。 - 返回值和答案不一样:返回给父节点的是”单侧最大贡献”(左右只能选一边,不能同时选),而答案记录的是”左 + 右 + 自身”的完整拐点路径。
- 路径不一定过根:必须用全局变量在每个节点更新,不能只算根节点的左右拼接。
解法一:朴素双重计算(暴力)
对每个节点,都重新调用一次 maxDown 求左右单侧最大和来更新答案,深度信息反复计算。
class Solution {
private int ans = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
if (root == null) return 0;
// 以 root 为最高点的最大路径 = 左单侧 + 右单侧 + 自身
int throughRoot = maxDown(root.left) + maxDown(root.right) + root.val;
ans = Math.max(ans, throughRoot);
// 左右子树内部的答案(重复遍历,O(n^2))
maxPathSum(root.left);
maxPathSum(root.right);
return ans;
}
// 以 node 为起点的单侧最大路径和(负则取 0 表示不走)
private int maxDown(TreeNode node) {
if (node == null) return 0;
return Math.max(0, Math.max(maxDown(node.left), maxDown(node.right)) + node.val);
}
}
- 时间复杂度:n 个节点各调一次 O(n) 的
maxDown→ O(n²)。 - 空间复杂度:递归栈 O(h)。
- 瓶颈:每个节点的单侧贡献被重复计算,没有复用。
解法二:后序一次遍历(最优 / 源材料主解)
class Solution {
private int maxPathSum = Integer.MIN_VALUE; // 结果可能为负,初始为最小 int
public int maxPathSum(TreeNode root) {
maxContribution(root);
return maxPathSum;
}
// 返回以 root 为起点、向下延伸的最大路径和(可贡献给父节点的值)
private int maxContribution(TreeNode node) {
if (node == null) return 0;
// 左右子树的最大贡献值(若为负则舍弃,贡献视为 0)
int leftVal = Math.max(maxContribution(node.left), 0);
int rightVal = Math.max(maxContribution(node.right), 0);
// 经过当前节点的完整路径:左贡献 + 节点值 + 右贡献
maxPathSum = Math.max(maxPathSum, leftVal + rightVal + node.val);
// 向父节点返回单侧最大贡献(只能选左或右,不能同时选)
return Math.max(leftVal, rightVal) + node.val;
}
}
复杂度逐步推导:一次后序遍历,每个节点访问一次,每个节点内做常数次 Math.max,总时间 = n · O(1) = O(n)(对比暴力版,把 n 次 O(n) 的 maxDown 合并进同一次遍历)。空间 = 递归栈深度 = 树高 h → O(h),最坏退化链表 O(n)。
💭 思考:这题和 #543 直径是同一套模板,多出来的「负值取 0」为什么必不可少?——路径和允许为负,负的子树贡献会拉低答案,所以返回给父节点的单侧贡献要
Math.max(贡献, 0),负数就「不走这一侧」;但全局答案的初值必须是Integer.MIN_VALUE,因为整条路径可能全为负。想通「返回值(单侧、可舍弃负值)vs 全局答案(完整拐点、可为负)」这层差别,就知道为什么同样的后序模板在这里要多加一句Math.max(..., 0)——这正是从 543 到 124 的题眼,也是面试官最爱追问的点。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 朴素双重计算 | O(n²) | O(h) | 仅作思路铺垫 |
| 后序一次遍历 | O(n) | O(h) | 面试标准解 |
为什么选后序一次遍历:这是”树形 DP / 后序聚合”的巅峰题,与 543 直径同一模板(把”深度”换”路径和”、多加”负值取 0”),一次遍历即可在 O(n) 内求最优。
CodeTop 变体
- 字节超高频:原题手撕,重点考察”负值取 0”和”返回值 vs 全局答案”的区别,是路径和类题目的母题。
- 腾讯追问:543. 二叉树的直径——同模板姊妹题,直径=最大路径”边数”,最大路径和=最大路径”权值和”。
- 同套路:687. 最长同值路径、1373. BST 子树最大键值和(后序聚合)。
总结:15 题的两条主线
- 遍历线(94 中序、102 层序、199 右视图、230 第 K 小、98 验证 BST):掌握”前/中/后序只是 add 位置不同”和”层序靠 levelSize 快照分层”,这 5 题就是模板的排列组合。
- 后序自底向上线(104 深度、226 翻转、101 对称、543 直径、114 展开、236 LCA、124 最大路径和):套路是”递归返回值 = 单侧贡献 + 全局变量更新答案”,543 与 124 是这套模板的一对巅峰姊妹题。
- 分治构造/前缀和线(108 有序数组转 BST、105 前中序构造、437 路径和 III):抓住”中点/根位置划分”和”前缀和回溯”两个关键动作。
手撕时先判断题目属于哪条主线,套对应模板,再针对题目的坑(负值、边界、镜像位置、提前终止)微调,15 题即可无痛拿下。
章末提问
二叉树篇的追问多集中在「为什么选这个解法 / 复杂度怎么推 / 边界怎么抠」这类思考型问题。下面是最常考的 5 个角度(结论先行,因为跟着原因):
-
「二叉树遍历,递归和迭代分别什么时候用?」 回答思路:结论是默认递归(代码最短、最不易错),只有树可能退化到极深会栈溢出、或需要「暂停 / 恢复」遍历(如 173 迭代器)时才上显式栈迭代,因为递归本质是系统调用栈,其深度等于树高,最坏退化链表时是 O(n)。
-
「543 直径和 124 最大路径和,为什么一次后序就能做到 O(n)?」 回答思路:结论是让递归「返回单侧贡献 + 全局变量更新答案」,把每个节点的左右深度 / 贡献合并进同一次遍历,因为暴力版对每个节点都重复求子树深度导致 O(n²),后序复用返回值就省掉了 n 倍的重复计算。
-
「从 O(n²) 优化到 O(n),你在这篇里用了哪几种手段?」 回答思路:结论是三种——复用返回值(543/124)、HashMap 预处理换时间(105 建表 O(1) 查根位置)、前缀和把区间查询降到 O(1)(437),因为它们共同的病灶都是「重复扫描 / 重复计算」,用空间或返回值缓存即可一次遍历完成。
-
「验证 BST 为什么不能只比较当前节点和左右孩子,必须传上下界?」 回答思路:结论是 BST 要求「整棵子树」所有值都落在界内,只看直接子节点会漏掉跨层违规(如 [5,1,4,null,null,3,6] 中 3 在右子树却小于根),因为右子树所有节点都要大于根,必须把界向下逐层收紧传递。
-
「边界细节:为什么 BST 上下界用 long,直径和最大路径和却用全局变量?」 回答思路:结论是节点值可能是 Integer.MIN_VALUE / MAX_VALUE,用 int 边界会与「严格小于 / 大于」冲突所以要 long;而直径和最大路径和可以不经过根、必须每个节点都更新答案,所以用全局变量,因为只算根节点的左右拼接会漏掉子树的更优解。