当前:LC1 · 两数之和 · 首次出现于 Day 2 · 路径:顶栏「56天打卡」→ 点击 LC 题号 → 逐题动画

LC1 · Two Sum · 数组 / 哈希

两数之和:一遍扫描,一个记忆

把「找一对」翻转成「认一个见过的数」,O(n²) 的重复劳动就此消失。

高效解法只做一件事:每扫过一个数就把它「记住」,然后用一次 O(1) 的查找回答「它的补数我见过吗」。全程一遍扫描,时间 O(n),空间 O(n)。

时间 O(n)空间 O(n)结论先行 · 全文约 6 节
导读

想象你在翻一本旧电话簿,想找出两个号码,使它们的和等于某个目标数字。你会一页一页地把两个号码两两相加去碰运气,还是边翻边把见过的号码记下来,等遇到「恰好能凑成目标」的那个时立刻认出来?

前者是每个新手的第一直觉——它正确,但会把同样的加法重复成千上万次。后者只需要一遍扫描:每到一个数,只问一句「它的补数,我见过吗」,用一张 O(1) 的查找表回答。

这篇文章会带你重走这条从直觉到最优的路:先看清暴力解法为什么重复,再完成一次关键的视角翻转,最后落成五行 Go 代码。读完之后你会理解,为什么高手看到这类「两两相加」的题,第一反应不是去配对,而是去建一张记忆表。

先明确问题

给你一个整数数组 nums 和一个目标值 target,请你在数组里找出两个下标 i 和 j(i ≠ j),使得 nums[i] + nums[j] 正好等于 target,然后返回这两个下标。

以示例为例:nums = [2, 7, 11, 15],target = 9。因为 2 + 7 = 9,所以返回 [0, 1]。

这道题是 LeetCode 的第 1 题,也是几乎所有人的第一道题。它足够简单,因此几乎所有错误的认识都暴露得很充分——尤其是「暴力枚举够用」的错觉。

第一直觉:两两配对

最直接的想法是固定第一个数,把它和后面每一个数都加一次:2+7、2+11、2+15,都不对就固定第二个数,继续 7+11、7+15……直到找到一组相加等于 target 的。

在示例里,第一次比较 2 + 7 = 9 就命中了。看起来快得惊人——但请注意,命中的是运气。换一组不那么配合的数据:[3, 1, 4, 2, 7],target = 6。3 要依次和 1、4、2、7 各加一次:3+1=4、3+4=7、3+2=5、3+7=10,全部落空。

更糟的在后面。轮到 1 的时候,它又要和 4、2、7 各加一次;轮到 4,又要和 2、7 各加一次。每一轮比较的结果都被直接丢弃,从不复用。n 个数一共要做 n(n−1)/2 次比较——这就是 O(n²)。

暴力两两配对:每一对都从头加起,命中靠运气,落空要重来
2
[0]
7
[1]
11
[2]
15
[3]
2 + 7 = 9 ✓ 命中

从第一个数开始,依次向后相加。

一个视角翻转:补数

暴力之所以重复,是因为它一直在问「我和谁相加等于 target」——这是一个从当前数出发、向后搜寻的问题,每次都要重新打量所有后面的数。

现在把这个问题翻转过来:x + y = target,等价于 y = target − x。也就是说,当我手上是 x 时,我真正要找的只有一个确切的数 y,它被 x 唯一决定。我把它叫做 x 的「补数」。

于是问题从「找一对」变成了「认一个见过的数」。2 的补数是 7,7 的补数是 2,11 的补数是 −2,15 的补数是 −6。我需要回答的问题,从「数组里有没有另一个数能和我凑成 9」,变成了「我见过的数里,有没有这个补数」。

视角「找一对」是 O(n²) 的循环;「认一个见过的数」是 O(1) 的查询。只换一个问法,复杂度就被写进了问题的形状里。
视角翻转:每个数只关心它的补数,不再关心其他任何人
2
need = 92 = 7
7
need = 97 = 2
11
need = 911 = -2
15
need = 915 = -6

每个数只关心它的补数——一个由它唯一决定的数。

一遍扫描,加一个记忆

现在把「补数」和「记忆」拼起来:从左到右扫过数组,每扫到一个数 x,先查补数 target − x 在不在记忆里。在,就立刻返回答案;不在,就把 x 连同它的下标存进记忆,继续往后走。

以 [3, 1, 4, 2, 7]、target = 6 为例。扫到 3,补数是 3,记忆是空的,存入「3 → 0」;扫到 1,补数是 5,没见过,存入「1 → 1」;扫到 4,补数是 2,没见过,存入「4 → 2」;扫到 2,补数是 4——见过!它在下标 2。返回 [2, 3]。

每个数只做两件事:一次 O(1) 的查询,至多一次 O(1) 的存入。整个数组一遍扫完,总时间 O(n)。记忆表最坏情况下存下所有数,空间 O(n)。

为什么保证不漏?扫到第 i 个数时,记忆里已经存了前 i−1 个数。如果答案的补数存在于数组中,它必然落在前 i−1 个里,也必然被这次的查询找到;查不到,说明它尚未出现,我们把当前数存进去继续。每一步都不漏,归纳地,整个数组都不漏。

一遍扫描 + 一个记忆:查不到就存下自己
3
[0] 当前
1
[1]
4
[2]
2
[3]
7
[4]
记忆当前补数 need = 3
(空)
补数 3 不在记忆里 → 存入「30

顺序的坑:先查,再存

上面那句「先查补数,再存自己」的顺序不是随口说的,它是一个真正的坑。

假设输入是 [2],target = 4。如果先把当前数存进记忆,再查补数:存入「2 → 0」,然后查询补数 4 − 2 = 2——命中了,但它命中的是「自己」。返回 [0, 0],两个下标相同,而题目要求 i ≠ j。错。

反过来,先查补数再存自己:此时记忆还是空的,查询补数 2 落空,然后把「2 → 0」存入。这才安全。规则一句话:记忆里永远只装「我已经真正看过的数」,而当前的自己,在查询的这一刻还不算「看过」。

先存再查:命中的是自己
2
[0] 当前
need = 42 = 2
→ 查记忆
记忆
(空)
还没存:记忆为空 → 查询补数 2

复杂度,与它可以推广到哪里

时间 O(n):一遍扫描,每个数一次查询、至多一次存入。空间 O(n):记忆表最坏存下全部元素。这已经是最优——读入整个数组本身就要 O(n)。

这套「用补数 + 记忆消灭重复比较」的哲学可以走得很远。三数之和可以固定一个数,把剩下的问题降维成两数之和;判断数组里的最长连续序列,可以先问「我的前驱在不在」再决定是否扩张;更普遍的「记忆化」思想——把算过的结果存起来,下次直接取——也是同一种味道:用空间换掉重复计算。

直觉O(n²) 的重复,多半能用一个 O(n) 的记忆消掉。先问自己:我是不是在重复做已经做过的事?

五行 Go

solution.goGo
func twoSum(nums []int, target int) []int {
seen := map[int]int{}
for i, x := range nums {
if j, ok := seen[target-x]; ok {
return []int{j, i}
}
seen[x] = i
}
return nil
}

1seen 是一张「值 → 下标」的记忆表,一开始是空的。

2每一轮,i 是当前下标,x 是当前值。

3先查补数:target − x 在不在 seen 里。在,说明补数在前面的某处 j 出现过,直接返回 [j, i]。

4不在,就把当前值存进记忆:seen[x] = i。先查后存,正是上一节的坑。

总结

先查补数,再存自己。

  • 暴力两两配对是 O(n²),因为每一次比较的结果都被丢弃,从不复用。
  • 补数视角把「找一对」翻转成「认一个见过的数」,把问题写进了 O(1) 查询的形状里。
  • 一遍扫描 + 一张记忆表:查不到就存下自己,每个数至多查一次、存一次。
同族题目
LC167两数之和 II(有序数组,双指针)LC15三数之和(固定一个 + 两数之和)LC454四数相加 II(分组 + 哈希计数)