力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 07二叉树:递归思维的主战场

如果说数组考验细心、链表考验指针,那二叉树就是递归思维的主战场。 树天然是「自相似」的——一棵树的左子树、右子树,本身又是一棵小树。这种结构几乎逼着你用递归。 本章先给你一个看待树题的「二分法」:遍历 vs 分解,再带你用 6 道题把递归写顺。

学习目标
建立树的递归直觉
前置要求
专题 06 栈(递归=隐式栈)
本章产出
6 道 Hot 题 + 两种思路
建议学时
5–7 小时

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、修改原树)。

判断口诀:问自己「这个问题的答案,是不是『左子树的答案 ⊕ 右子树的答案 ⊕ 自己』?」 是 → 分解式(有返回值);否,只是「顺手做件事」→ 遍历式(无返回值,靠副作用)。 本章 6 题里,深度/翻转/对称/LCA 全是分解式,只有「按某种顺序打印」才算遍历式。

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?因为翻转是「原地改」,改完把根交回去,上层就能接着接。

关键点:交换用的是「递归后的返回值」left/right,不是直接 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) 判断「两棵树是否互为镜像」:

参考代码

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 的计数内,于是自动归到下一轮。这个「快照」是分层的核心。

关键点:BFS 的 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。

两种情况被同一套逻辑统一了,不需要特判「谁是祖先」——这正是后序递归的优雅之处。

易错提醒:有人想用「自底向上找父指针」做 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 的好处,换花样只动收集方式。

学完这章,你应该能回答:「树题我该用遍历还是分解?递归函数返回什么?」 答案:问全局量(最大深度、是否对称、LCA)用分解式,让左右子树先交答案、父节点汇总; 递归第一句必写 base case(null 怎么返回)。能独立写出最大深度、翻转、对称、层序、LCA, 就进 专题 08 · 递归与回溯。