专题 07二叉树:递归思维的主战场
如果说数组考验细心、链表考验指针,那二叉树就是递归思维的主战场。 树天然是「自相似」的——一棵树的左子树、右子树,本身又是一棵小树。这种结构几乎逼着你用递归。 本章先给你一个看待树题的「二分法」:遍历 vs 分解,再带你用 6 道题把递归写顺。
7.1树题的两种思考方式:遍历与分解
很多人写树题卡住,不是不会递归,是不知道「递归函数该返回什么」。 其实树题基本只有两种写法,先分清楚,后面每题都对号入座:
方式一:遍历(Traverse)——「一边走一边做事」
递归只是为了走到每个节点,真正的工作在「路过」时顺手做。函数返回值常常没用(返回 void 或一个标记), 典型如「打印整棵树」「统计节点个数」「把每个节点值加 1」。你脑子里是一根指针在树上逛,逛到哪算到哪。
方式二:分解(Divide & Conquer)——「把问题拆给左右子树」
这是本章重点,也是面试 80% 树题的写法。核心想法:「我不管左右子树内部怎么长,只要它们各自把结果交给我,我合并一下就出答案」。 递归函数有返回值,返回值就是「以当前节点为根的子树,针对这个问题该给的答案」。
分解式思考模板(伪代码):
int f(节点 root):
if root == null: return 基线值 // 空树的情况
left = f(root.left) // 左子树交出的答案
right = f(root.right) // 右子树交出的答案
return 用 left、right、root 算出的本层答案
本章的最大深度、翻转、对称、LCA 全是分解式。区分窍门:题目问的是「整棵树某个全局量」(最大深度、是否对称), 那就用分解——让每个子树先报自己的局部答案,父节点汇总。
树节点的标准定义
力扣里树节点长这样,后面所有题都基于它:
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;
}
}
用一个例子区分两种方式
同样一棵简单的树,两种写法写出来差别巨大。以「[1,2,3]」为例,比较「前序打印所有值」和「求节点总数」:
1
/ \
2 3
【遍历式:打印每个节点值】
void dfs(TreeNode root) {
if (root == null) return;
System.out.print(root.val); // 路过就干活
dfs(root.left);
dfs(root.right);
}
→ 输出 1 2 3。函数无返回值,信息靠「副作用」(打印)带出。
【分解式:数节点总数】
int count(TreeNode root) {
if (root == null) return 0;
return 1 + count(root.left) + count(root.right); // 左右交答案,父层汇总
}
→ 返回 3。每个子树报自己的节点数,父节点 +1 合并。
看出区别了吗?打印那个函数「脑子里有个游客在走」,到哪打到哪;计数那个函数「自己不动,让左右孩子报数,最后加总」。 凡是结果能「由左右子树的同类结果合并得到」的,优先写分解式——它不需要全局变量,返回值一路把信息带上顶层, 既好写又好测。遍历式适合「副作用型」任务(打印、收集到外部 List、修改原树)。
7.2二叉树的最大深度(简单,力扣 104)
简单 核心:分解式入门
题意:返回树的最大深度(从根到最远叶子节点的节点数)。例:[3,9,20,null,null,15,7] 最大深度 3。
思路推导
用分解式想:一棵树的「最大深度」等于什么?等于「左子树的最大深度」和「右子树的最大深度」里较大的那个,再加 1(自己这一层)。 「左右子树最大深度」怎么来?递归调用自己就行了——同一个函数,丢给它左孩子、右孩子。 空节点(null)深度是 0,这是递归停下来的地方(base case)。
3 depth(3) = max(depth(9), depth(20)) + 1
/ \ depth(9) = 0+1 = 1(左右都空)
9 20 depth(20) = max(depth(15), depth(7)) + 1 = max(1,1)+1 = 2
/ \ depth(15)=1, depth(7)=1
15 7
→ depth(3) = max(1, 2) + 1 = 3 ✓
参考代码
class Solution {
public int maxDepth(TreeNode root) {
if (root == null) return 0; // 空树深度 0(base case)
int left = maxDepth(root.left); // 左子树报深度
int right = maxDepth(root.right); // 右子树报深度
return Math.max(left, right) + 1; // 取较大者 + 自己这层
}
}
复杂度
时间 O(n):每个节点恰好访问一次。空间 O(n):最坏退化为链表时递归栈深 n;平衡树 O(log n)。
为什么是 +1?因为上面的左右子树深度都还没算「当前节点本身」这一层,汇总时得补上。
if (root == null) return 0 必须写,且返回 0 不是 1。
空节点没有层数,是「贡献 0 层」,让父节点的 +1 自然表达「加上自己」。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 二叉树的最小深度(力扣 111,简单)——与本题共用「分解式求深度」技巧;差异在最小深度必须到达叶子节点才算一层,某一侧为空的分支不能算作深度(提示:返回前判断左右孩子是否都为空,只在一侧为空时不要直接取 min,要先过滤掉空支再比)。
- 平衡二叉树(力扣 110,简单)——与本题共用「自底向上返回高度」的分解式技巧;差异在要同时检查左右子树是否平衡且高度差不超过 1(提示:用辅助返回值既带高度又带「是否平衡」标记,遇到不平衡就提前返回 -1)。
7.3翻转二叉树(简单,力扣 226)
简单 核心:先递归后交换
题意:把整棵树的左右子树镜像翻转。例:根 4 的左孩子 2 和右孩子 7 互换,且各自子树也翻。
思路推导
分解式:翻转一棵以 root 为根的树,等于——先分别翻转它的左子树和右子树,再把 root 的 left、right 指针对调。 注意顺序:先「递归翻完下面」,再「在本层交换」。如果先交换再递归,交换后 left/right 已经互换,递归就翻错边了。 (其实先交换再递归也对,只要递归传的是交换后的引用;但「先递归后交换」最直观、最不易错。)
原: 4 翻左子树(2→1,3) 翻右子树(7→6,9) 交换 4 的左右
/ \ → 2:左右换 → 7:左右换 → 4.left=7, 4.right=2
2 7 1 3 9 6
翻转后: 4
/ \
7 2
/ \ / \
9 6 3 1
参考代码
class Solution {
public TreeNode invertTree(TreeNode root) {
if (root == null) return null; // 空树直接返回
TreeNode left = invertTree(root.left); // 先翻左子树
TreeNode right = invertTree(root.right); // 再翻右子树
root.left = right; // 交换:左接翻好的右
root.right = left; // 右接翻好的左
return root; // 返回翻转后的根
}
}
复杂度
时间 O(n):每个节点访问一次。空间 O(n)(递归栈,最坏链表)。
为什么返回 root?因为翻转是「原地改」,改完把根交回去,上层就能接着接。
root.left = root.right。
后者会丢失引用(root.right 被赋给 left 后,原 right 的子树就乱了)。先收好左右翻转结果再赋值才安全。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 对称二叉树(力扣 101,简单)——与本题共用「递归处理左右子树」技巧;差异在对称是比较「左子树的左 vs 右子树的右」的交叉镜像关系,而不是真的把树翻过来(提示:写一个 check(p, q) 双树递归,注意是交叉比较而非平行比较)。
- 翻转等价二叉树(力扣 951,中等)——与本题共用「翻转/交换左右子树」技巧;差异在判断两棵树是否「翻转等价」——每对节点可交换可不交换,只要存在一种翻转方案使两树相同即可(提示:比较 root1 与 root2 时,分别尝试「不交换」与「交换左右」两种情况,任一成立即可)。
7.4对称二叉树(简单,力扣 101)
简单 核心:双树同步递归
题意:判断一棵树是否中心对称(左子树是右子树的镜像)。例:[1,2,2,3,4,4,3] 对称 → true;[1,2,2,null,3,null,3] → false。
思路推导
「对称」不是「左右相等」,是「左子树的左 = 右子树的右,左子树的右 = 右子树的左」——典型的镜像关系。 所以不能只比较 root.left 和 root.right 的值,要同时递归两棵树:一个走「外侧」(左的左 vs 右的右),一个走「内侧」(左的右 vs 右的左)。
写一个辅助函数 check(p, q) 判断「两棵树是否互为镜像」:
- 都为空 → 真;一个空一个不空 → 假;值不等 → 假;
- 否则递归
check(p.left, q.right) && check(p.right, q.left)。
参考代码
class Solution {
public boolean isSymmetric(TreeNode root) {
return root == null || check(root.left, root.right); // 空树算对称
}
// 判断两棵树是否互为镜像
private boolean check(TreeNode p, TreeNode q) {
if (p == null && q == null) return true; // 都空,这一支对称
if (p == null || q == null) return false; // 一个空一个不空,不对称
if (p.val != q.val) return false; // 值不同,不对称
// 外侧配外侧,内侧配内侧
return check(p.left, q.right) && check(p.right, q.left);
}
}
复杂度
时间 O(n):每个节点在比较中被访问(镜像对一一对应)。空间 O(n)(递归栈)。 为什么用「双树递归」而不是单树?因为对称性是两棵树之间的关系,单棵树无法表达「左子树的左」和「右子树的右」的对应,必须两个指针各走一边。
check(p.left, q.right) 和 check(p.right, q.left)——
交叉而非平行。写成 check(p.left, q.left) 就变成「判断两树完全相同」了(那题是 100 题 Same Tree)。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 相同的树(力扣 100,简单)——与本题共用「双树同步递归」技巧;差异在相同的树是「平行比较」(左比左、右比右)而非交叉镜像(提示:把 check 里的交叉调用改成平行调用
check(p.left, q.left) && check(p.right, q.right)即可,这正是本题 tip 里点到的对比)。
7.5二叉树的层序遍历(中等,力扣 102)
中等 核心:BFS + 按层出队
题意:返回「按层」的节点值列表。例:[3,9,20,null,null,15,7] → [[3],[9,20],[15,7]]。
思路推导
层序 = 广度优先(BFS),用队列实现。难点在「怎么把同一层的节点分到一组」。 诀窍是:每一层开始前,先记下当前队列的长度 size——这个 size 就是「本层节点数」。 然后只出队 size 次,把这一层的节点收集起来;出队时把它们的孩子入队,孩子会自然排在「下一层」的队伍后面。 下一轮再取新的 size,如此一层层展开。
队列初始 [3],size=1 → 出3,入9,20 → 本层[3]
队列 [9,20],size=2 → 出9入null忽略;出20入15,7 → 本层[9,20]
队列 [15,7],size=2 → 出15,7,无孩子 → 本层[15,7]
结果 [[3],[9,20],[15,7]]
注意:size 必须在每层循环外先取好。如果写在循环里 while (!q.isEmpty()) 每次都重新数,
新入队的孩子会被算进同一层,分层就乱了。
参考代码
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> res = new ArrayList<>();
if (root == null) return res; // 空树返回空列表
Deque<TreeNode> queue = new ArrayDeque<>();
queue.offer(root); // 根入队
while (!queue.isEmpty()) {
int size = queue.size(); // ① 先取本层节点数
List<Integer> level = new ArrayList<>();
for (int i = 0; i < size; i++) { // ② 只出队 size 次
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) queue.offer(node.left); // 孩子入队,排到下一层
if (node.right != null) queue.offer(node.right);
}
res.add(level); // 本层收集完,加入结果
}
return res;
}
}
复杂度
时间 O(n):每个节点入队出队各一次。空间 O(n):队列最坏存满一层(满二叉树底层约 n/2)。 为什么必须「先 size 再循环」?因为循环内入队的孩子属于下一层,若实时取 size 会把它们算进当前层,分层失败。
为什么用「队列」而不是「栈」
层序要求「先遇到的节点先处理」——这正好是队列的 FIFO 语义。如果用栈(DFS 那套),会一头扎进最深的左子树, 出来的顺序是「根→左到底→回退」,变成深度优先,不是逐层。所以层序 = BFS = 队列,这是结构性绑定,不是个人喜好。 可以对比记忆:
| 遍历方式 | 用的结构 | 出来的顺序 | 典型题 |
|---|---|---|---|
| 前/中/后序 | 系统调用栈(或显式栈) | 一根筋走到叶子再回退 | 最大深度、翻转、LCA |
| 层序 | 队列 | 一层一层从左到右 | 102、之字形 103 |
再手推一遍 [3,9,20,null,null,15,7] 的 size 机制,重点看「size 怎么锁住当前层」:
初始队列 [3],进入 while:size=1(锁定第1层只有3)
循环1次:poll 3,level=[3],入队 9,20 → 队列[9,20]
res 加 [3]
下一轮:size=2(锁定第2层只有9,20,刚入队的15,7不算)
循环2次:poll 9(入15,7→队列[20,15,7]),poll 20(入15? 已入;入7? 已入)→ level=[9,20]
res 加 [9,20]
下一轮:size=2(锁定第3层:队列里正是[15,7])
循环2次:poll 15、poll 7,都无孩子 → level=[15,7]
res 加 [15,7]
队列空,结束 → [[3],[9,20],[15,7]] ✓
关键就在每一轮开头那句 int size = queue.size():它把「当前层有几个节点」快照下来,
循环里新入队的孩子虽然进了同一个队列,但不在本次 size 的计数内,于是自动归到下一轮。这个「快照」是分层的核心。
size 是层序遍历的命门。固定写法:int size = queue.size(); for (i=0; i。
这句话背下来,所有「按层处理」的题(之字形遍历 103、层平均值 637)都靠它。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 二叉树的层次遍历 II(力扣 107,简单)——与本题共用「BFS + size 分层」技巧;差异在结果要从底层往上层输出(提示:先按正常层序收集,最后把结果列表整体反转一次即可)。
- 二叉树的右视图(力扣 199,中等)——与本题共用「按层 BFS」技巧;差异在每层只取「最右边」那个节点(提示:每层出队 size 次时,取最后一个出队的节点加入答案)。
- 二叉树的锯齿形层序遍历(力扣 103,中等)——与本题共用「size 分层 BFS」框架;差异在奇数层要逆序(提示:用层号标记,偶数层正常收集、奇数层头部插入或收集后反转)。
7.6二叉树的最近公共祖先(中等,力扣 236)
中等 核心:后序递归传信息
题意:给定树和两个节点 p、q,返回它们最近的公共祖先(最低的、同时是两者祖先的节点)。
例:树 [3,5,1,6,2,0,8,...],p=5、q=1 → 返回 3;p=5、q=4 → 返回 5(5 是 4 的祖先,自己也是自己的祖先)。
思路推导
这是分解式里「后序」最漂亮的例子。从底向上想:对每个节点,问它的左右子树——
- 「你那边找到 p 或 q 了吗?」
- 如果左子树找到、右子树也找到 → 当前节点就是 LCA(p、q 分列两侧);
- 如果只有一侧找到 → 把找到的那个往上传递(LCA 在更上层);
- 如果都没找到 → 返回 null。
再看 base case:如果当前节点就是 p 或 q 本身,直接返回它(它自己就是答案的候选,不用再往下找)。 这样天然处理了「一个节点是另一个祖先」的情况。
3
/ \
5 1 找 p=5, q=4(4在5的左子树2里)
/ \ / \
6 2 0 8
/ \
7 4
递归到 4:==q 返回 4
递归到 2:左null 右4 → 返回 4
递归到 5:左6返回null,右2返回4 → 一侧有 → 向上传 4?不对,5本身是p!
实际上 5==p 直接返回 5;但 5 的右子树返回了 4(含 q)
→ 5 的左返回 null,右返回 4,且 5 自身是 p → 5 就是 LCA ✓
更严谨地说:函数返回「以 root 为根的子树里,是否包含了 p 或 q 中的一个(且是哪个)」。 当 root 自身命中、或左右各命中一个,root 即 LCA。
为什么「后序」是关键
这题的递归是后序(先左右、后处理当前),和前面几题的「先处理后递归」不同。原因:LCA 的答案 取决于左右子树的返回结果,必须先拿到两边「找到没」,才能在本层判断「我是不是 LCA」。 如果写成前序(先判断当前再递归),你就还没知道下面的情况,无从决策。
换个角度理解「左右各命中一个 → 我是 LCA」:p 和 q 在树里的相对位置只有两种——
- 分列两侧:一个在左子树、一个在右子树,那它们最近的公共祖先只能是「当前这个分叉点」;
- 祖先关系:一个在另一个的子树里。这时递归到「那个祖先」时,它本身命中(
root==p或root==q), 直接把自己返回,上层把「含另一个的那侧结果」和「这个命中」凑一起,祖先被确认为 LCA。
两种情况被同一套逻辑统一了,不需要特判「谁是祖先」——这正是后序递归的优雅之处。
TreeNode 没有 parent 指针,
那种思路在这里行不通。递归后序返回「找到的节点」是唯一干净的路子。也别在递归里用全局变量记答案,
用返回值一路上传更不容易错。
参考代码
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
// 遇到空、或正好碰到 p/q,直接返回(p/q 自身可作为候选祖先)
if (root == null || root == p || root == q) return root;
TreeNode left = lowestCommonAncestor(root.left, p, q); // 左子树结果
TreeNode right = lowestCommonAncestor(root.right, p, q); // 右子树结果
if (left != null && right != null) return root; // 分列两侧 → 当前是 LCA
return left != null ? left : right; // 只在一侧 → 把找到的传上去
}
}
复杂度
时间 O(n):最坏遍历全树一遍。空间 O(n)(递归栈)。 为什么「自身命中就返回」能覆盖祖先情况?因为如果 p 是 q 的祖先,递归到 p 时直接返回 p, 上层发现一侧返回 p、另一侧(含 q 的子树)也最终返回非 null,于是 p 被确认为 LCA——逻辑自洽。
root == p(引用相等),不是 root.val == p.val。
题目保证 p、q 是树中真实存在的节点对象,用引用比对最稳,也避免「值相同但不是同一个节点」的歧义。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 二叉搜索树的最近公共祖先(力扣 235,中等)——与本题共用「后序递归找 p/q 在哪一侧」技巧;差异在利用 BST 的有序性:p、q 值都小就走左、都大就走右,分列两侧时当前节点即答案(提示:无需像 236 那样同时查左右返回值,比较值大小即可决定单边深入)。
- 路径总和(力扣 112,简单)——与本题共用「后序递归把结果往上传」技巧;差异在目标变成「是否存在一条根到叶子的路径,其节点值之和等于 targetSum」(提示:到叶子时判断累加值是否等于目标,递归返回左右两侧是否有任意一条满足)。
7.7本专题常见坑
- 递归没写 base case:树递归第一句几乎永远是
if (root == null) return …。 漏了会无限递归或空指针。返回什么要看题意(深度返回 0、翻转返回 null、LCA 返回 null)。 - 层序遍历 size 在循环里重取:必须
int size = queue.size()在 for 外取好, 否则新入队的孩子被算进当前层,分层错乱。 - 混淆「深度」与「高度」:深度是「根到该节点」,高度是「该节点到叶子」。 最大深度 = 根的高度,但二者递归写法方向相反,别混。
- 翻转时直接交换引用:写成
root.left = root.right; root.right = root.left会丢失原引用。 应先收好递归后的 left/right 再赋值。 - 对称题写成平行比较:check 里必须用
check(p.left, q.right)交叉,平行比较会变成「判断相同树」。 - 遍历 vs 分解选错:问「全局量」优先分解式(有返回值),别硬用全局变量遍历。
7.8自测题
Q1.(动手题)默写「最大深度」递归版,并说明 return 0 能不能改成 return 1。
if (root == null) return 0; 然后 return Math.max(maxDepth(left), maxDepth(right)) + 1;。
不能改 1:空节点代表「没有这一支」,应贡献 0 层;若返回 1,叶子节点的空孩子也被算成 1 层,整棵树深度会凭空多 1。
Q2. 最近公共祖先里,如果 p 和 q 是同一个节点(题目说保证不同,但假设),代码会返回什么?
递归到该节点时 root == p 命中返回自己;上层左右一侧返回它、另一侧返回 null,
最终 left != null ? left : right 把它传上去,LCA 就是 p 自身。逻辑也成立,说明代码对「节点是自身祖先」天然兼容。
Q3.(动手题)层序遍历若要求「之字形」(奇数层正序、偶数层反序),在现有代码上改哪一处即可?
只改「收集本层」那一步:偶数层(从 0 或 1 计,看约定)把 level 反转,或用 LinkedList 的 addFirst 头插。
核心的 int size = queue.size(); for... 框架一字不动——这就是先固定 size 的好处,换花样只动收集方式。