两数之和:从暴力解到哈希表,算法入门第一题的核心思路
发布时间:2026/10/16 18:00:14 锦皓数字建站

聊到算法刷题很多人习惯把 LeetCode 排在计划表的某个角落总觉得门槛太高、离日常工作太远。但如果真要说哪道题最适合当作算法入门的第一道我大概率会推荐 Hot 100 榜单里的第 1 题——两数之和Two Sum。这道题常年霸榜是无数人开始刷题旅程的真正起点也是面试现场出现频率极高的“送分题”。不过现实是很多新手看完题解之后依然一脸懵暴力解为什么慢哈希表为什么快代码就那么几行背后的思考过程却没人讲清楚。这篇就用最直白的话把两数之和从暴力到最优解掰开揉碎聊明白。你会搞懂题目里的隐藏条件、两种主流解法的完整推导、三种编程语言的具体实现以及新手最容易踩的坑。不管你是刚接触数据结构还是刷题三个月还在原地打转这篇都值得从头看到尾。1. 先把题目读懂暴力解为什么是第一步1.1 题面与四个隐藏条件题目描述本身很短我简化一下给定一个整数数组 nums 和一个整数目标值 target请在数组中找出和为目标值 target 的两个整数并返回它们的数组下标。注意几个容易被忽略的细节题目默认“每种输入只会对应一个答案”意思是只要找到一对结果就可以直接返回不用处理多种答案的情况。返回的是下标不是数值本身。这一点很关键后面会专门展开。同一个元素不能使用两次。意思是 nums 里同一个位置的元素不能用两遍但如果有两个值相同但位置不同的元素可以同时用。你可以按任意顺序返回答案[0,1] 和 [1,0] 都算对。这四个条件决定了后面的解法选型。特别是“返回下标”和“同一元素不能用两次”直接否定了某些看似聪明的捷径。1.2 暴力解法的完整推导把题目翻译成最朴素的做法既然要找到两个数那就把所有可能的组合都试一遍。用第一个数固定住然后遍历它后面所有的数看看有没有哪个能和它凑成 target。如果第一轮没找到就换第二个数作为固定点再往后遍历。这就是双重循环。Java 代码写出来是这样class Solution { public int[] twoSum(int[] nums, int target) { int n nums.length; for (int i 0; i n; i) { for (int j i 1; j n; j) { if (nums[i] nums[j] target) { return new int[]{i, j}; } } } return new int[]{}; } }内层循环从i 1开始而不是从 0 开始有两点考虑一是避免同一个元素自己和自己配对比如nums[0] nums[0]二是避免重复计算(i, j)和(j, i)其实是同一对组合只需要算一次就够了。这段代码的正确性不用怀疑但性能不太好。假设数组长度是 n那么需要比较的组合数量是n * (n - 1) / 2种。当 n 等于 10 万时要比较约 50 亿次这在在线判题系统里基本会超时。空间复杂度倒是很优秀只用了一个变量是 O(1)。1.3 新手最容易踩的思维误区排序后双指针很多同学看到两数之和第一反应不是哈希表而是先排序再用双指针。思路看起来很有道理把数组排好序左指针指向最小右指针指向最大然后根据两数之和与 target 的大小关系调整指针移动。这个做法对“判断数组中是否存在两个数之和等于 target”是完全成立的时间复杂度也能做到 O(n log n)。但回到这道题上有一个致命问题题目要求返回原始数组中的下标。一旦排了序原始下标全部丢失。虽然可以在排序前先记录每个元素的下标比如搞一个“值原下标”的结构体但那额外占用 O(n) 的空间代码复杂度也上去了。我在初学阶段就掉进过这个坑写了一个很漂亮的排序加双指针方案提交之后才发现返回的下标全是排序后的位置调试了半天才意识到问题出在排序破坏了原始下标信息。所以如果面试时你主动提出排序方案面试官大概率会追问一句“那下标怎么办”想不清楚的话反而不如老老实实说出暴力解。但从另一个角度看排序加双指针也告诉了我们一件事如果这道题改成“返回两个数本身而不是下标”那它就是一个标准的双指针应用题了。这个变体在后面面试追问部分还会再提到。2. 从暴力到哈希最优解是怎么一步步想出来的2.1 暴力解效率低的核心瓶颈在哪暴力解法慢不是因为比较本身慢而是因为做了大量无意义的重复劳动。拿nums [1, 4, 6, 8, 10, 13]和target 14举例。暴力解会这样执行1 4、1 6、1 8、1 10、1 13全部不满足然后4 6、4 8、4 10、4 13……直到4 10才命中。你会发现每固定一个数都要把它后面所有的数全部扫描一遍。也就是说同一个数会被反复遍历很多次。从信息利用的角度看这是在重复读取相同的数据。如果我们能想个办法把“已经扫过的数”记录下来当新来一个数时直接问一句“我之前有没有见过 target 减去当前的数”那么每个数只需要被处理一次整体效率就能大幅提升。2.2 用查字典理解哈希表哈希表也叫哈希映射、字典是解决这一类“快速查找”问题的利器。它的底层原理可以粗浅地理解为把某种类型的键通过哈希函数转换成一个数组下标实现近似 O(1) 的查找和插入。听起来抽象用查字典来类比就很好理解。你查一个英文单词的意思时不会一页一页翻完整本词典而是直接翻到对应的首字母区域再顺着找。哈希表就是那本按某种规则快速定位的词典key 是单词value 是释义。传入一个 key它能在极短时间里找到对应的 value。在两数之和这道题里key 应该存什么value 应该存什么需要想清楚。我们需要“用值快速找到位置”所以 key 应该是数组元素的值value 应该是该值在数组中的下标。这样当我们想知道“之前有没有出现过 target - nums[i]”时直接把 target - nums[i] 当作 key 去查查到了就能立刻得到它的下标。2.3 完整推演一遍哈希解法先看一下最终代码后面我会用手工模拟的方式逐行拆解。class Solution { public int[] twoSum(int[] nums, int target) { MapInteger, 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); } return new int[]{}; } }用nums [3, 2, 4]target 6来模拟一遍。初始化空哈希表 map {}i 0nums[0] 3complement 6 - 3 3。map 中还没有 key 为 3 的记录所以把3 - 0放进去。此时 map {3: 0}。注意顺序先查再存。先查的目的是避免使用当前元素自己。如果先存再查当 complement 恰好等于当前值时就会把当前位置当成答案返回属于经典错误后面会专门展开。i 1nums[1] 2complement 6 - 2 4。map 中还没有 key 为 4把2 - 1存进去。此时 map {3: 0, 2: 1}。i 2nums[2] 4complement 6 - 4 2。map 中已经存在 key 2对应的 value 是 1也就是下标 1。于是直接返回[1, 2]。验证一下nums[1] 2nums[2] 42 4 6完全正确。整个过程只扫描了一遍数组每个元素最多被访问一次时间复杂度是 O(n)。代价是需要额外维护一个哈希表空间复杂度是 O(n)。这就是典型的用空间换时间。2.4 复杂度对比与直觉判断直接把两种解法放在一起对比一目了然解法时间复杂度空间复杂度是否返回原下标暴力双重循环O(n²)O(1)是排序 双指针O(n log n)O(1)如返回数值或 O(n)需保留下标需额外处理哈希表一次遍历O(n)O(n)是从大 O 复杂度看哈希表是最优的。但这里也想多聊一句不是所有场景都该无脑选哈希表。如果数组本身已经有序双指针方案时间 O(n)、空间 O(1)反而是更合适的。再比如面试时如果你能清晰地说出“数组无序用哈希表 O(n) 解决数组有序用双指针 O(1) 空间解决”这种对比思维会给面试官留下很好的印象比单纯背代码要强得多。我在实际写业务代码时也深有体会哈希表虽快但内存开销比较大如果数据量特别大或者对内存极其敏感有时宁可多花一点时间也要省下那份空间。算法题里追求最优复杂度没错但工程上要学会权衡。3. 完整代码实现三种主流语言逐行解析3.1 Java 版本Java 版是面试中最常用的版本核心用到HashMap。class Solution { public int[] twoSum(int[] nums, int target) { // 创建一个哈希表key 存数组元素值value 存该元素的下标 MapInteger, Integer map new HashMap(); for (int i 0; i nums.length; i) { // 当前元素 nums[i] 需要的“另一半” int complement target - nums[i]; // 如果之前扫过的元素里有 complement直接返回两个下标 if (map.containsKey(complement)) { return new int[]{map.get(complement), i}; } // 没找到把当前元素及其下标放入哈希表 map.put(nums[i], i); } // 题目保证有解这里只是为了通过编译 return new int[]{}; } }注意Map是接口HashMap是实现类声明时用接口类型MapInteger, Integer map是更推荐的做法。如果后续想换成TreeMap或ConcurrentHashMap改动更小语义也更清晰。还有一个 Java 特有的细节int[]是引用类型返回new int[]{}表示空数组。在 LeetCode 环境里因为题目保证有解这个返回值永远不会真的被用到但为了编译通过必须写。3.2 Python 版本Python 版用内置的字典dict就能实现写起来最简洁。class Solution: def twoSum(self, nums: List[int], target: int) - List[int]: # 字典值 - 下标 seen {} for i, num in enumerate(nums): complement target - num if complement in seen: return [seen[complement], i] seen[num] i return []Python 的enumerate同时拿到下标和值非常方便。if complement in seen判断的是“键在不在字典里”也就是值在不在 seen 的键集合里。这里容易产生一个直觉混淆你会觉得“我要找的是一个数”但 in 判断的对象是字典的键不是值。不过在我们这个设计里键恰好就是数所以没问题。3.3 C 版本C 版用unordered_map思路完全一致。class Solution { public: vectorint twoSum(vectorint nums, int target) { // 哈希表值 - 下标 unordered_mapint, int map; for (int i 0; i nums.size(); i) { int complement target - nums[i]; if (map.count(complement)) { return {map[complement], i}; } map[nums[i]] i; } return {}; } };map.count(complement)返回 0 或 1表示键是否存在。也可以用map.find(complement) ! map.end()但 count 写法更简洁。C 的unordered_map平均 O(1) 查找但极端哈希冲突情况下会退化不过算法题里几乎不会触发这种极端情况。3.4 三种语言的细节差异与常见坑三种语言实现同一逻辑但有几个容易出错的差异点语言哈希表容器判断键是否存在返回多个值的方式JavaHashMapInteger, IntegercontainsKeynew int[]{a, b}Python字典dictin列表[a, b]Cunordered_mapint, intcount或find大括号初始化的vectorint还有一个容易忽略的问题Python 和 C 的map[nums[i]] i或seen[num] i在做插入时如果 key 已经存在会覆盖旧值。放在这道题里不会出问题因为题目保证只有一对答案遍历过程中不会遇到“同一个值对应两个不同下标”的冲突场景。但如果你在练习扩展题比如解三数之和时就要考虑键冲突时的覆盖策略了。我在实际中见过有人把seen[num] i写成seen[i] num正好反了导致查找时永远找不到 complementary。这个错误很隐蔽因为编译和运行都不会报错只是结果一直是空。如果你遇到这种情况先检查一下 key 和 value 是否放反了。4. 新手最容易踩的坑错误代码现场还原4.1 高频错误速查表下面几个错误是我在带新人时见过最多、自己也踩过的情况。有些错误代码甚至能通过部分测试用例非常迷惑人。错误类型错误示范正确做法先存后查先把当前元素放入 map再查 complement先查 complement再把当前元素放进去key 和 value 颠倒map.put(i, nums[i])map.put(nums[i], i)直接返回数值返回[nums[a], nums[b]]返回[a, b]内层循环从 0 开始for (int j 0; j n; j)从i 1开始避免重复和自己配对用 HashSet 存值SetInteger set new HashSet()需要同时存下标得用 Map先存后查这个错误值得单独展开。看这段代码// 错误示范 MapInteger, Integer map new HashMap(); for (int i 0; i nums.length; i) { map.put(nums[i], i); int complement target - nums[i]; if (map.containsKey(complement)) { return new int[]{map.get(complement), i}; } }当nums [3, 2, 4]target 6时程序会怎么跑i 0先把3 - 0放入 map然后查 complement 3发现 map 里已经有 3 了返回[0, 0]。这就是“同一个元素”被用了两次。虽然题目说了“数组中同一个元素不能使用两遍”但很多判题用例并不会有[3, 2, 4]这种第一个元素恰好等于 target 一半的情况所以错误代码可能在部分用例下通过让人更加迷惑。4.2 边界情况逐条核对除了逻辑错误边界条件也是新手容易失分的地方。虽然题目保证一定存在答案但拿到题目后先习惯性地想一遍边界能培养更扎实的语感。数组长度为 2比如nums [1, 2]target 3。此时循环只需要跑一次i 0 时 complement 2查不到存入 1i 1 时 complement 1查到下标 0返回[0, 1]。这个用例能检查循环边界是否有越界问题。数组中存在负数比如nums [-1, -2, -3, -4]target -6。哈希表的 key 允许负数逻辑完全正常。有些同学习惯在循环里假设nums[i] target一遇到负数就会漏掉正确组合这个习惯要改。数组中有大量重复值比如nums [7, 7, 7, 7]target 14。因为先查后存第一个 7 不会被当作答案第二个 7 出现时map 里已经有第一个 7 的下标 0返回[0, 1]。这种行为完全符合题目要求。结果在数组末尾比如nums [5, 9, 2, 8, 1]target 3答案 1 和 2 在下标 4 和 2。这要求循环能完整遍历到最后一个元素我们的代码天然支持。4.3 为什么返回顺序不重要但下标千万不能错题目说了答案顺序任意所以[map.get(complement), i]和[i, map.get(complement)]都能通过。但注意map.get(complement)得到的一定是之前某个下标i是当前下标两者一定不同因为当前元素还没有被放进 map 里。这也是“先查后存”的一个额外好处天然保证了两个下标不是同一个位置。我曾经在给别人 review 代码时看到一种写法先判断i ! map.get(complement)然后再返回。这种防御性判断没有错但因为先查后存这个条件永远为真属于冗余代码。如果你是在扩展题里维护了一个“包含当前元素”的哈希表这个判断才有意义。4.4 另一个常被忽略的工程化细节LeetCode 的函数式编程和真实工程项目不太一样。在真实业务里你很少会写一个twoSum函数单独处理数组更多会遇到的是“从数据库里读一批数据找出满足某种条件的记录对”。但算法思想是通用的遇到需要在集合里反复查找的场景优先想到用哈希表做索引能省下大量的时间。我在某公司实习时遇到过一个实际场景需要给一大批订单记录做配对找出金额相加等于 100 的两笔订单。原始方案用了双重循环处理几万条记录接口响应时间直接飙到五六秒。后来参考两数之和的思路用哈希表把金额作为 key、订单作为 value一次遍历就完成了配对响应时间降到了 200 毫秒以内。算法题的价值往往就体现在这些意想不到的地方。5. 从刷题到面试追问与进阶路线5.1 面试官会怎么变着法问两数之和属于高频题中的高频题面试官一般不会只满足于“你写出了最优解”大概率会追问几个变体。第一个常见追问如果数组是有序的你会怎么做这个问题考察你是否理解双指针的价值。数组有序的前提下左指针指向最小值右指针指向最大值和小于 target 就左移和大于 target 就右移直到相遇。时间复杂度 O(n)空间复杂度 O(1)。第二个常见追问如果返回的是两个数的值而不是下标你会怎么做这个问题可以回答排序加双指针甚至双指针都不需要额外空间保存下标。注意这里和原始题目的区别返回值意味着排序不会丢失任何关键信息所以排序加双指针是很好的答案。第三个常见追问如果数据量特别大内存放不下哈希表怎么办这个问题考验工程思维。哈希表需要 O(n) 的内存当 n 达到几亿时单机内存可能撑不住。可以考虑的思路包括分治法把数据切分成多个文件分别处理后再合并结果或者使用外部排序加磁盘存储。这种问题没有唯一标准答案关键是展示你面对大规模数据时的思考路径。第四个常见追问能不能只遍历一次数组就能完成这其实就是在暗示哈希表解法。如果你能主动说出“用空间换时间把已经访问过的数存下来”就已经拿到基础分了。5.2 这道题的四个经典变体学会了基础版可以顺手把几个变体都串起来三数之和给定数组找出三个数使其和等于 0。思路是先排序固定一个数再对剩下的部分用双指针。时间复杂度可以做到 O(n²)。进阶版四数之和思路类似但边界处理更繁琐。两数之和 II输入有序数组这道题就是双指针的直接应用不需要哈希表。两数之和 IV输入是二叉搜索树要判断是否存在两个节点的值之和等于 target。可以中序遍历得到一个升序序列然后套用双指针或者用哈希表配合遍历。和为 K 的子数组给定数组和一个整数 K统计和为 K 的连续子数组个数。这道题用的是“前缀和 哈希表”思路里带着两数之和的影子但难度上了一个台阶。把这几道题放在一起刷你会发现“找配对”类问题有一套完整的打法无序数组想高效配对优先哈希表有序数组配对优先双指针连续子数组求和优先前缀和。5.3 从两数之和开始构建算法体系很多人把两数之和当作一道孤立的题刷完就忘记了。其实它是一棵算法树的根节点从它出发可以延伸到哈希表应用、双指针技巧、前缀和思想、排序与查找的组合使用。我在带新人时喜欢让他们做一个动作刷完这题后把官方题解里的“暴力法”和“哈希表法”各写三遍第一遍纯默写第二遍边写边注释每一行的作用第三遍尝试不借助任何参考自己推导出整个思考过程。看起来简单但三遍下来你对空间换时间这个思想的理解会深刻很多而不是停留在“见过这个解法”的层面。如果你还在担心“哈希表是什么”“HashMap 怎么用”不要慌。两数之和就是用来消灭这种担心的。它把所有初学者需要的基础知识点浓缩在一道简单题里花一两个小时彻底搞明白它比盲目刷十道简单题更有效。我个人还有一个体会写题解或者给别人讲题是巩固算法理解最快的方式。你觉得自己明白了但真正要解释清楚“为什么先查后存”“为什么哈希表查找是 O(1)”时往往会发现自己还有一些模糊的地方。如果你能把两数之和的完整思路讲给一个完全不懂代码的朋友听而且他能听懂那你才是真的掌握了。最后分享一个小技巧刷题初期不要追求题数追求“一题多解”和“多题一解”。两数之和就是最好的练手素材。把它吃透后面遇到三数之和、四数之和、和为 K 的子数组你会觉得似曾相识。多年后你再回头看很可能这道题就是你算法之路真正的起点。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。