LeetCode 170. 两数之和 III - 数据结构设计
题目描述
题意分析
要设计一个支持两种操作的容器:
add(number)往里塞一个整数,find(value)回答「容器里是否存在两个不同位置的元素,它们的和恰好等于value」。「两个不同位置」这五个字是全题的关键。它不要求两个数的值不同,只要求它们是两次不同的
add塞进来的。所以当value恰好是某个数的两倍时,必须确认这个数至少被添加过两次——只出现一次的话,它不能和自己配对。这是本题唯一的语义陷阱,也是它与 1 题最本质的差别。与 1 题的第二个差别是:这里没有一次性给定的数组,而是在线的操作流。数据随时会追加,查询随时会到来,无法在某个时刻做一次性的全量预处理。设计题的评价标准也因此从「总复杂度」变成了「每个操作各自的复杂度」,以及两者之间的取舍。
题面没有承诺
add与find的调用比例,也没有承诺数值范围很小,所以设计时要在心里先假设「两种操作都可能很频繁」,再决定把成本压在哪一边。边界包括:容器为空时调用
find;只加了一个元素时查询它的两倍;重复添加同一个数;以及value恰好等于某个元素的两倍但该元素只有一份。
解法:哈希表维护状态映射
核心思路
最朴素的实现是用一个列表保存所有添加过的数,
find时双重循环枚举所有下标对。add是 $O(1)$,但find是 $O(n^2)$,查询稍多就撑不住。瓶颈在于「找配对」被当成了盲目枚举,而配对关系其实是确定的:给定value和其中一个数x,另一个数只能是value - x,没有第二种可能。于是问题从「枚举两个数」降为「枚举一个数,再查另一个在不在」。这一步把 $O(n^2)$ 压成了 $O(n)$ 乘以单次查找的成本,而哈希表让单次查找是均摊 $O(1)$。
但只用「是否存在」的集合还不够,因为要处理自我配对。集合无法区分「这个数出现过一次」和「出现过多次」,而这两种情况在
value = 2x时结论完全相反。所以容器选哈希计数表:键是数值,值是它被添加的次数。不变量是:计数表在任意时刻都精确记录了「每个数值被
add过多少次」。add只做一次自增,保证不变量维持;find只读不写,不破坏不变量。
find(value)的逻辑是遍历计数表的每个键x,令y = value - x:若y != x,只需检查y是否作为键存在——两个不同的数值必然来自两次不同的添加;若y == x,则必须检查x的计数是否达到 2,否则就是「拿自己和自己凑」的非法配对。找到任何一组即可返回真,遍历完都没找到则返回假。遍历的是键的集合而不是所有添加过的元素,所以
find的成本与不同数值的个数成正比,而不是与总添加次数成正比。重复添加同一个数不会拖慢查询,这是选计数表而非列表的额外收益。
解题步骤
- 构造函数里初始化一张空的哈希计数表。键为数值、值为出现次数。用计数而不是布尔标记,是为了让自我配对可判定;这个决定必须在设计之初就做出,事后补救要改动所有接口。
add(number)把对应计数加一。取不到就当作 0 再加一。这是唯一的写入路径,保证计数表始终与添加历史一致。整个操作是均摊 $O(1)$,把成本全部留给了查询侧。find(value)遍历计数表的所有键。对每个键x算出配对值y = value - x。这里遍历键而不是遍历原始序列,避免了重复元素带来的无谓重复检查。- 分两种情况判定。
y == x时要求该键的计数至少为 2,因为需要两个不同位置的元素;y != x时只要y在表中即可,此时两者必然来自不同的添加。这两个分支必须都写全,只写其中一个都会漏解或误判。- 一旦命中立即返回真,全部遍历完仍未命中返回假。提前返回能让大多数查询在扫到前几个键时就结束。
走一遍一组操作:依次
add(1)、add(3)、add(5),然后find(4)、find(7)。三次添加后计数表是
{1: 1, 3: 1, 5: 1}。find(4):取键 1,y = 3,y != x且 3 在表中,返回真——对应元素 1 与 3。find(7):取键 1,y = 6不在表中;取键 3,y = 4不在表中;取键 5,y = 2不在表中;遍历结束返回假。再走一组体现自我配对的操作:
add(3)、find(6)、add(3)、find(6)。第一次
add(3)后计数表是{3: 1}。find(6):取键 3,y = 3与x相等,检查计数为 1 未达到 2,不算命中;遍历结束返回假——正确,因为只有一个 3,不能和自己配对。第二次add(3)后计数表是{3: 2}。再find(6):取键 3,y = 3相等,计数为 2 满足条件,返回真——正确,两个 3 来自两次不同的添加。若把自我配对的分支写成「只要
y在表中就返回真」,第一次find(6)就会错误地返回真;若干脆不处理这个分支、遇到y == x就跳过,第二次find(6)又会错误地返回假。两个方向的错误各对应一半用例,必须都覆盖。
代码实现
import java.util.HashMap;
import java.util.Map;
class TwoSum {
// 用计数而非布尔标记,才能判定「两个相同数值」的配对。
private Map<Long, Integer> countMap;
public TwoSum() {
countMap = new HashMap<>();
}
public void add(int number) {
long key = number;
countMap.put(key, countMap.getOrDefault(key, 0) + 1);
}
public boolean find(int value) {
for (Map.Entry<Long, Integer> entry : countMap.entrySet()) {
long num = entry.getKey();
long complement = (long) value - num;
if (complement == num) {
// 自我配对必须有两份。
if (entry.getValue() >= 2) {
return true;
}
} else {
if (countMap.containsKey(complement)) {
return true;
}
}
}
return false;
}
}
type TwoSum struct {
// 用计数而非布尔标记,才能判定「两个相同数值」的配对。
countMap map[int64]int
}
func Constructor() TwoSum {
return TwoSum{countMap: make(map[int64]int)}
}
func (t *TwoSum) Add(number int) {
t.countMap[int64(number)]++
}
func (t *TwoSum) Find(value int) bool {
for num, cnt := range t.countMap {
complement := int64(value) - num
if complement == num {
// 自我配对必须有两份。
if cnt >= 2 {
return true
}
} else {
if _, ok := t.countMap[complement]; ok {
return true
}
}
}
return false
}
复杂度分析
- 时间复杂度:
add均摊 $O(1)$,只做一次哈希查找与一次自增;find是 $O(d)$,$d$ 为当前不同数值的个数,最坏等于添加次数 $n$。注意find的成本与「不同值的个数」而非「总添加次数」挂钩,反复添加同一个数不会让查询变慢。- 空间复杂度:$O(d)$,计数表中每个不同数值占一个条目。相比保存全部元素的列表,重复元素被压缩成一个计数,空间更省。
关键点总结
- 「两数之和」的通用降维手法是固定一个、反查另一个:因为配对值由目标唯一确定,枚举一个数就够了。这条思路从 1 题一直贯穿到 15、18、454。
- 容器选计数表而非集合,唯一目的是支撑自我配对的判定。设计数据结构时,「需要区分出现一次还是多次」是选择计数结构的典型信号。
- 遍历键集合而不是原始序列,让重复元素不影响查询成本。这是把「去重」这件事交给数据结构承担,而不是在算法里临时处理。
- 自我配对与异值配对必须写成两个互斥分支,任何一支缺失都会在一半用例上出错,而且这两类用例通常一个返回真、一个返回假,靠单个测试很难同时暴露。
易错点总结
- 错误写法:用
Set代替计数表。用例:add(3)后find(6)→ 集合里有 3,配对值也是 3,判定为存在并返回真,正确答案是假,因为只有一个 3。- 错误写法:不区分自我配对,一律写成
containsKey(value - num)。用例:add(0)后find(0)→ 配对值 0 在表中,返回真,正确答案是假。- 错误写法:遇到
complement == num就直接跳过。用例:add(3)、add(3)后find(6)→ 唯一能命中的分支被跳过,返回假,正确答案是真。- 错误写法:自我配对时判断条件写成
> 2。用例:add(3)、add(3)后find(6)→ 计数为 2 不满足严格大于,返回假,正确答案是真。- 错误写法:
add时用put(number, 1)而不是自增。用例:add(3)、add(3)后find(6)→ 计数被覆盖成 1,自我配对判定失败,返回假,正确答案是真。- 错误写法:把元素存进列表并在
find里做双重循环。用例:连续 $10^4$ 次add后再做 $10^4$ 次find→ 单次查询 $10^8$ 量级,必然超时;思路正确但复杂度不过关。- 错误写法:把列表排序后用双指针查找,但每次
find都重新排一次。用例:$10^4$ 次交替的add与find→ 每次查询都付出 $O(n \log n)$ 的排序成本,比线性扫描还慢。- 错误写法:
find中先返回假再继续遍历(把return false写在循环体内)。用例:add(1)、add(3)后find(4)→ 第一个键若不命中就立刻返回假,永远只检查一个键,正确答案是真。- 错误写法:用 32 位
int直接计算value - num。用例:依次加入1与Integer.MAX_VALUE,再查找Integer.MIN_VALUE;补数计算会溢出成Integer.MAX_VALUE,错误返回真,但两数的数学和是 2147483648。代码把键和补数提升到 64 位,避免溢出伪造配对。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 1. 两数之和 | 简单 | 一次性数组且要求返回下标,可以边遍历边写入表,无需处理在线追加 |
| 167. 两数之和 II - 输入有序数组 | 中等 | 有序前提下用双指针做到 $O(1)$ 额外空间,展示排序如何替代哈希 |
| 653. 两数之和 IV - 输入二叉搜索树 | 简单 | 数据装在 BST 里,可中序展开后双指针,也可边遍历边查集合 |
| 1099. 小于 K 的两数之和 | 简单 | 目标从「等于」变成「小于且最大」,哈希失效,必须排序后双指针 |
| 15. 三数之和 | 中等 | 固定一个数后转化为两数之和,难点转移到排序去重与指针跳过重复值 |
| 454. 四数相加 II | 中等 | 分成两半各自打表再对撞,是「固定一半查另一半」思路的规模化应用 |
| 705. 设计哈希集合 | 简单 | 要求手写哈希表本身,考点在冲突处理与扩容而非上层算法 |
| 703. 数据流中的第 K 大元素 | 简单 | 同为在线数据流的设计题,用小顶堆维护候选,同样要权衡两个接口的复杂度 |