
两数之和 (LeetCode #1)
题目
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案,并且你不能使用两次相同的元素。
示例:
输入:nums = [2, 7, 11, 15], target = 9
输出:[0, 1]
解释:因为 nums[0] + nums[1] == 9,返回 [0, 1]
解法一:最常规的思路
拿到这道题,直觉反应是:把所有可能的两个数组合都试一遍 。对于每个位置 i,看看后面每个位置 j,检查 nums[i] + nums[j] 是否等于 target。
换句话说,就是两层循环暴力枚举。
python
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
if nums[i] + nums[j] == target:
return [i, j]
return [-1, -1]
时间复杂度:O(n²) ,空间复杂度:O(1)
思路有了,逻辑也对。但这个做法有没有可以省掉的地方?来看一个最坏情况------
假设 nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],target = 19(答案是最后两个数 9 和 10,下标 8 和 9)。暴力法的执行过程:
| i | j 的范围 | 检查次数 | 这些检查里,有多少是重复的? |
|---|---|---|---|
| 0 | 1~9 | 9 次 | i=1 时还要再检查 2~9 |
| 1 | 2~9 | 8 次 | i=2 时还要再检查 3~9 |
| 2 | 3~9 | 7 次 | ... |
| ... | ... | ... | 每一轮都在重复前面的工作 |
| 7 | 8~9 | 2 次 | 最后才找到答案 |
| 8 | 9 | 1 次 | 找到答案 |
总检查次数:9 + 8 + 7 + 6 + 5 + 4 + 3 + 2 + 1 = 45 次
对于长度为 n 的数组,最坏情况下要检查 n(n-1)/2 个数对。
如果数组有一万个元素,暴力法要检查约 50,000,000 次。
问题就出在这里:每次都在重新找配对,却从不记住已经见过的数。
解法二:哈希表 ------ 换一种问法
那能不能换一种问法,让查找变成 O(1)?
与其问:
nums[i] + nums[j] == target ?不如问:
target - nums[i]这个数,我之前见过吗?
这个转变彻底改变了问题的性质:
- 原来要遍历后面所有数来"找配对",O(n)
- 现在只需要查一下哈希表,O(1) 就知道答案
按这个思路来写,会变成这样------
python
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
mem = dict()
for idx, num in enumerate(nums):
if target - num in mem:
return [mem[target - num], idx]
mem[num] = idx
return [-1, -1]
这段代码已经达到最优了。来看看关键设计------
"先查再存"的顺序:
- 先看
target - num是否在表中(找配对) - 再把当前数存入表中(留给后面的数来配对)
这样做天然避免了"用同一个元素两次"的问题,因为当前数还没存进去。
也可以这样写,把 target - num 提取出来更清晰:
python
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
mem = dict()
for idx, num in enumerate(nums):
complement = target - num
if complement in mem:
return [mem[complement], idx]
mem[num] = idx
return [-1, -1]
时间复杂度:O(n) ,空间复杂度:O(n)
解法三:双指针(排序后)
如果数组已经排序,或者允许排序,还有一种思路也值得知道------
python
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
sorted_nums = sorted(enumerate(nums), key=lambda x: x[1])
left, right = 0, len(sorted_nums) - 1
while left < right:
current_sum = sorted_nums[left][1] + sorted_nums[right][1]
if current_sum == target:
return [sorted_nums[left][0], sorted_nums[right][0]]
elif current_sum < target:
left += 1
else:
right -= 1
return [-1, -1]
这个方法需要 O(n log n) 时间排序,还要 O(n) 空间保存原始下标。在本题场景下不如哈希表解法,但作为"有序数组求两数之和"的标准解法,理解它的思路是有价值的。
复杂度对比
| 解法 | 时间复杂度 | 空间复杂度 | 一万个元素时的操作数 |
|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | ~50,000,000 次 |
| 哈希表 ⭐ | O(n) | O(n) | ~10,000 次 |
| 双指针 | O(n log n) | O(n) | ~130,000 次 |
上面说的重复问题,在这个数据规模下意味着:从 5000 万次运算降到 1 万次,差了 5000 倍。
这道题表面上是某个特定问题的解法,但真正值得带走的是一套通用的分析思路------
这道题教会了我们什么
核心思想一:空间换时间(Space-Time Tradeoff)
哈希表的本质是用额外的空间来记录历史信息,从而避免重复计算 。
这不是"偷懒",而是一种系统性的设计选择------当时间成本远高于空间成本时,多花一点内存来大幅降低时间复杂度,是非常值得的。
在本题里,哈希表用 O(n) 的额外空间,把时间从 O(n²) 降到 O(n)。当 n 足够大时,这个交换是绝对划算的。
核心思想二:改变问法,改变复杂度
很多时候,问题本身没有错,错的是我们问问题的方式。
- 暴力法在问:"哪两个数相加等于 target?"------这需要枚举所有组合。
- 哈希表法在问:"target 减去当前数,之前出现过吗?"------这只需要一次查找。
当你发现某个操作反复出现且代价很高,问问自己:能不能换一种问法,让这个操作变成 O(1)?
实际应用场景
场景一:数据库索引
没有索引时,查询需要全表扫描 O(n);建了哈希索引后,查询变成 O(1)。本质和这题一模一样------用额外的存储空间(索引),避免每次都从头找。
场景二:LRU 缓存
LRU 缓存需要在 O(1) 时间内完成「查找某个 key 是否存在」和「更新某个 key 为最近使用」。只靠双向链表做不到 O(1) 查找,必须配合哈希表------用空间换时间,这正是本题思想的延伸。
真正有力的总结是:算法不只是写代码,而是学会如何"重新定义问题"。