目录

题目描述

LCR 047. 二叉树剪枝

题意分析

题目目标:给定一棵所有节点值都是 0 或 1 的二叉树,删掉所有不包含 1 的子树,返回处理后的树根。换句话说,最终保留下来的每一个节点,其自身或后代中至少存在一个 1。
核心约束:删除的判定依赖整棵子树的信息而不是节点自身——一个值为 0 的节点,只要后代里有 1 就必须留下。这种"父节点的结论依赖子节点的结论"的依赖方向,说明信息必须自底向上汇总。
边界处理:整棵树可能被剪光,此时要返回空而不是原根;叶子是判定的起点,值为 0 的叶子直接被删;值为 1 的节点无论后代如何都保留;树本身也可能为空。
实现取舍:剪枝是链式的——删掉一个叶子后,它的父节点可能因此变成新的叶子并接着被删。所以必须保证父节点的判定发生在两个孩子都处理完之后,否则会漏剪。

解法:深度优先搜索

核心思路

直觉上的做法是反复扫描整棵树,每一轮删掉当前所有值为 0 的叶子,直到某一轮没有任何删除为止。这是对的,但一条长度为 n 的全零链要扫 n 轮,代价 $O(n^2)$,而且"反复直到稳定"这种写法在面试白板上既啰嗦又容易出错。
瓶颈在于每一轮只削掉最外面一层。观察链式剪枝的传播方向:删除总是从叶子开始,向上传染到父亲。既然结论从下往上流动,那就让递归的返回值承担这个流动——只要一次后序遍历,每个节点在自己被判定时,两个孩子的最终形态就已经确定了。
由此显式定义递归的语义(这是本题唯一需要想清楚的东西):pruneTree(node) 返回"以 node 为根的子树剪枝完成后的新根",若整棵子树都该被删除则返回空。这个定义同时覆盖了空、被删、保留三种情况,调用方只需把返回值赋回原来的孩子指针即可。
有了这个语义,判定规则就极简:先递归处理左右孩子并把返回值写回,此时 node.leftnode.right 已经是剪枝后的结果;如果 node.val == 0 且两个孩子都已为空,说明这棵子树里一个 1 都不剩,返回空;否则返回 node 本身。链式传播由递归的返回顺序自动完成,不需要任何循环。

解题步骤

  • 递归入口先处理空节点,直接返回空。为什么这一步能兜住所有边界:它既是递归的终止条件,也顺手处理了整棵树为空的输入,后续代码可以放心地解引用 root
  • 执行 root.left = pruneTree(root.left),把左子树的剪枝结果写回。为什么必须写回而不是只调用:递归返回的可能是空,只有赋值回去才能真正切断被删的子树;只调用不赋值等于什么也没做。
  • 同样处理右子树。为什么两次递归都要在判定之前完成:当前节点是否该删取决于孩子剪完之后的形态,提前判定就会用到过时的信息,漏掉链式传播。
  • 判定 root.val == 0 && root.left == null && root.right == null 时返回空。为什么三个条件缺一不可:值为 1 说明本身含 1 必须保留;任一孩子非空说明后代里还有 1(否则那个孩子早就被剪掉了),也必须保留。
  • 否则返回 root,把自己交还给父亲。为什么返回值语义要在所有分支上保持一致:父亲拿到的必须永远是"剪枝后的子树根或空",一旦某个分支返回了别的东西(比如返回孩子),整棵树的结构就串了。
  • 具体用例:树 [1, null, 0, 0, 1](根 1 只有右孩子 0,记作 A;A 的左孩子是 0,记作 B;A 的右孩子是 1,记作 C)走一遍。递归到 B:它的左右孩子都是空,val == 0 且孩子全空,返回空,于是 A 的左指针被置空。递归到 C:孩子全空但 val == 1,返回 C 自身,A 的右指针保持指向 C。回到 A:此时左孩子为空、右孩子是 C 非空,虽然 val == 0 但条件不满足,返回 A。回到根:val == 1,返回根。最终树是 [1, null, 0, null, 1],B 被剪掉而 A 因为后代含 1 被保留。再看链式情形,树 [0, 0, 0]:两个叶子先各自返回空,根随后发现自己值为 0 且孩子全空,返回空,整棵树被剪光。

代码实现

// 核心实现:深度优先搜索,维护必要状态并避免重复处理。
class Solution {
    public TreeNode pruneTree(TreeNode root) {
        if (root == null) {
            return null;
        }
        root.left = pruneTree(root.left);
        root.right = pruneTree(root.right);
        if (root.val == 0 && root.left == null && root.right == null) {
            return null;
        }
        return root;
    }
}
// 核心实现:深度优先搜索,维护必要状态并避免重复处理。
func pruneTree(root *TreeNode) *TreeNode {
    if root == nil {
        return nil
    }
    root.Left = pruneTree(root.Left)
    root.Right = pruneTree(root.Right)
    if root.Val == 0 && root.Left == nil && root.Right == nil {
        return nil
    }
    return root
}

复杂度分析

  • 时间复杂度:$O(n)$。凭什么:每个节点只被递归访问一次,访问时做的是常数次指针赋值与比较,链式剪枝靠返回顺序完成而不是靠重复扫描。
  • 空间复杂度:$O(h)$,h 为树高,最坏(链状树)为 $O(n)$。凭什么:没有申请任何额外容器,唯一开销是递归调用栈,深度等于树高。

关键点总结

  • 凡是"父节点的结论依赖子树整体信息"的树形问题,都应当用后序遍历,并把结论编码进递归的返回值里;先想清返回值代表什么,代码几乎是自动写出来的。
  • node.child = dfs(node.child) 这个"递归结果写回指针"的写法是所有结构性修改树的题目的统一模板(剪枝、删点、修剪区间都适用),比在父节点里手动判断该不该置空要稳得多。
  • 链式连锁反应不需要循环到稳定:只要判定发生在孩子已经定型之后,一次后序遍历就把连锁效应全部吃掉。
  • 判定条件里"孩子为空"之所以能代表"孩子那边没有 1",靠的是递归的归纳保证——每一步都要能说出这句话,才算真正理解了后序的力量。
  • 面试视角:面试官想看的是你能否准确说出递归函数的契约。开口先讲"我定义 pruneTree(node) 返回剪枝后的子树根,可能为空",再解释为什么必须后序,最后补一句"根也可能被剪掉,所以返回值必须交给调用方而不能假设根一直在",基本就拿满了。

易错点总结

  • 错误写法:只调用 pruneTree(root.left) 而不把返回值赋回 → 树 [1, 0, 0] 时两个叶子的删除结果丢失,返回的树仍是 [1, 0, 0],一个节点都没剪掉。
  • 错误写法:把判定写在两次递归之前(前序) → 树 [0, 0, 0] 时根在孩子还没被剪时判定,此时孩子非空所以根被保留,返回 [0] 而不是空树,链式传播被切断。
  • 错误写法:判定条件漏掉 root.val == 0,写成只要孩子全空就删 → 树 [1] 时根是叶子且值为 1,却被误删,返回空树。
  • 错误写法:判定条件漏掉孩子判空,写成只要 val == 0 就返回空 → 树 [0, null, 1] 时根被删掉,值为 1 的右孩子跟着丢失,返回空树而不是 [0, null, 1]
  • 错误写法:条件里的 && 写成 || → 树 [1, null, 0, null, 1] 中值为 1 的叶子因为"孩子为空"这半边成立而被删除,含 1 的节点被误剪。
  • 错误写法:值为 0 的节点直接返回它的某个非空孩子来"跳过自己" → 树 [1, null, 0, null, 1] 会被压成 [1, null, 1],题目要求的是删除整棵不含 1 的子树,而不是压缩路径。
  • 错误写法:缺少 root == null 的入口判断 → 空树输入时访问 root.val 直接空指针异常,Go 里同样 panic。
  • 错误写法:假设根一定不会被删,直接 return root → 树 [0][0, 0, 0] 时应返回空树,实际返回了一个孤零零的 0 节点。
  • 错误写法:改写成反复扫描删叶子直到不再变化 → 结果正确但一条 $10^4$ 长的全零链要扫 $10^4$ 轮,代价升到 $10^8$ 级别,同时循环终止条件极易写成死循环。

相似题目

题目 难度 考察点
1325. 删除给定值的叶子节点 中等 判定值由参数给出,同样靠后序触发链式删除
669. 修剪二叉搜索树 中等 剪枝依据是值域区间,可借助有序性只递归一侧
450. 删除二叉搜索树中的节点 中等 删除单个内部节点需要找后继补位,重接逻辑更复杂
226. 翻转二叉树 简单 同为结构性修改,但改的是左右指针而非删除
617. 合并二叉树 简单 递归返回新节点,考察两棵树同步下降的写法
110. 平衡二叉树 简单 同样自底向上汇总,但返回值要同时携带高度与结论