LeetCode 1038. 从二叉搜索树到更大和树
题目描述
题意分析
给定一棵二叉搜索树,要把每个节点的值改成「原树中所有大于等于它的节点值之和」,然后返回改造后的树根。注意求和范围包含节点自身,不是严格大于。
输入的关键信号是「二叉搜索树」四个字:左子树所有值小于根、右子树所有值大于根,且题目保证节点值互不相同。如果丢掉这个条件,本题就退化成一道普通的求和排序题。
数据规模只有不超过 100 个节点、值域 0 到 100,怎么写都不会超时,所以出题意图显然不在优化上,而在于能否用上有序性写出一趟扫描的解法。
边界上要考虑:树可能只有一个节点,此时结果就是它自己;树可能退化成一条链,递归深度会等于节点数;累加过程中的和最大约为 100 × 100,不存在溢出问题。
还有一处容易忽略的细节:改值是原地进行的,一旦某个节点被改写,它的旧值就没了,所以访问顺序必须保证「用到某个节点旧值时它还没被改」。
解法:反向中序遍历累计和
核心思路
最朴素的做法是两趟:先把整棵树的所有值收集到一个数组里,再对每个节点重新扫一遍数组,把不小于它的值加起来写回去。逻辑没毛病,但代价是 $O(n^2)$,而且完全没用上二叉搜索树的性质,面试官看到会立刻追问。
瓶颈在于「每个节点都重新求一次和」。如果改成按值从大到小的次序去访问节点,那么访问到某个节点时,比它大的节点恰好就是前面已经访问过的那一批,它们的和只要一个变量累加就够了,不必回头再扫。
关键观察是:二叉搜索树里「先右子树、再根、最后左子树」的访问次序,产生的正是一条严格递减的值序列。这条性质把「按值排序」免费做掉了,不需要真的去排序。
由此得到贯穿全程的不变量:在即将处理节点 node 之前,变量 sum 恰好等于原树中所有大于
node.val的节点值之和。处理动作固定为两步——先sum += node.val,再node.val = sum——执行完后 sum 就等于所有大于等于该节点的值之和,正好是下一个(更小的)节点所需要的前提。这两步的先后不能换,因为题目要求的和包含节点自身;它同时也解释了为什么必须先读旧值再写新值,写早了旧值就找不回来了。
解题步骤
- 用一个在整趟遍历中共享的变量 sum 记录累计和,初始为 0。它必须跨递归共享(Java 用成员变量、Go 用闭包捕获),因为不变量是全局推进的,写成局部变量每层都会归零。
- 递归进入一个节点时先判空,空则直接返回。这一步同时兜住了空树和叶子节点的两个空孩子,让后面的逻辑不必再判空。
- 先递归右子树。原因是右子树里的值全都大于当前节点,必须让它们先把自己算进 sum,当前节点的不变量才成立。
- 回到当前节点后执行
sum += node.val,再执行node.val = sum。先加后赋是为了把自身计入;反过来写会先把节点改成不含自身的和,再把这个和又加进 sum,值直接翻倍。- 最后递归左子树。左子树的所有值都小于当前节点,它们需要的前提是「当前节点及其右侧全部已计入」,而这正是上一步做完后的状态。
- 遍历结束后返回原来的 root。树是原地改的,节点结构没有任何变化,返回的还是同一个根指针。
以
root = [4,1,6,0,2,5,7]走一遍:这棵树根为 4,左孩子 1(左 0、右 2),右孩子 6(左 5、右 7)。按「右、根、左」的次序,访问序列是 7、6、5、4、2、1、0。访问 7 时 sum 由 0 变成 7,节点 7 改写为 7;访问 6 时 sum 变成 13,节点 6 改写为 13;访问 5 时 sum 变成 18,节点 5 改写为 18;访问 4 时 sum 变成 22,根改写为 22;访问 2 时 sum 变成 24,节点 2 改写为 24;访问 1 时 sum 变成 25,节点 1 改写为 25;访问 0 时 sum 仍为 25,节点 0 改写为 25。最终树为 [22,25,13,25,24,18,7]。sum 的终值 25 恰好等于原树全部节点之和 0 + 1 + 2 + 4 + 5 + 6 + 7,可以拿来当一次自检。
代码实现
class Solution {
// 访问某个节点时,右子树和所有更大节点已经处理完,累计和正好是所有大于当前值的节点和。
private int sum;
public TreeNode bstToGst(TreeNode root) {
sum = 0;
traverse(root);
return root;
}
private void traverse(TreeNode node) {
if (node == null) {
return;
}
traverse(node.right);
sum += node.val;
node.val = sum;
traverse(node.left);
}
}
func bstToGst(root *TreeNode) *TreeNode {
// 访问某个节点时,右子树和所有更大节点已经处理完,累计和正好是所有大于当前值的节点和。
sum := 0
var traverse func(node *TreeNode)
traverse = func(node *TreeNode) {
if node == nil {
return
}
traverse(node.Right)
sum += node.Val
node.Val = sum
traverse(node.Left)
}
traverse(root)
return root
}
复杂度分析
- 时间复杂度:$O(n)$,其中 n 为节点数;每个节点恰好被访问一次,每次只做常数次加法和赋值。
- 空间复杂度:$O(h)$,h 为树高,来自递归调用栈;树平衡时约为 $O(\log n)$,退化成链时为 $O(n)$。
关键点总结
- 二叉搜索树的题目,第一反应应当是「哪种访问次序能把值排成有序」,本题需要降序,于是把常规次序里的左右两次递归对调即可。
- 「按有序次序扫描 + 一个累加变量」是一整类前缀和/后缀和问题的共同骨架,把排序这一步藏进遍历次序里就省掉了额外的 $O(n \log n)$。
- 原地改写结构时,必须明确「读旧值」和「写新值」的先后,一旦顺序错了错误会沿着后续节点扩散,很难从最终结果反推出问题在哪。
- 累加变量要跨递归共享;Java 常用成员变量,Go 常用闭包捕获,两种写法都要能在白板上默写出来。
- 面试视角:本题和 538 是同一道题的两个编号,答完后要主动补一句「如果不允许改动树、要求返回新树怎么办」以及「迭代版本怎么写」,用显式栈模拟这个次序是常见的追问点。
易错点总结
- 错误写法:先写
node.val = sum再写sum += node.val→ 单节点树 [5] 会得到node.val = 0然后 sum 变成 0,返回 [0],正确答案是 [5]。- 错误写法:以为要把自身从和里剔除,写成
sum += node.val; node.val = sum - node.val;→ [4,1,6] 会得到 [6,10,0],而正确答案是 [10,11,6],因为题目要求的和是包含自身的。- 错误写法:把递归顺序写成「左、根、右」 → [4,1,6] 会得到 [5,1,11],因为访问顺序变成升序,sum 累的是比自己小的部分,方向正好相反。
- 错误写法:把 sum 声明成
traverse内部的局部变量 → 每层递归各自持有一份,右子树累出来的和传不到根,[4,1,6] 会得到 [4,1,6] 原样返回。- 错误写法:Java 里把 sum 声明为成员变量却不在
bstToGst开头重置 → 同一个 Solution 实例被判题系统连续调用多组用例时,上一组的残留和会带进下一组,本地单测通过、提交却错。- 错误写法:递归里漏掉
node == null的出口,改成在父节点判断左右孩子是否为空 → 少写一个分支就会空指针,而且两处判断容易只改一处。- 错误写法:把 sum 改成递归函数的参数和返回值来回传,却在处理完当前节点后忘记把更新过的 sum 传进左子树 → [4,1,6] 的左孩子会得到 1 而不是 11,因为它拿到的还是进入根之前的旧值。
- 错误写法:为了「省事」先中序收集成升序数组,再从后往前求后缀和写回,但写回时仍按原来的前序次序遍历 → 值和节点对不上号,必须保证收集和写回用的是同一种次序。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 98. 验证二叉搜索树 | 中等 | 升序遍历中比较前驱值,或传上下界 |
| 230. 二叉搜索树中第 K 小的元素 | 中等 | 升序遍历计数到第 K 个即可提前返回 |
| 426. 将二叉搜索树转化为排序的双向链表 | 中等 | 遍历中改指针,还要首尾成环 |
| 538. 把二叉搜索树转换为累加树 | 中等 | 与本题完全同题,可用作默写检查 |
| 897. 递增顺序搜索树 | 简单 | 遍历中重建成只有右孩子的链 |
| LCR 052. 递增顺序搜索树 | 简单 | 与 897 同题,注意断开左指针 |
| LCR 054. 把二叉搜索树转换为累加树 | 中等 | 与本题同题的另一个编号 |
| 剑指 Offer 36. 二叉搜索树与双向链表 | 中等 | 需额外维护 pre 与 head 两个指针 |
| 剑指 Offer 54. 二叉搜索树的第k大节点 | 简单 | 降序遍历计数,方向与本题一致 |
| 面试题 04.05. 合法二叉搜索树 | 中等 | 与 98 同题,需处理值的极端边界 |
| 面试题 17.12. BiNode | 简单 | 原地改造成链表且要求 $O(1)$ 额外空间 |