LeetCode 669. 修剪二叉搜索树
题目描述
题意分析
给一棵二叉搜索树和一个闭区间 [low, high],要把所有取值落在区间外的节点全部去掉,只留下取值在区间内的节点,最后返回修剪后的树根。
关键在于「修剪」两个字的含义比「筛选」严格得多:留下来的节点不仅数量要对,彼此之间的祖先与后代关系还必须和原树完全一致,也就是说不允许把留下的值重新收集起来另建一棵树。这条要求把很多看似可行的做法直接排除掉了。
边界上有三处要留神。区间是闭区间,值恰好等于 low 或 high 的节点必须保留。修剪后整棵树可能被删空,此时要返回空而不是报错。最容易忽略的是:一个节点被删掉,不代表它的整棵子树都要被删掉——它的某一侧子树里完全可能还藏着落在区间内的节点,必须接上去。
输入还给了一条强信息:这是一棵二叉搜索树,左子树所有值小于根、右子树所有值大于根。凡是给出有序性的题目,都应该先想能不能靠有序性成片地排除候选,而不是逐个检查。
解法:递归裁剪
核心思路
一种直觉做法是把树遍历一遍,找出所有越界的节点,再逐个调用「删除二叉搜索树中的节点」把它们摘掉。这条路能走通,但每删一个有两个孩子的节点都要去找中序后继来顶替,代码量成倍增长,而且删除过程中树的形态一直在变,很难说清正确性。
另一种直觉做法是中序遍历取出所有在区间内的值,再重建一棵树。这条路直接违反题意:重建出来的树虽然值集合正确,但祖先后代关系已经和原树不同了。
瓶颈在于把「修剪」当成了一串独立的删除动作。换个角度看,修剪的结果本身也是一棵树,那就可以用递归定义它:设
trim(node)返回「以 node 为根的子树修剪之后的新根」,只要每一层都严格按这个语义返回,父层把返回值接回去就自动完成了拼装。有了这个语义,二叉搜索树的有序性就能成片地砍掉候选。若
node.val < low,那么 node 的整棵左子树的值都比 node 还小,自然全部小于 low,可以整体丢弃,同时 node 自己也要丢;剩下唯一可能有幸存者的地方就是右子树,于是直接返回trim(node.right)。若node.val > high则完全对称,返回trim(node.left)。若 node 的值落在区间内,node 必然保留,只需把两侧子树各自修剪后接回来。贯穿全程的不变量是:
trim的返回值永远是一棵合法的、已经修剪完毕的子树的根(可能为空),并且它内部所有节点的相对结构与原树一致。每一层只依赖这条约定,不需要知道更上层的任何信息。
解题步骤
- 先处理空节点:
node为空时直接返回空。这既是递归的出口,也让上层不必在调用前判空。- 若
node.val < low:整棵左子树连同 node 自己一起出局,返回对右子树递归的结果。注意返回的是递归后的右子树而不是原始右子树,因为右子树里同样可能有超过 high 的节点需要继续裁。- 若
node.val > high:对称地返回对左子树递归的结果,理由同上。- 否则 node 落在闭区间内,一定保留。把
node.left赋成对左子树递归的结果、node.right赋成对右子树递归的结果。这两行必须写成赋值,只调用不赋值的话越界的孩子根本没被摘下来。- 最后返回 node。整个函数从头到尾只有一种返回语义,所以每一层都能放心地把下层结果当成成品使用。
以
root = [3,0,4,null,2,null,null,1]、low = 1、high = 3走一遍:这棵树的形状是根为 3,左孩子 0、右孩子 4,节点 0 的右孩子是 2,节点 2 的左孩子是 1。从根 3 开始,3 落在 [1, 3] 内,保留,先递归左边。节点 0 小于 low,于是 0 和它并不存在的左子树一起被丢弃,转而返回trim(2)。节点 2 落在区间内,保留,它的左孩子递归得到trim(1):1 落在区间内且没有孩子,两侧递归都返回空,直接返回节点 1;它的右孩子为空,返回空。于是trim(2)返回的是「以 2 为根、左孩子为 1」的子树,这个结果被接到 3 的左指针上。再递归右边:节点 4 大于 high,丢弃 4 和它的整棵右子树,返回trim(4.left),而 4 的左孩子为空,所以返回空,3 的右指针被置空。最终得到根为 3、左子树是 2、2 的左孩子是 1、右子树为空的树,序列化后正是 [3,2,null,1]。
代码实现
// 若节点值大于 high,则整棵右子树都被裁掉,返回裁剪后的左子树。
class Solution {
public TreeNode trimBST(TreeNode root, int low, int high) {
if (root == null) {
return null;
}
if (root.val < low) {
return trimBST(root.right, low, high);
}
if (root.val > high) {
return trimBST(root.left, low, high);
}
root.left = trimBST(root.left, low, high);
root.right = trimBST(root.right, low, high);
return root;
}
}
// 若节点值大于 high,则整棵右子树都被裁掉,返回裁剪后的左子树。
func trimBST(root *TreeNode, low int, high int) *TreeNode {
if root == nil {
return nil
}
if root.Val < low {
return trimBST(root.Right, low, high)
}
if root.Val > high {
return trimBST(root.Left, low, high)
}
root.Left = trimBST(root.Left, low, high)
root.Right = trimBST(root.Right, low, high)
return root
}
复杂度分析
- 时间复杂度:$O(n)$,其中 n 是节点总数。每个节点最多被访问一次,被整体丢弃的子树连访问都不会发生,所以这是一个宽松上界。
- 空间复杂度:$O(h)$,h 是树高,只消耗递归调用栈;树退化成链时是 $O(n)$,平衡时是 $O(\log n)$。除此之外没有分配任何新节点,修剪完全在原树上完成。
关键点总结
- 树形问题一旦能把答案本身也描述成一棵树,就该用「递归函数返回处理后的子树根」这个范式;它把删除、拼接这些指针操作全部收敛成一句赋值。
- 递归函数的返回语义必须一次定死并且每条分支都遵守,这是本题唯一需要保证的东西;三个分支返回的都是「修剪后的子树根」,所以它们可以互相替换、层层嵌套。
- 有序性的价值在于成片排除:
node.val < low一次性否定了整棵左子树,这是把逐点判断降到线性以下的常见手法,二叉搜索树题里几乎处处可用。- 节点越界不等于它的子树全部越界,必须继续往仍有希望的那一侧递归;这是本题最集中的错误来源。
- 面试视角:面试官很可能先问「为什么不能用删除节点的模板逐个删」,想听的是你意识到删除有两个孩子的节点需要找中序后继、过程中树形会变,正确性难以论证;点破这一点再给出递归返回子树根的写法,思路的高低立判。
- 面试视角:写完之后常被追问改成迭代版本。答法是先自上而下把根移动到第一个落在区间内的节点,再分别沿左链剪掉小于 low 的部分、沿右链剪掉大于 high 的部分,空间可降到 $O(1)$;能说清这个改写方向,说明你理解的是结构而不是模板。
易错点总结
- 错误写法:
node.val < low时返回trim(node.left),方向写反:root = [3,0,4,null,2,null,null,1]、low = 1、high = 3→ 节点 0 的右子树里还有合法的 2 和 1,却顺着空的左子树返回了空,答案退化成只剩 [3]。- 错误写法:节点越界就直接
return null,不再往下递归:同样是上面这组输入 → 节点 0 被删的同时把 2 和 1 一起带走,答案变成 [3],而正确答案是 [3,2,null,1]。- 错误写法:递归调用后忘记赋值,写成两行光秃秃的
trim(node.left, low, high);:root = [1,0,2]、low = 1、high = 2→ 节点 1 的左指针从未被改动,返回的树里仍然挂着越界的 0。- 错误写法:把闭区间当成开区间,判断写成
node.val <= low就丢弃:root = [1,0,2]、low = 1、high = 2→ 值恰好等于 low 的根节点 1 被误删,返回 [2],正确答案是 [1,null,2]。- 错误写法:忘记处理空节点就直接访问
node.val:任何一棵有叶子的树 → 递归到叶子的空孩子时立刻空指针异常。- 错误写法:把修剪理解成「收集区间内的值再重建一棵树」:
root = [3,0,4,null,2,null,null,1]、low = 1、high = 3→ 值集合 {1, 2, 3} 虽然对,但重建出的树形与原树的祖先后代关系不同,判题不通过。- 错误写法:在区间内的分支里只递归一侧、假设另一侧不需要处理:
root = [3,0,4,null,2,null,null,1]、low = 1、high = 3→ 若只修左边,越界的 4 会被保留在结果里;若只修右边,越界的 0 会被保留。- 错误写法:担心修剪后树不再平衡而额外做一次重建或旋转:任意输入 → 题目只要求删点并保持原有相对结构,任何重排都会改变节点间的祖先关系,属于画蛇添足的错解。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 700. 二叉搜索树中的搜索 | 简单 | 只沿一条路径下探,不需要回头拼接子树 |
| 701. 二叉搜索树中的插入操作 | 中等 | 同为返回子树根的递归范式,但做的是挂上新节点而非摘除 |
| 450. 删除二叉搜索树中的节点 | 中等 | 只删一个节点,难点在于双孩子情形要用中序后继顶替 |
| 98. 验证二叉搜索树 | 中等 | 同样围绕取值区间递归,但区间是自上而下收紧的判定条件 |
| 938. 二叉搜索树的范围和 | 简单 | 同样靠有序性剪掉整棵子树,但只累加数值、不改动树结构 |
| 235. 二叉搜索树的最近公共祖先 | 中等 | 靠值与两个目标的大小关系决定下探方向,一路不回溯 |