LeetCode 653. 两数之和 IV - 输入二叉搜索树
题目描述
题意分析
给一棵二叉搜索树的根节点和一个整数
k,判断树中是否存在两个不同的节点,它们的值之和等于k。只要返回布尔值,不需要给出是哪两个节点。要什么:存在性判定。这一点决定了我们一旦找到答案就可以立刻短路返回,不必遍历完整棵树。
「两个不同的节点」是本题最容易被忽略的约束。它禁止的是同一个节点被用两次(比如
k = 8且树中有一个值为 4 的节点,不能拿这个 4 自己配自己);但它并不禁止两个值相同的不同节点——虽然标准 BST 通常约定节点值互不相同,实现时也不应依赖这条假设去写「值相等就跳过」的逻辑。约束里的算法信号有两层。第一层是「二叉搜索树」:中序遍历会得到一个升序序列,天然可以套双指针;同时也可以对任意一个值
x用 $O(h)$ 的时间去树中查找k - x。第二层是节点数的规模在万级、节点值与k都在 $10^4 \sim 10^5$ 量级,说明 $O(n)$ 或 $O(n \log n)$ 都能过,不必为常数因子纠结,但 $O(n^2)$ 的两两枚举在极端退化成链的树上会吃紧。值得注意的是:BST 的有序性在这道题里不是必需的。题目本质就是在一堆数里找和为
k的一对,把树换成任意二叉树、甚至换成数组,问题都成立。识别出这一点,才不会被「二叉搜索树」四个字带着去做多余的上下界剪枝。边界:根为空返回
false;只有一个节点时无论如何都凑不出两个不同节点,返回false;节点值可以为负,k也可以为负,所以不能用 0 或负数当哨兵;k - node.val的取值范围在int内安全,不会溢出。
解法:DFS + 哈希集合记录补数
核心思路
先看暴力:对每个节点
x,再完整遍历一遍整棵树找有没有值为k - x的另一个节点。时间 $O(n^2)$,在节点数上万且树退化成链时是 $10^8$ 量级的节点访问,不划算。瓶颈在于每个节点的「找补数」都从零开始重扫全树,前面已经看过的信息一次都没被复用。稍微好一点的做法是利用 BST:对每个节点
x,从根出发用 $O(h)$ 的时间二分查找k - x,总复杂度 $O(n h)$。但这引入了一个恼人的细节——查到的那个节点可能就是x自己,必须额外判断「找到的节点是不是同一个节点对象」,而不能只比值。逻辑变复杂了,收益却有限。换个角度:把「找一对和为
k的数」这个经典问题剥离出树的外壳,它就是 1. 两数之和 的原型。两数之和的标准解法是一次遍历 + 补数哈希——遍历到x时,不去问「后面有没有k - x」,而是问「前面有没有k - x」。这样每个数只需被检查一次,且天然保证配对的两个数来自不同位置。于是得到本解法的不变量:在处理任意节点
node的那一刻,集合seen中恰好装着按 DFS 前序顺序在node之前被访问过的所有节点的值,且这些节点与node一定是不同的节点。基于这个不变量,只要seen.contains(k - node.val)成立,就找到了一对合法的解;反之,若整棵树遍历完都没有命中,说明任意一对节点都不满足——因为任何一对节点在 DFS 序中总有先后,后被访问的那个必然会在自己被处理时于seen里看到先被访问的那个。这里遍历顺序采用什么并不重要(前序、中序、后序、BFS 都行),因为不变量只要求「先访问的已入集合、当前的尚未入集合」。关键是先查再存:必须在把
node.val放进集合之前完成查询,否则当k恰好等于2 * node.val时,节点会查到刚被自己塞进去的值,把自己和自己配成一对,违反「两个不同节点」的约束。最后,返回值用
dfs(left) || dfs(right)的短路或来串联:一旦左子树给出true,右子树根本不会被访问,实现了「找到即停」。
解题步骤
- 在入口创建一个空的
HashSet<Integer> seen,把它作为参数贯穿整个递归。为什么要在入口建而不是在递归里建:这个集合必须被所有节点共享,代表「全局已访问过的值」;若在递归函数内部新建,每层都拿到空集合,等于什么都没记。- 递归函数首先判空返回
false。为什么返回false而不是true:false是「这条分支没找到」的中性元,与上层的||组合时不会污染结果;空子树也确实不可能提供答案。- 算出
need = k - node.val,先在seen中查询它。为什么必须先查后存:见核心思路中的说明,先存会让k = 2 * node.val的节点自己匹配自己。这是本题唯一的语义陷阱。- 命中则立刻返回
true。为什么可以立刻返回:题目只要存在性,找到一对就足够,且seen里的值来自另一个节点,配对合法。- 未命中则把
node.val加入seen。为什么加的是node.val而不是need:集合的语义是「已经见过的值」,加need会把一个可能根本不存在的值当成已见过,导致后续节点误判为true。- 递归左右子树并用
||连接返回。为什么用||而不是先算两边再取或:||在 Java 与 Go 中都是短路运算符,左子树返回true时右子树整棵都不会被遍历,这就是「找到即停」的实现方式。以
具体用例 root = [5,3,6,2,4,null,7], k = 9走一遍。这棵树的结构是:根 5,左孩子 3(其左右孩子为 2 和 4),右孩子 6(左孩子为空,右孩子为 7)。预期答案是true,因为2 + 7 = 9。初始
seen = {}。DFS 按前序访问。
访问 5:need = 9 - 5 = 4,seen为空不含 4,把 5 存入,seen = {5}。递归左子树。
访问 3:need = 9 - 3 = 6,seen = {5}不含 6(注意树里其实有 6,但它在前序中排在 3 之后,此刻还没被访问,这正是不变量的体现——不用担心漏掉,等轮到 6 时它会反过来查到 3)。把 3 存入,seen = {5, 3}。递归左子树。
访问 2:need = 9 - 2 = 7,seen = {5, 3}不含 7。存入,seen = {5, 3, 2}。左右孩子均为空,两次递归都返回false。
回到 3 的右子树,访问 4:need = 9 - 4 = 5,seen中含有 5,命中,返回true。
这个true沿dfs(3.left) || dfs(3.right)向上传递,3 返回true;再沿dfs(5.left) || dfs(5.right)传递,由于左侧已为true,短路生效,节点 6 与 7 整棵右子树完全没有被访问,函数直接返回true。顺带看一个反例走查
root = [2,1,3], k = 4(预期true,因为1 + 3 = 4):访问 2 时need = 2,seen为空未命中——注意此处若写成「先存后查」,seen会先变成{2},紧接着查need = 2立刻命中,错误地把节点 2 与它自己配成一对返回true(虽然本例最终答案碰巧也是true,但把k换成 4 而树只有单节点[2]时,先存后查会返回true,而正确答案是false)。继续正确流程:存入 2,访问 1,need = 3未命中,存入 1;访问 3,need = 1,seen = {2, 1}命中,返回true。
代码实现
class Solution {
// 只要在遍历过程中记录出现过的值,当前值 x 只需判断 k - x 是否见过即可。
public boolean findTarget(TreeNode root, int k) {
HashSet<Integer> seen = new HashSet<>();
return dfs(root, k, seen);
}
private boolean dfs(TreeNode node, int k, HashSet<Integer> seen) {
if (node == null) {
return false;
}
int need = k - node.val;
if (seen.contains(need)) {
return true;
}
seen.add(node.val);
return dfs(node.left, k, seen) || dfs(node.right, k, seen);
}
}
func findTarget(root *TreeNode, k int) bool {
// 只要在遍历过程中记录出现过的值,当前值 x 只需判断 k - x 是否见过即可。
seen := make(map[int]struct{})
return dfs653(root, k, seen)
}
func dfs653(node *TreeNode, k int, seen map[int]struct{}) bool {
if node == nil {
return false
}
if _, ok := seen[k-node.Val]; ok {
return true
}
seen[node.Val] = struct{}{}
return dfs653(node.Left, k, seen) || dfs653(node.Right, k, seen)
}
复杂度分析
- 时间复杂度:$O(n)$。凭什么:每个节点至多被访问一次,访问时只做一次减法、一次哈希查询和一次哈希插入,均摊都是常数;短路返回只会让实际访问的节点更少,不会更多。
- 空间复杂度:$O(n)$。凭什么:
seen最坏情况下要装下全部 $n$ 个节点值(当树中不存在任何满足条件的数对时),再加上递归调用栈的深度 $O(h)$;树退化成链时 $h = n$,两项都是 $O(n)$。这里不是 $O(1)$——哈希集合是这个解法的核心代价,也是它换取 $O(n)$ 时间所付的价钱。
关键点总结
- 先识别问题的骨架,再考虑容器的外壳。这道题贴着「二叉搜索树」的标签,但内核就是两数之和;把树看成一个「能被遍历一次的数的集合」之后,解法立刻明朗。遇到包装过的题目,先问「去掉数据结构的外衣,它到底在问什么」。
- 「先查后存」是补数哈希的铁律。查询发生在插入之前,才能保证配对的两个元素来自不同位置,这条规则在两数之和、和为 K 的子数组、四数相加等一整类题里完全通用。
- 不变量要能一句话说清「集合里此刻装的是什么」。这里是「DFS 序中排在当前节点之前的所有节点值」,有了这句话,正确性论证只剩一步:任意一对节点在 DFS 序里总有先后,后者必然能看见前者。
- 短路运算符是搜索类题目免费的剪枝。
dfs(left) || dfs(right)比先求两个布尔值再取或省下的可能是半棵树,写法上却毫无额外成本。- 不要用值相等去判断「是不是同一个节点」。BST 通常保证值互不相同,但一旦题目允许重复值,靠值判同就会误判;靠遍历顺序(先查后存)来保证不同,才是与数据无关的稳健写法。
- 面试视角:这题的标准追问是「能不能不用额外的哈希集合」。答案是可以——利用 BST 中序有序的性质,用两个 173. 二叉搜索树迭代器 式的栈分别做正序和逆序迭代,模拟 167. 两数之和 II 的对撞双指针,空间可降到 $O(h)$。主动说出「哈希法 $O(n)$ 空间但与树的形态无关,双指针法 $O(h)$ 空间但依赖 BST 有序性」这组权衡,比只写出一种解法更能拿分。
易错点总结
- 错误写法:先
seen.add(node.val)再seen.contains(k - node.val)→ 用例root = [1], k = 2,节点 1 把自己存进集合后立刻查到2 - 1 = 1,返回true;正确答案是false,因为只有一个节点,凑不出两个不同的节点。- 错误写法:在递归函数内部
HashSet<Integer> seen = new HashSet<>()→ 用例root = [5,3,6,2,4,null,7], k = 9,每层递归都拿到一个空集合,任何节点都看不到其他节点的值,恒返回false。- 错误写法:
seen.add(need)而不是seen.add(node.val)→ 用例root = [2,1,3], k = 100,访问 2 时把 98 存进集合,访问 1 时need = 99未命中但把 99 存入,访问 3 时need = 97未命中;虽然本例结果碰巧对,但换成root = [2,1,3], k = 5时访问 2 存入 3、访问 1 时need = 4未命中存入 4、访问 3 时need = 2未命中,最终返回false,而正确答案是true(2 + 3 = 5)。- 错误写法:
return dfs(node.left, ...) | dfs(node.right, ...)(用按位或代替逻辑或) → 逻辑结果正确但失去短路,用例是一棵十万节点的链且答案在最左侧时,右子树仍被完整遍历,白白多花一倍时间。- 错误写法:空节点返回
true→ 用例root = [1], k = 100,节点 1 的左右孩子都是空,各自返回true,结果直接短路成true;正确答案是false。递归基的返回值必须是「未找到」这一侧的中性元。- 错误写法:以为 BST 有序就写成「当前值大于
k时剪枝不再往右走」 → 用例root = [-5,-10,-1], k = -6,节点值允许为负,node.val > k时右子树里仍可能有能与负数配成k的值,剪枝会漏解返回false(正确答案是true,-5 + -1 = -6)。- 错误写法:把结果收集成中序数组后用
for i, for j两层枚举 → 用例是一棵一万节点的树时约 $5 \times 10^7$ 次比较,虽然勉强能过但完全没用上有序性,且面试中会被直接判定为没有识别出两数之和的模板。- 错误写法:中序遍历成数组后用双指针,却在
left == right时仍然判定成立 → 用例root = [3], k = 6,双指针初始left = right = 0,nums[0] + nums[0] = 6命中返回true;对撞双指针的循环条件必须是left < right,严格排除同一个元素。- 错误写法:Go 中用
map[int]bool却写成if seen[k-node.Val] { ... }并在别处误写seen[x] = false→ 一旦某个值被显式置为false,它虽然存在于 map 中但判定为未见过,造成漏解;用map[int]struct{}加逗号 ok 判定可以从类型上杜绝这类失误。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 1. 两数之和 | 简单 | 本题的原型,容器是数组且要求返回下标,哈希表存的是「值 → 下标」的映射 |
| 167. 两数之和 II - 输入有序数组 | 中等 | 输入已有序,改用对撞双指针做到 $O(1)$ 额外空间,是本题 $O(h)$ 解法的原理来源 |
| 170. 两数之和 III - 数据结构设计 | 简单 | 变成在线设计题,要在插入与查询两个操作之间权衡把代价放在哪一侧 |
| LCR 056. 两数之和 IV - 输入二叉搜索树 | 简单 | 与本题同题,可直接套用同一份代码 |
| 173. 二叉搜索树迭代器 | 中等 | 用栈把中序遍历拆成可暂停的迭代器,是把本题空间压到 $O(h)$ 的关键组件 |
| 230. 二叉搜索树中第 K 小的元素 | 中等 | 真正依赖中序有序性,靠计数提前终止,与本题「有序性可有可无」形成对照 |
| 98. 验证二叉搜索树 | 中等 | 依赖的是上下界约束而非中序序列,代表 BST 题的另一条主线 |
| 501. 二叉搜索树中的众数 | 简单 | 利用中序序列里相等元素必然相邻的性质做流式统计,考的是遍历中维护状态 |