LeetCode 938. 二叉搜索树的范围和
题目描述
题意分析
给一棵二叉搜索树的根节点
root和闭区间[low, high],把树中所有落在这个区间内的节点值加起来返回。注意是闭区间,low和high本身若存在于树中也要计入。题目特意强调了「二叉搜索树」而不是普通二叉树,这是全题唯一的信号。普通二叉树只能老老实实访问每个节点,而 BST 多出一条全局性质:任意节点的左子树所有值都小于它,右子树所有值都大于它。这条性质意味着只看当前节点的值就能对整棵子树下判断,而不需要走进去看。
顺着这条信号往下推:如果当前节点值小于
low,那它的整棵左子树都比它还小,必然全部小于low,一个都不可能有贡献;对称地,如果当前节点值大于high,右子树可以整体丢弃。能整体丢弃子树,就说明这题期待的是一个带剪枝的遍历,而不是全量遍历后过滤。边界方面要留意三处:空树返回 0;节点值恰好等于
low或high时必须计入;题目保证节点值互不相同,所以不用担心区间内重复计数的问题。另外值域可以到 $10^5$、节点数也可到 $2 \times 10^4$,累加和会超过单个节点值的范围但仍在int内,不必上long。还有一点容易被忽略:题目没有保证区间内的节点在树中是连续的一段,也没有保证
low、high一定存在于树中,所以不能写成「先找到 low 再中序走到 high」那种依赖端点存在的写法。
解法:DFS + BST 剪枝
核心思路
最朴素的做法是把树当普通二叉树,完整遍历一遍,对每个节点判断
low <= val <= high,是就累加。这在 $O(n)$ 时间内也能过,但它完全没有用到「搜索树」这个前提——如果把树的所有值随机打乱重排,这份代码依然成立,说明它没有抓住题目给的信息。瓶颈就在这里:全量遍历会走进大量明知不可能有贡献的子树。比如区间是
[7, 15],而某个节点值为 3,那它左子树里全是小于 3 的数,这些节点被访问了却一个也没被累加,纯属浪费。观察的关键是把「一个节点的值」提升为「一整棵子树的值域约束」。设
dfs(node)表示以node为根的子树中,落在[low, high]内的节点值之和。由 BST 性质可知:
- 若
node.val < low,则node及其整棵左子树的值都< low,因此dfs(node) = dfs(node.right);- 若
node.val > high,则node及其整棵右子树的值都> high,因此dfs(node) = dfs(node.left);- 否则
low <= node.val <= high,当前节点计入,两侧子树都可能有贡献,dfs(node) = node.val + dfs(node.left) + dfs(node.right)。这三条就是完整的递归定义,它们互斥且覆盖了所有情况,因此不需要额外的分支。递归的不变量是:
dfs(node)的返回值永远等于「该子树中区间内节点值之和」,与调用它的上层处于哪个分支无关——这一点保证了三条规则可以随意组合而不会重复或遗漏计数。值得强调的是,第一条和第二条规则里被剪掉的不是「一个节点」,而是「一整棵子树」,这才是剪枝的价值所在。第三条规则里两边都要递归,是因为当前节点在区间内时,左子树中仍可能有小于
low的节点、右子树中仍可能有大于high的节点,必须继续往下判断。
解题步骤
- 定义递归函数的语义:
dfs(node, low, high)返回node子树中区间内节点值之和。先把语义写死,后面每一个分支都只需检查「返回值是否符合这个语义」,不会写着写着混淆。- 递归基:
node == null返回 0。为什么用 0 而不是特判父节点的子指针:空子树的区间和天然就是 0,用它做单位元可以让上层的加法与替换式返回都无须特判,代码只有一个出口分支。- 左剪枝:
node.val < low时直接return dfs(node.right, ...)。为什么可以整棵左子树都不看:BST 保证左子树全部小于node.val,而node.val已经小于low,传递下去左子树全部小于low,贡献恒为 0;当前节点自己也不在区间内,所以不加node.val。- 右剪枝:
node.val > high时直接return dfs(node.left, ...)。理由与上一条对称。- 命中区间:剩下的情况必然满足
low <= node.val <= high,返回node.val + dfs(left) + dfs(right)。为什么两边都要继续递归:当前节点在区间内只说明它自己合法,左子树里仍可能藏着比low小的节点、右子树里仍可能藏着比high大的节点,剪枝要交给更深层去做。- 注意判断用的是非严格边界:写
node.val < low而不是<=,写node.val > high而不是>=,这样等于端点的节点会落进第三个分支被计入,正好对应闭区间语义。以
root = [10,5,15,3,7,null,18]、low = 7、high = 15走一遍。这棵树是:根 10,左孩子 5(左 3、右 7),右孩子 15(右 18)。
dfs(10):10在[7, 15]内,命中第三条,结果为10 + dfs(5) + dfs(15)。
dfs(5):5 < 7,命中左剪枝,节点 3 整棵子树被跳过,结果为dfs(7)。
dfs(7):7在区间内(这里正是端点,用<而非<=才能保住它),结果为7 + dfs(null) + dfs(null) = 7。所以dfs(5) = 7。
dfs(15):15在区间内(另一个端点),结果为15 + dfs(null) + dfs(18)。
dfs(18):18 > 15,命中右剪枝,结果为dfs(null) = 0。所以dfs(15) = 15。
回到根:10 + 7 + 15 = 32,与预期一致。整个过程访问了 10、5、7、15、18 五个节点,节点 3 一次都没被访问,剪枝生效。
代码实现
class Solution {
public int rangeSumBST(TreeNode root, int low, int high) {
return dfs(root, low, high);
}
private int dfs(TreeNode node, int low, int high) {
if (node == null) {
return 0;
}
// 当前值小于下界,左子树只会更小,整棵跳过。
if (node.val < low) {
return dfs(node.right, low, high);
}
// 当前值大于上界,右子树只会更大,整棵跳过。
if (node.val > high) {
return dfs(node.left, low, high);
}
return node.val + dfs(node.left, low, high) + dfs(node.right, low, high);
}
}
func rangeSumBST(root *TreeNode, low int, high int) int {
if root == nil {
return 0
}
// 当前值小于下界,左子树只会更小,整棵跳过。
if root.Val < low {
return rangeSumBST(root.Right, low, high)
}
// 当前值大于上界,右子树只会更大,整棵跳过。
if root.Val > high {
return rangeSumBST(root.Left, low, high)
}
return root.Val + rangeSumBST(root.Left, low, high) + rangeSumBST(root.Right, low, high)
}
复杂度分析
- 时间复杂度:最坏 $O(n)$,实际远小于此。凭什么:每个被访问的节点只做常数次比较与加法,且每个节点至多被访问一次;当区间覆盖整棵树时无法剪枝,退化为全量遍历,故上界为 $O(n)$;区间较窄时被剪掉的子树完全不进入递归,实际访问量约为「区间内节点数 + 两条边界搜索路径」。
- 空间复杂度:$O(h)$,
h为树高。凭什么:除了递归调用栈没有开辟任何辅助结构,栈深度等于当前递归路径长度,最多为树高;平衡时为 $O(\log n)$,退化成链时为 $O(n)$。
关键点总结
- BST 的核心价值是「用一个节点的值给一整棵子树的值域定界」,凡是题目点明 BST,第一反应就该是找哪些子树可以整体排除,而不是先想怎么遍历。
- 把递归函数的返回值语义先写成一句话(「该子树中区间内节点值之和」),再逐条推分支,是写树形递归最省心的顺序;分支写乱通常是因为语义没定死。
- 空节点返回 0 属于「用单位元消灭特判」的通用手法,同类的还有空子树深度返回 0、空子树最大值返回负无穷。
- 闭区间与开区间的差别全落在比较符的等号上,写代码前先把题面的区间开闭读准,比事后调试快得多。
- 面试视角:写完剪枝版后主动说明「不剪枝的全量遍历也是 $O(n)$,但剪枝版在窄区间下实际访问量小得多,而且能体现对 BST 性质的理解」,这是面试官在这道简单题上真正想听的点;若被追问进阶,可以提中序遍历天然有序、可以在遍历到超过
high时提前终止。- 同一套「按值域剪枝」的思路可以直接迁移到 BST 上的查找、插入、删除与区间查询,模板高度一致。
易错点总结
- 错误写法:把剪枝条件写成
node.val <= low→ 用例root = [10,5,15,3,7,null,18]、low = 7、high = 15中,节点 7 会被当成越界而跳过自身,答案从 32 变成 25。- 错误写法:把
node.val > high写成>=→ 同一用例中节点 15 被跳过,答案从 32 变成 17。- 错误写法:左剪枝时写成
return node.val + dfs(node.right, ...)→ 用例low = 7时节点 5 明明不在区间内却被累加,答案多出 5。- 错误写法:左剪枝时误递归左子树
return dfs(node.left, ...)→ 用例中从节点 5 走向节点 3,越走越小,区间内的节点 7 永远访问不到,答案漏掉 7。- 错误写法:忘记
node == null的递归基 → 用例中递归到节点 18 的空左孩子时直接空指针异常(Go 里是 nil 解引用 panic)。- 错误写法:命中区间时只递归一侧,比如
node.val + dfs(node.left, ...)→ 用例中根节点 10 命中区间后不再看右子树,节点 15 丢失,答案变成 17。- 错误写法:把三个分支写成
if / if / if且前两个分支不return,落到最后统一累加 → 剪枝分支的结果被丢弃后又执行了第三条规则,节点 5、18 被重复计入,答案变成 55。- 错误写法:用一个成员变量累加而不是靠返回值传递,且多组测试用例复用同一个
Solution实例 → 第二次调用时上一次的和没清零,结果被叠加放大。- 错误写法:假设
low、high一定存在于树中,先二分找到low的位置再中序累加到high→ 用例low = 6、high = 16时找不到起点,逻辑直接落空返回 0。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 700. 二叉搜索树中的搜索 | 简单 | 同样按值比较决定走哪一侧,但只需单侧下沉,不存在两侧都递归的情况 |
| 701. 二叉搜索树中的插入操作 | 中等 | 从「读」变成「写」,要在空位处挂上新节点并把子指针接回父节点 |
| 450. 删除二叉搜索树中的节点 | 中等 | 定位后还要处理双子节点的前驱/后继替换,是 BST 修改类里最复杂的一档 |
| 98. 验证二叉搜索树 | 中等 | 反过来用值域:向下传递 (min, max) 上下界校验,而非用值域剪枝 |
| 230. 二叉搜索树中第 K 小的元素 | 中等 | 依赖的是中序遍历有序性与计数提前返回,而不是子树整体排除 |
| 1038. 从二叉搜索树到更大和树 | 中等 | 反向中序累加并原地改值,考的是遍历次序而非剪枝 |
| 653. 两数之和 IV - 输入二叉搜索树 | 简单 | 需要跨节点配对,单纯的子树剪枝不成立,得配哈希集合或双指针 |