根到叶路径上的不足节点 图解题解
这道题到底在问什么
- 输入
- root=[40,30,25,50,-10,20,15], limit=100
- 输出
- 保留 40 → 30 → 50 这一支
最优解:为什么这么做
一句话答案:LeetCode 1080 根到叶路径上的不足节点:自顶向下把 limit 一路减去节点值往下传,叶子看剩余够不够、内部节点等左右都删光就删自己,一趟后序递归剪枝,时间 O(n)。
哪些节点该从树上删掉
给二叉树 root 和整数 limit。定义:穿过某个节点的每条根到叶路径,节点值之和都<limit,这节点就算不足、要删掉;叶子指没有孩子的节点。题面例子 root=[40,30,25,50,-10,20,15]、limit=100,四条根到叶路径只有 40→30→50 和 120 够到 100,另外三条都不足。
为什么不能对着每个节点单独算
一个节点是否不足,取决于穿过它的所有根到叶路径。若对每个节点都单独把下面的路径算一遍,同一段路会被反复重算,n 个节点退化成 O(n²)。何况删除会连锁:删掉叶子后父亲可能变成新叶子、要不要删得重判,静态扫一次接不住。
把路径和翻成一路减下去的预算
与其反复求整条路的和,不如给每条路发一份预算 limit:从根往叶子走,每过一个节点就把预算减掉它的值。到叶子时预算还>0,就说明这条路没凑够 limit、不足。于是「路径和<limit」等价于「到叶子剩余预算>0」。
内部节点看的是另一件事:只有它的每条根到叶路径都不足它才不足,这恰好等价于它的左右孩子最后全被删光。道理在于,穿过它的路无非是穿过左、右孩子的路,两个孩子都被删光,那两边的路就全不足,它自然也全不足。而要判孩子删没删光,得先把左右子树处理完再回头定它,先子后父的次序正是后序遍历。
递归返回时,一棵子树要么留要么整删
写成递归,返回值设成「处理后的子树根」:返回节点表示子树留下,返回空表示整棵删掉。到一个节点,先做 limit 减去它的值往下传。叶子若预算还>0 就返回空、删掉自己,否则返回自己。内部节点先递归处理左右孩子,把新子树接回原位;两边回来后,左右都空就返回空、连自己一起删,还留一个就返回自己。
这里的「删」不是真摘节点,而是让父亲的孩子指针指向空:上层把返回值往左右孩子一接就落定。
拿题面这棵树、limit=100 手推一遍剪枝
根 40,预算 100−40=60,传给两个孩子。左孩子 30,预算 60−30=30;左叶子 50,预算 30−50=−20,−20 不>0,保留 50;右叶子 −10,预算 30−(−10)=40,40>0,删掉 −10——扣的是负数,预算不减反增。回到 30,左边还留着 50,30 保留。
右孩子 25,预算 60−25=35;左叶子 20,预算 35−20=15,15>0,删;右叶子 15,预算 35−15=20,20>0,删。回到 25,左右孩子都删光,25 自己也删。回到根 40,左边 30 还在,40 保留。删完只剩 40→30→50,正是题面给的那一支。
叶子那个>写成≥,边界就删错
每个节点递归里只访问一次,做常数次加减比较,时间 O(n);额外开销是递归栈,层数跟着树高走:树越均衡栈越浅、接近 O(log n),退化成一条链时栈最深、到 O(n)。
叶子判删只能用剩余预算>0,写成≥0 就错:路径和正好等于 limit 时剩余预算为 0,这条路够、会被误删。内部节点也别看自身值够不够 limit,删不删只看左右孩子是否都删光。整棵树全不足时连根一起删、返回空树;单节点的树按它自己那条路判。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3记住三句话:下行扣预算、叶子看剩余、内部看孩子。下面每一帧都在套它。
- 4预算 limit = 100开局预算 limit = 100。DFS 从根 40 出发,沿每条路往下扣预算,到叶子再结算够不够。把根先标紫,正式下探。
- 5100 - 40 = 60经过节点 40,预算 100 扣掉 40 剩 60,把这 60 的预算交给它下面的子树去凑。先递归处理它的左右孩子,自己暂不下结论。
- 660 - 30 = 30经过节点 30,预算 60 扣掉 30 剩 30,把这 30 的预算交给它下面的子树去凑。先递归处理它的左右孩子,自己暂不下结论。
- 730 - 50 = -20走到叶子 50(紫色)。预算 30 扣掉它的值 50,剩 -20。到叶子了,该结算这条路够不够。
- 8和 120 ≥ 100 → 留这条根到叶路径和 = 40+30+50 = 120,120 ≥ limit 100,够了。预算已扣到 -20 ≤ 0 也是同一个意思。叶子 50 达标,保留(变绿)。
- 930 - -10 = 40走到叶子 -10(紫色)。预算 30 扣掉它的值 -10,剩 40。注意它是负数,扣负数等于预算不减反增。到叶子了,该结算这条路够不够。
- 10和 60 < 100 → 删这条根到叶路径和 = 40+30+(-10) = 60,60 < limit 100,没够。等价地看,预算还剩 40 > 0 也说明没凑够。叶子 -10 不足,剪掉(变红)。
- 11还有活孩子 → 留 30回到节点 30。它至少还留着一个孩子(左边 50),说明还有一条达标的路穿过它,保留(变绿)。
- 1260 - 25 = 35经过节点 25,预算 60 扣掉 25 剩 35,把这 35 的预算交给它下面的子树去凑。先递归处理它的左右孩子,自己暂不下结论。
- 1335 - 20 = 15走到叶子 20(紫色)。预算 35 扣掉它的值 20,剩 15。到叶子了,该结算这条路够不够。
- 14和 85 < 100 → 删这条根到叶路径和 = 40+25+20 = 85,85 < limit 100,没够。等价地看,预算还剩 15 > 0 也说明没凑够。叶子 20 不足,剪掉(变红)。
- 1535 - 15 = 20走到叶子 15(紫色)。预算 35 扣掉它的值 15,剩 20。到叶子了,该结算这条路够不够。
- 16和 80 < 100 → 删这条根到叶路径和 = 40+25+15 = 80,80 < limit 100,没够。等价地看,预算还剩 20 > 0 也说明没凑够。叶子 15 不足,剪掉(变红)。
- 17左右皆删 → 删 25回到节点 25。它的左右孩子刚才全被剪光了,意味着穿过它的每一条根到叶路径都不足,它就是不足节点,自己也剪掉(变红)。这一步是后序的关键:得等孩子定了才能定它。
- 18还有活孩子 → 留 40回到节点 40。它至少还留着一个孩子(左边 30),说明还有一条达标的路穿过它,保留(变绿)。
- 19保留 50,30,40处理完毕。最终只剩 40 → 30 → 50 这一支:它的路径和 120 够到了 100。其余不足节点 -10、20、15、25 全被删光。下面我们逐条回放四条路径核对一遍。
- 2040+30+50 = 120第一条 40 → 30 → 50,和 = 40+30+50 = 120,120 ≥ 100,达标。正因为有它,40 和 30 才得以保留。
- 2140+30+(-10) = 60第二条 40 → 30 → (-10),和 = 60,60 < 100,不足,叶子 -10 被删。它没拖垮 30,因为 30 还有 50 那条达标的路。
- 2240+25+20 = 85第三条 40 → 25 → 20,和 = 85,85 < 100,不足,叶子 20 被删。
- 2340+25+15 = 80第四条 40 → 25 → 15,和 = 80,80 < 100,不足,叶子 15 被删。25 的两条路都没够,所以 25 整片连根删掉。
⚠️ 容易写错的地方
✗ 错:叶子用 rem ≥ 0 判删
✓ 对:只有 rem > 0 才删
路径和正好等于 limit(rem=0)算够,要保留,写成 ≥ 会误删边界
✗ 错:自顶向下提前删内部节点
✓ 对:必须后序,等孩子定了再定它
内部节点删不删取决于孩子全删没全删,没处理完孩子无法判断
✗ 错:默认预算一路单调减
✓ 对:节点值可为负,扣负数预算会变大
负节点让剩余预算回升,不能假设越往下预算越小
✗ 错:内部节点看自身值是否够 limit
✓ 对:内部节点只看左右孩子是否都被删
不足的定义是「穿过它的每条路都不足」,等价于两个孩子都被删
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def sufficientSubset(
self, root: Optional[TreeNode], limit: int
) -> Optional[TreeNode]:
if root is None:
return None
limit -= root.val
if root.left is None and root.right is None:
return None if limit > 0 else root
root.left = self.sufficientSubset(root.left, limit)
root.right = self.sufficientSubset(root.right, limit)
return None if root.left is None and root.right is None else rootC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <optional>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
TreeNode* sufficientSubset(TreeNode* root, int limit) {
if (!root) {
return nullptr;
}
limit -= root->val;
if (!root->left && !root->right) {
return limit > 0 ? nullptr : root;
}
root->left = sufficientSubset(root->left, limit);
root->right = sufficientSubset(root->right, limit);
return !root->left && !root->right ? nullptr : root;
}
};Java
import java.util.*;
/**
* Definition for a binary tree node.
* 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;
* }
* }
*/
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;} }
class ListNode { int val; ListNode next; ListNode(){} ListNode(int val){this.val=val;} ListNode(int val, ListNode next){this.val=val;this.next=next;} }
class Solution {
public TreeNode sufficientSubset(TreeNode root, int limit) {
if (root == null) {
return null;
}
limit -= root.val;
if (root.left == null && root.right == null) {
return limit > 0 ? null : root;
}
root.left = sufficientSubset(root.left, limit);
root.right = sufficientSubset(root.right, limit);
return root.left == null && root.right == null ? null : root;
}
}复杂度
时间
O(n)
每个节点只在 DFS 里访问一次,常数次加减与比较
空间
O(h)
只有递归栈,深度等于树高 h,最坏(退化成链)O(n),平衡时 O(log n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 根到叶路径上的不足节点 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么必须后序自底向上,不能边下行边删?+
内部节点是否不足,取决于它下面所有根到叶路径是否全部不足,而这要等左右子树都处理完、看两个孩子是不是都被删掉才能确定。所以内部节点的去留只能放在递归返回时定,也就是后序。叶子不一样,它本身就是一条完整路径的终点,下行到底时预算够不够当场就能判,不用等谁。
这道题和「路径总和」那类题是什么关系?+
都是 DFS 累计根到叶的路径和。区别在于路径总和只问存不存在一条满足条件的路,这道题要根据每条路够不够,回头把不达标的节点从树上删掉。实现上靠「递归返回新的子树根」原地改树:返回空就表示这棵子树整个删掉,父亲接住这个空指针,删除就完成了。
节点值可能是负数,这里会不会有坑?+
会。不少人默认预算一路往下只会越减越小,于是想提前砍枝。但节点值为负时,减去一个负数预算反而变大——例子里叶子 −10 那步,预算就从 30 涨到 40。所以不能靠「预算好像变小了」提前下结论,必须走到叶子按剩余预算判、内部节点按孩子是否删光判。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 根到叶路径上的不足节点 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。