给定一个整数数组 nums 和一个目标值 target,找出数组中和为 target 的两个元素,返回它们的下标。题目保证恰好有一个答案,同一个位置的元素不能用两次。这道题的核心是把"找另一半"从逐个比较换成哈希表查找。本文用真实测量对比两种写法在不同规模下的耗时,验证"先查后存"这个顺序为什么不能换,最后把哈希表的每一步做成可以逐帧回放的演示。
完整的题目描述和约束见官方页面(链接在文末)。下面是本文测试用到的前几组用例,输出是暴力解(两层循环)算出来的基准结果;所有用例一共 8 组,包含负数、零、重复值和接近 32 位上限的数:
输入 [[2,7,11,15],9] 输出 [0,1] 输入 [[3,2,4],6] 输出 [1,2] 输入 [[3,3],6] 输出 [0,1] 输入 [[-1,-2,-3,-4,-5],-8] 输出 [2,4] 输入 [[0,4,3,0],0] 输出 [0,3] 输入 [[1000000000,294967296,-1,5],1294967296] 输出 [0,1]
暴力解是两层循环,检查每一对 (i, j)。把答案放在数组最后两个位置(最坏情况),分别测量暴力解和哈希表解法在不同长度下的真实耗时,每个规模取三次中最快的一次:
数组长度 n | 暴力解 | 本文解法
1000 | 14.391 ms | 0.061 ms
5000 | 363.299 ms | 0.290 ms
20000 | 6081.193 ms | 1.197 ms
100000 | (未测,太慢) | 6.549 ms数组长度从 1000 增加到 5000(5 倍),暴力解耗时从 14.4ms 涨到 376.3ms,增长约 26 倍;从 5000 增加到 20000(4 倍),从 376.3ms 涨到 5973.7ms,增长约 16 倍。长度乘以 k,耗时乘以接近 k²,符合 O(n²)。哈希表解法从 1000 到 5000 增长约 4.7 倍,从 5000 到 20000 增长约 4.1 倍,从 20000 到 100000(5 倍)增长约 5.3 倍,接近线性。100000 长度的暴力解没有测,按上面的趋势要跑几分钟。
从左到右扫描,用一个哈希表记下"已经扫过的数及其下标"。对每个 x,先看 target - x 是否已经在表里:在,就找到了答案;不在,再把 x 存进去。下面是 Python、Java、Go、JavaScript、C++ 五种语言的实现,每一种都真实编译运行,并且逐组与暴力解对比过:
Python · 解法代码def solve(nums, target): seen = {} # 值 -> 下标:已经扫过的数 for i, x in enumerate(nums): need = target - x if need in seen: # 之前见过 need,答案就是 (seen[need], i) return [seen[need], i] seen[x] = i # 先查再存:同一个元素不会和自己配对 return []
$ python3 solution.py # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON [[2,7,11,15],9] → [0,1] [[3,2,4],6] → [1,2] [[3,3],6] → [0,1] [[-1,-2,-3,-4,-5],-8] → [2,4] [[0,4,3,0],0] → [0,3] [[1000000000,294967296,-1,5],1294967296] → [0,1]
Java · 解法代码class Solution { public int[] twoSum(int[] nums, int target) { Map<Integer, Integer> seen = new HashMap<>(); // 值 -> 下标 for (int i = 0; i < nums.length; i++) { Integer j = seen.get(target - nums[i]); // 先查 if (j != null) return new int[]{j, i}; seen.put(nums[i], i); // 后存 } return new int[0]; } }
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl [[2,7,11,15],9] → [0,1] [[3,2,4],6] → [1,2] [[3,3],6] → [0,1] [[-1,-2,-3,-4,-5],-8] → [2,4] [[0,4,3,0],0] → [0,3] [[1000000000,294967296,-1,5],1294967296] → [0,1]
Go · 解法代码func twoSum(nums []int, target int) []int { seen := make(map[int]int, len(nums)) // 值 -> 下标 for i, x := range nums { if j, ok := seen[target-x]; ok { // 先查 return []int{j, i} } seen[x] = i // 后存 } return nil }
$ go build -o bin . && ./bin < cases.jsonl [[2,7,11,15],9] → [0,1] [[3,2,4],6] → [1,2] [[3,3],6] → [0,1] [[-1,-2,-3,-4,-5],-8] → [2,4] [[0,4,3,0],0] → [0,3] [[1000000000,294967296,-1,5],1294967296] → [0,1]
JavaScript · 解法代码function twoSum(nums, target) { const seen = new Map(); // 值 -> 下标 for (let i = 0; i < nums.length; i++) { const j = seen.get(target - nums[i]); // 先查 if (j !== undefined) return [j, i]; seen.set(nums[i], i); // 后存 } return []; }
$ node run.js < cases.jsonl [[2,7,11,15],9] → [0,1] [[3,2,4],6] → [1,2] [[3,3],6] → [0,1] [[-1,-2,-3,-4,-5],-8] → [2,4] [[0,4,3,0],0] → [0,3] [[1000000000,294967296,-1,5],1294967296] → [0,1]
C++ · 解法代码#include <unordered_map> #include <vector> using namespace std; class Solution { public: vector<int> twoSum(vector<int>& nums, int target) { unordered_map<int, int> seen; // 值 -> 下标 for (int i = 0; i < (int)nums.size(); i++) { auto it = seen.find(target - nums[i]); // 先查 if (it != seen.end()) return {it->second, i}; seen[nums[i]] = i; // 后存 } return {}; } };
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl [[2,7,11,15],9] → [0,1] [[3,2,4],6] → [1,2] [[3,3],6] → [0,1] [[-1,-2,-3,-4,-5],-8] → [2,4] [[0,4,3,0],0] → [0,3] [[1000000000,294967296,-1,5],1294967296] → [0,1]
共 8 组用例(含边界),以暴力解的结果为基准逐组比对: Python 通过 8/8 Java 通过 8/8 Go 通过 8/8 JavaScript 通过 8/8 C++ 通过 8/8
顺序不能反。如果先存再查,当 target 恰好是 x 的两倍时,x 会在表里找到它自己。下面是两种顺序在同样用例上的真实输出:
# 先存后查的错误写法 vs 先查后存的正确写法 nums=[3, 2, 4], target=6: 错误写法 -> [0, 0] 正确写法 -> [1, 2] nums=[3, 3], target=6: 错误写法 -> [0, 0] 正确写法 -> [0, 1] nums=[2, 7, 11, 15], target=9: 错误写法 -> [0, 1] 正确写法 -> [0, 1]
[3, 2, 4] 目标 6 时,先存后查的写法返回 [0, 0],也就是把第一个 3 和它自己配成了一对,而正确答案是 [1, 2]。[3, 3] 这种重复值的用例,先查后存的写法能正确返回 [0, 1]:第二个 3 进来时表里只有第一个 3,配对成功,后一个值覆盖前一个值的问题在这里不会出现,因为答案在覆盖发生之前就已经返回了。
用随机数组把哈希表解法和暴力解逐组对比。生成器保证每组数据恰好只有一对满足条件,这与题目的保证一致;否则两种解法可能返回不同的合法答案,比对就没有意义了。(我最初写的一组固定用例 [1, 5, 5, 9] 目标 10 就有两对答案,运行器直接报出了两种解法结果不同,这组用例已经换掉。)
随机对拍 5000 组(长度 2 到 12、取值 -20 到 20 的随机数组,目标值取自随机选的两个位置之和,并保证恰好只有一对满足),solve 与 brute 的结果全部一致。
下面的演示用 nums = [2, 11, 7, 15, 3]、target = 10。它的每一帧都是把上面的解法在这组输入上真实执行一遍记录下来的,运行器还检查了演示最终得到的答案与 solve 的返回值一致。点"下一步"逐帧看,或者点击右下方记录里的任意一行跳到那一步。