两数之和(Two Sum)是算法学习中最经典的入门题目之一,也是各大技术面试中的高频考题。这道题看似简单,却蕴含着算法设计中时间复杂度和空间复杂度权衡的核心思想。
给定一个整数数组nums和一个目标值target,需要在数组中找到两个数,使它们的和等于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 []
这种解法的时间复杂度是O(n²),空间复杂度是O(1)。对于小规模数据(n<1000)尚可接受,但当数据量增大时性能会急剧下降。
提示:在面试中,即使你首先想到的是暴力解法,也应该主动指出其效率问题,并尝试优化。这展示了你的算法思维。
哈希表(Hash Table)是一种通过哈希函数将键映射到值的数据结构,提供平均O(1)时间复杂度的查找性能。在Python中,字典(dict)就是哈希表的实现。
利用哈希表,我们可以将寻找target - x的时间复杂度从O(n)降到O(1):
python复制class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
hashmap = {}
for i, num in enumerate(nums):
complement = target - num
if complement in hashmap:
return [hashmap[complement], i]
hashmap[num] = i
return []
当数组中有重复元素时,如nums = [3,3], target = 6,我们的哈希表解法依然有效。因为第二个3会找到第一个3的补数(6-3=3),而第一个3已经被存入哈希表。
虽然题目保证有解,但在实际工程中应该考虑无解的情况。可以在最后添加raise ValueError("No two sum solution")。
对于极大数组(如n>10⁶),哈希表解法仍然高效,但要注意Python字典的内存开销。在极端内存受限环境下,可能需要考虑其他方法。
这是两数之和的扩展版本:在数组中找到三个数,使它们的和为target。解法思路类似但更复杂,通常需要排序加双指针。
如果题目改为返回所有满足条件的索引对,解法需要稍作修改:
python复制def twoSumAll(nums, target):
hashmap = {}
result = []
for i, num in enumerate(nums):
complement = target - num
if complement in hashmap:
for j in hashmap[complement]:
result.append([j, i])
if num not in hashmap:
hashmap[num] = []
hashmap[num].append(i)
return result
如果输入数组已经排序,可以使用双指针法,空间复杂度可降至O(1):
python复制def twoSumSorted(nums, target):
left, right = 0, len(nums)-1
while left < right:
current_sum = nums[left] + nums[right]
if current_sum == target:
return [left, right]
elif current_sum < target:
left += 1
else:
right -= 1
return []
在数据库系统中,类似的思想可用于优化JOIN操作。建立适当的索引相当于构建哈希表,可以大幅提高查询效率。
Web开发中,缓存系统经常需要快速判断某个键是否存在,哈希表的高效查找特性使其成为理想的底层数据结构。
在量化交易中,快速匹配买卖订单(如限价单)可以借鉴两数之和的思想,将订单价格作为键存储在哈希结构中。
初学者常犯的错误是返回的索引顺序不正确。记住:哈希表中存储的是之前遍历过的元素,所以应该先返回hashmap[complement],再返回当前索引i。
确保不要使用同一个元素两次。在暴力解法中,内层循环应从i+1开始;在哈希表解法中,先检查补数再存入当前元素。
Python是动态类型语言,但要确保target和数组元素是同类型(通常都是int)。混合类型可能导致意外错误:
python复制nums = ['2','7','11','15'] # 字符串
target = 9 # 整数
# 会导致类型错误
让我们比较两种解法在不同数据规模下的表现:
| 数据规模(n) | 暴力解法时间(ms) | 哈希表解法时间(ms) |
|---|---|---|
| 100 | 0.5 | 0.1 |
| 1,000 | 45 | 0.8 |
| 10,000 | 4500 | 8 |
| 100,000 | 超时(>60s) | 80 |
注意:实际测试时应使用timeit模块,并考虑多次运行取平均值
对于极大数组,可以考虑将数组分割后并行处理。每个线程处理一部分数据,最后合并结果。
如果需要对同一个数组进行多次查询,可以预先建立完整的哈希表,后续查询只需O(1)时间:
python复制class TwoSum:
def __init__(self, nums):
self.hashmap = {}
for i, num in enumerate(nums):
if num not in self.hashmap:
self.hashmap[num] = []
self.hashmap[num].append(i)
def query(self, target):
for num in self.hashmap:
complement = target - num
if complement in self.hashmap:
if complement == num:
if len(self.hashmap[num]) >= 2:
return self.hashmap[num][:2]
else:
return [self.hashmap[num][0], self.hashmap[complement][0]]
return []
如果内存是瓶颈,可以牺牲一些时间效率来减少内存使用:
python复制def twoSumMemoryOptimized(nums, target):
for i in range(len(nums)):
complement = target - nums[i]
for j in range(i+1, len(nums)):
if nums[j] == complement:
return [i, j]
return []
这个版本的空间复杂度是O(1),但时间复杂度回到O(n²)。适用于内存极度受限但可以接受较长运行时间的场景。
enumerate是Python中同时获取索引和值的优雅方式,比range(len(nums))更Pythonic:
python复制# 不推荐
for i in range(len(nums)):
num = nums[i]
# 推荐
for i, num in enumerate(nums):
Python字典的查找操作平均时间复杂度是O(1),但在最坏情况下(大量哈希冲突)可能退化到O(n)。不过在实际应用中很少遇到。
Python 3.6+支持类型注解,可以增加代码可读性:
python复制from typing import List
def twoSum(nums: List[int], target: int) -> List[int]:
在实际项目中,建议先编写测试用例再实现功能:
python复制import unittest
class TestTwoSum(unittest.TestCase):
def test_basic(self):
self.assertEqual(twoSum([2,7,11,15], 9), [0,1])
self.assertEqual(twoSum([3,2,4], 6), [1,2])
self.assertEqual(twoSum([3,3], 6), [0,1])
def test_no_solution(self):
with self.assertRaises(ValueError):
twoSum([1,2,3], 7)
if __name__ == '__main__':
unittest.main()
对于关键路径上的算法,应该添加性能监控:
python复制import time
def twoSum_with_timing(nums, target):
start = time.perf_counter()
result = twoSum(nums, target)
elapsed = time.perf_counter() - start
print(f"twoSum took {elapsed:.6f} seconds")
return result
面对复杂问题时,尝试将其分解为已解决的简单问题。例如,三数之和可以转化为多个两数之和问题。
哈希表解法展示了典型的空间换时间思想。在算法设计中,这种权衡非常常见。
养成考虑边界条件的习惯:
为了巩固两数之和的解法,可以尝试以下类似题目:
在技术面试中,关于两数之和的常见追问包括:
在电商平台中,可能需要找出两件商品的总价正好等于用户预算:
python复制def find_product_pair(products, target_price):
"""products是商品列表,每个商品有price和id属性"""
price_map = {}
for product in products:
complement = target_price - product.price
if complement in price_map:
return [price_map[complement], product.id]
price_map[product.price] = product.id
return None
在游戏开发中,可能需要检查玩家是否有两个可以合成高级物品的基础物品:
python复制def check_combination(items, recipe):
"""recipe是字典,包含所需物品类型和数量"""
item_counts = {}
for item in items:
item_counts[item.type] = item_counts.get(item.type, 0) + 1
for item_type, needed in recipe.items():
complement = item_type # 假设合成需要两个相同物品
if item_counts.get(complement, 0) < needed:
return False
return True
为了更直观地理解哈希表解法,可以想象:
这种"留纸条"的方法正是哈希表解法的精髓所在。
从数学角度看,两数之和问题可以表述为:
给定集合S和目标值t,寻找x,y∈S使得x+y=t
哈希表解法实际上是在遍历时构建了一个映射f:S→index,使得对于每个y,我们可以快速检查t-y是否在f的定义域中。
虽然我们主要讨论Python实现,但了解其他语言的实现也很有帮助:
java复制public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
if (map.containsKey(complement)) {
return new int[] { map.get(complement), i };
}
map.put(nums[i], i);
}
throw new IllegalArgumentException("No two sum solution");
}
javascript复制function twoSum(nums, target) {
const map = new Map();
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i];
if (map.has(complement)) {
return [map.get(complement), i];
}
map.set(nums[i], i);
}
return [];
}
cpp复制vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> map;
for (int i = 0; i < nums.size(); i++) {
int complement = target - nums[i];
if (map.find(complement) != map.end()) {
return {map[complement], i};
}
map[nums[i]] = i;
}
return {};
}
两数之和问题最早出现在编程竞赛和算法教材中,后来成为LeetCode等平台的基础题目。它的解法演变反映了算法设计的进步:
两数之和作为算法入门题目具有重要教学价值:
在实际编程和面试中,关于两数之和我有几点深刻体会:
一个容易忽略的优化点是:在构建哈希表时,可以同时检查当前元素的补数是否存在,这样只需要一次哈希表查找:
python复制def twoSum_optimized(nums, target):
hashmap = {}
for i, num in enumerate(nums):
if num in hashmap: # 当前元素是否是他人的补数
return [hashmap[num], i]
hashmap[target - num] = i # 存储补数和当前索引
return []
这种写法减少了每次迭代中的哈希表查找次数,在实际测试中可能有轻微性能提升,但代码可读性稍差,需要根据场景权衡。