LeetCode 814. 二叉树剪枝
题目描述
题意分析
给的是一棵节点值只有
0和1的二叉树,要把「整棵子树里一个1都没有」的部分全部摘掉,返回剪完之后的树根。题面的措辞是「移除所有不包含
1的子树」,这句话有个陷阱:判断依据是整棵子树,不是单个节点。一个值为0的节点,只要它的后代里还藏着一个1,就必须留下来当路径上的连接点。反过来讲,同一个条件还有更强的一层含义:如果某棵子树该被删,那么它内部的每一棵子树也都该被删,因为它们同样一个
1都没有。所以「删掉整棵子树」和「自底向上逐层删空」是等价的,这为递归留下了空间。边界上要考虑:整棵树全是
0(答案是空树,包括根也要删)、整棵树全是1(原样返回)、单节点树、以及值为0但一侧子树保留另一侧被删空的情况。
解法:后序遍历剪枝
核心思路
暴力做法是对每个节点单独跑一次「这棵子树里有没有
1」的查询,有就留、没有就整棵摘掉。每次查询要遍历整棵子树,n个节点各查一次,最坏是 $O(n^2)$,链状树上尤其糟糕。瓶颈在于「子树里有没有
1」被反复重算:查根的时候扫了一遍全树,查根的孩子时又把同一批节点扫了一遍。而这个信息其实完全可以在一次遍历里自底向上攒出来——一个节点的答案只取决于它自己的值和两个孩子的答案。观察到「删掉」这个动作可以用返回值表达:让递归函数返回「这棵子树剪完之后的根」,删掉就是返回空。父节点拿到返回值后重新挂到自己的孩子指针上,删除就自动完成了,不需要显式的指针操作或父指针。
于是得到显式的不变量:
pruneTree(node)返回的一定是一棵「每个节点的子树里都至少含一个1」的树,且它是原子树剪枝后的唯一结果;当原子树里一个1都没有时返回空。有了这条不变量,当前节点的判断就非常简单。递归完成后,
node.left和node.right已经是剪好的子树。如果两个孩子都是空,说明左右两侧原本都不含1;此时只要node.val == 0,整棵以node为根的子树也不含1,返回空;只要有一个孩子非空,或者node.val == 1,这棵子树就含1,原样返回node。必须是后序:当前节点的去留依赖孩子剪完之后的形态,先序或中序都拿不到这个信息。
解题步骤
- 空节点直接返回空。这是递归出口,同时也让「孩子不存在」和「孩子被剪掉」统一成同一种表示,后面判断时不用分两种情况。
- 先递归处理左子树,并把返回值重新赋给
node.left。赋回去这一步是删除动作真正发生的地方,只调用不赋值等于什么都没做。- 同样递归处理右子树并赋回
node.right。- 检查
node.val == 0 && node.left == null && node.right == null,成立就返回空。三个条件缺一不可:值为0说明自己不贡献1,两个孩子都空说明后代里也没有1(因为不变量保证保留下来的子树必含1),合起来才能断定整棵子树可删。- 否则返回
node本身,把它交还给上层重新挂载。- 最外层直接返回递归结果,不要假设根一定活着——全
0的树最终会返回空。以
root = [1, null, 0, 0, 1]走一遍:这棵树的形状是根1没有左孩子,右孩子是A(0);A的左孩子是B(0),右孩子是C(1);B和C都是叶子。递归先下到根的左边,
null直接返回空,根的左指针仍是空。接着进入右子树A。在
A内部先处理B:B的两个孩子都是null,递归各自返回空;判断B.val == 0且两孩子皆空,条件成立,返回空。于是A.left被赋成null,B被摘掉。再处理
C:两个孩子递归返回空,但C.val == 1,第一个条件就不成立,返回C自身。A.right仍然是C。回到
A自己:A.val == 0成立,A.left == null成立,但A.right是C不为空,整体条件不成立,返回A。根的右指针仍是A。最后回到根:根的值是
1,直接返回自己。最终树形是[1, null, 0, null, 1],只有B被剪掉。再看全
0的用例root = [0, 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)$,
n是节点总数。每个节点恰好进入递归一次,函数体内只有常数次比较和两次赋值,没有任何重复扫描。- 空间复杂度:$O(h)$,
h是树高,全部来自递归调用栈。平衡树上是 $O(\log n)$,退化成链时是 $O(n)$;算法没有申请任何额外的数组或集合,剪枝是在原树上原地完成的。
关键点总结
- 「自顶向下判断」和「自底向上汇总」是树上问题的两条路,选错方向就会陷入重复计算。凡是当前节点的决策依赖整棵子树的聚合信息,就必须走后序,让孩子先把结论算好再回来。
- 用返回值表达「删除」,是链表和树的删除类问题里最省心的写法。递归函数返回「处理后的新根」,父节点接住并重新赋值,就不需要维护父指针、也不需要区分「删的是左孩子还是右孩子」。
- 递归调用必须赋回去,这条在
pruneTree(root.left)上体现得最直白。只调用不赋值的写法看起来很像对的,但它一个节点也删不掉,是这类题最高频的失分点。- 空节点和被剪空的子树用同一种表示(都是
null),使得判断条件只需要看孩子指针是否为空,不必额外记录「这个孩子是本来就没有还是被我删了」。统一表示能省掉一整类分支。- 面试视角:常见追问是「如果值不止
0和1,要删掉所有和为0的子树呢」。答案是把返回值从「新根」扩展成「新根 + 子树和」,或者用一个额外的后序函数先算和;核心的后序框架完全不变。第 1325 题的「反复删除值为target的叶子」也是同一个模板。- 面试视角:写完后主动指出「根节点也可能被剪掉,所以函数签名必须返回
TreeNode而不是void」,能说明你考虑过全0的退化输入,这是面试官最想听到的边界意识。
易错点总结
- 错误写法:前序遍历,一进节点就判断
val == 0并整棵摘掉。用例[1, null, 0, 0, 1]→ 走到A(0)时直接把它连同子树删掉,而它的右孩子C是1,结果返回[1],正确答案是[1, null, 0, null, 1]。- 错误写法:递归调用了却没有把返回值赋回孩子指针,写成
pruneTree(root.left);。用例[1, null, 0, 0, 1]→ 递归照常跑完,但树的指针一个都没改,返回的还是原树[1, null, 0, 0, 1],叶子B没被剪掉。- 错误写法:删除条件写成
val == 0就返回空,不检查孩子。用例[1, null, 0, 0, 1]→A的值为0被直接删掉,连带把C这个1也丢了,返回[1]而不是[1, null, 0, null, 1]。- 错误写法:判断孩子时用递归前的原始指针,而不是剪枝后的结果。用例
[1, null, 0, 0, 1]的子树A→ 剪枝前A.left指向B不为空,条件不成立所以保留A(结果碰巧对),但换成[0, 0, 0]时根的两个孩子在剪枝前都非空,根被误判为要保留,返回[0]而不是空树。- 错误写法:函数签名写成
void,靠在父节点里手动断开孩子来实现删除。用例[0, 0, 0]→ 整棵树都该删,但根没有父节点可以断开它,函数无法表达「返回空树」这个结果。- 错误写法:只把当前的
0叶子摘掉,跑一趟就结束,不考虑摘完会产生新叶子。用例[1, 0, 0, 0, 0](根1,左右孩子都是0,左孩子又带两个0叶子)→ 一趟扫描删掉最底层的两个0叶子和右边那个0叶子,但左孩子这时才变成0叶子却没人再处理,返回[1, 0]而不是[1]。- 错误写法:把删除条件里的
&&写成||。用例[1, null, 0, 0, 1]→C的值虽然是1,但它两个孩子都空,left == null这一支单独成立就被删掉,A随后也被删掉,返回[1]而不是[1, null, 0, null, 1]。- 错误写法:递归出口漏写,直接访问
node.val。用例 任何有单侧孩子的树,例如[1, null, 0, 0, 1]→ 处理根的左孩子时对null取val,抛空指针异常。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 1110. 删点成林 | 中等 | 删除后要收集断开产生的多个新根,返回的是森林而非单棵树 |
| 1325. 删除给定值的叶子节点 | 中等 | 删除会级联产生新叶子,考察后序天然完成了「反复删」这件事 |
| 669. 修剪二叉搜索树 | 中等 | 剪枝依据是值域区间,可借助 BST 有序性整条子树跳过 |
| LCR 047. 二叉树剪枝 | 中等 | 同题的另一入口,适合用来复核返回值赋回这一步是否写对 |