bigywater.tech
算法与数据结构 · LeetCode 1

LeetCode 1. 两数之和

给定一个整数数组 nums 和一个目标值 target,找出数组中和为 target 的两个元素,返回它们的下标。题目保证恰好有一个答案,同一个位置的元素不能用两次。这道题的核心是把"找另一半"从逐个比较换成哈希表查找。本文用真实测量对比两种写法在不同规模下的耗时,验证"先查后存"这个顺序为什么不能换,最后把哈希表的每一步做成可以逐帧回放的演示。

5,000
组随机对拍,与暴力解的结果全部一致
11 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整的题目描述和约束见官方页面(链接在文末)。下面是本文测试用到的前几组用例,输出是暴力解(两层循环)算出来的基准结果;所有用例一共 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 []
真实实测 · Python · 前几组用例的输出
$ 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]; } }
真实实测 · Java · 前几组用例的输出
$ 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 · 前几组用例的输出
$ 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 []; }
真实实测 · JavaScript · 前几组用例的输出
$ 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 {}; } };
真实实测 · C++ · 前几组用例的输出
$ 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 的返回值一致。点"下一步"逐帧看,或者点击右下方记录里的任意一行跳到那一步。

两数之和:nums = [2, 11, 7, 15, 3],target = 10
正在看的元素已经扫过并存入哈希表配对成功

    参考与说明

    • 官方题目页:LeetCode 1. 两数之和。本文只用自己的话概括了题意,完整的约束以官方页面为准。
    • 五种语言都在本机真实编译运行,通过同一批 8 组用例,结果与暴力解逐组一致;5000 组随机对拍和上面的耗时对比只用 Python 做,暴力解和哈希表解法在每组随机数据上都做了逐组比较,耗时是三次中最快的一次,机器上有其他进程时数字会有波动,但增长的倍数关系是稳定的。
    • 另外四种语言只在这 8 组固定用例上验证过,没有做大规模随机对拍,用例里有负数、零、重复值和接近 32 位上限的数。
    • 没有覆盖:题目没有保证唯一答案时的行为(此时返回哪一对取决于扫描顺序);先排序再用双指针的做法(它拿不到原始下标,要额外记录下标,本文没有展开)。
    京ICP备2026067502号-1京公网安备11010502063487号