资讯详情

LeetCode两数之和:哈希表优化与空间换时间详解

📅 2026/9/13 20:20:18 | 华诺云谱 👁 阅读
LeetCode两数之和:哈希表优化与空间换时间详解
1. 题目与核心思路拆解1.1 这题到底在考什么两数之和是 LeetCode Hot 100 的开门题也是无数人刷题生涯的第一站。题目本身不复杂给定一个整数数组和一个目标值要求在数组中找到两个数使它们的和等于目标值返回这两个数的下标。我见过太多人把这题当成简单题刷完就过去了但说实话这道题能考的东西远比字面意思多。它考察的不只是会不会写循环而是你是否具备以下几点基本功对暴力解法的复杂度有清醒认知对哈希表这种数据结构有直觉层面的理解懂得用空间换时间这个核心编程思想能处理边界情况能写出优雅的代码面试时这题被翻牌的频率极高不单是因为它简单更因为它是一块很好的试金石。一个候选人在写这题时表现出来的代码风格、边界意识、优化思路能反映他几年的工程习惯。1.2 暴力法为什么不够好先说最直觉的解法。两层循环外层固定一个数内层遍历找另一个数判断两数之和是否等于 target。伪代码长这样def two_sum_brute(nums, target): 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²)在数组长度达到十万、百万级别时耗时会呈现指数式的恐怖增长。LeetCode 的测试用例虽然没到那种极端程度但 O(n²) 的算法在系统设计层面的思路就输了——它没有利用已经扫描过的信息。我一直跟身边的同事说暴力解法在面试里可以提一嘴证明你有 baseline 认知但千万不要直接提交了事。面试官不关心你能不能做出来他关心你能不能做得好。1.3 哈希表方案的直觉来源优化的突破点在于内层循环在找什么它在找 target - nums[i] 这个差值。如果我们能把已经扫描过的数存起来并且能在 O(1) 时间内查到某个数是否存在那么整个算法就能压缩成单次遍历。哈希表就是干这个的。数组的值作为 key下标作为 value扫到一个数先查 target - nums[i] 在不在表里在就直接返回不在就把当前数存进表里继续扫。这就是空间换时间——用额外的 O(n) 内存换取了 O(n) 的时间复杂度。在大数据场景下这个交换几乎总是值得的。2. 两种主流实现方案详解2.1 方案一两次遍历哈希表第一次遍历把整个数组的值和下标的映射关系存入哈希表。第二次遍历时对于每个元素 nums[i]查询 target - nums[i] 是否在哈希表中且保证查到的下标不是 i 本身。def two_sum_two_pass(nums, target): n len(nums) hashmap {} # 第一次遍历建立值到下标的映射 for i in range(n): hashmap[nums[i]] i # 第二次遍历查找互补数 for i in range(n): complement target - nums[i] if complement in hashmap and hashmap[complement] ! i: return [i, hashmap[complement]] return []这里有个很容易踩的坑当重复元素出现时哈希表的 value 会被后一次覆盖。比如数组是 [3, 3]target 是 6第一次遍历结束后 hashmap[3] 1等于说把下标 0 覆盖掉了。好在第二次遍历到 i0 时complement 3查表得到 hashmap[3] 1不等于 i所以能正确返回 [0, 1]。但如果题目要求返回所有满足条件的数对这个方案就会漏数据。LeetCode 原题只要求返回一组解所以覆盖没问题。工程思维在这里重要起来了用之前要想清楚覆盖策略是否影响正确性。2.2 方案二一次遍历哈希表推荐这是我在面试和实际编码中最常使用的版本。核心逻辑是边遍历边查表查不到就存进去。def two_sum_one_pass(nums, target): hashmap {} for i, num in enumerate(nums): complement target - num if complement in hashmap: return [hashmap[complement], i] hashmap[num] i return []两个版本有什么区别一次遍历版本哈希表里存的是当前这个数之前已经扫描过的所有数所以查到的互补数下标一定不等于当前下标。不需要额外的下标判断逻辑代码更干净。而且这个方案天然处理了重复元素问题。拿 [3, 3], target6 举例i0 时hashmap 为空查不到 3存入 hashmap[3]0i1 时查 complement3查到 value0返回 [0, 1]。因为第二遍遍历时互补数还没被当前元素覆盖所以永远不会出现自己和自己相加的问题。工程实现上一次遍历也比两次遍历少一轮循环代码可读性和执行效率都有优势。2.3 两种方案对比与选型建议维度两次遍历哈希表一次遍历哈希表时间复杂度O(n)O(n)空间复杂度O(n)O(n)代码行数略多简洁处理重复元素需额外判断天然安全可读性中高面试推荐度中高我个人的建议是面试时直接写一次遍历版本但心里要清楚两次遍历版本的逻辑差异。面试官有时候会追问你为什么要用一次遍历而不是两次这不是刁难而是想确认你是真的理解了这个优化逻辑还是在背模板。3. 实操过程与多语言实现3.1 Python 实现细节def twoSum(self, nums: List[int], target: int) - List[int]: 一次遍历哈希表解法 :param nums: 输入整数数组 :param target: 目标值 :return: 两个下标组成的列表 hashmap {} for i, num in enumerate(nums): complement target - num if complement in hashmap: return [hashmap[complement], i] hashmap[num] i return []Python 有几个性能细节值得注意。enumerate比手动索引循环更快且更 Pythonic字典的in操作是平均 O(1) 的哈希查找但 Python 字典的哈希冲突处理代价略高于 Java 的 HashMap尤其是当 key 是大整数时。这个题目的数据规模下差异几乎不可感知但养成写enumerate的习惯对你之后写其他题有帮助。3.2 Java 实现细节class Solution { public int[] twoSum(int[] nums, int target) { MapInteger, Integer hashmap new HashMap(); for (int i 0; i nums.length; i) { int complement target - nums[i]; if (hashmap.containsKey(complement)) { return new int[]{hashmap.get(complement), i}; } hashmap.put(nums[i], i); } return new int[]{}; } }Java 版本要留意Integer缓存问题。如果数组里的值在 -128 到 127 之间HashMap用的是缓存对象能直接比较相等但超出这个范围比较的是引用地址必须用.equals()或者依赖containsKey和get的自动装箱比较。这里不用手动写.equals()因为HashMap内部比较 key 用的是equals但如果你自己写循环做判断就一定要注意这个问题。这是我见过不少人踩的 Java 细节坑。3.3 Go 实现细节func twoSum(nums []int, target int) []int { hashmap : make(map[int]int) for i, num : range nums { complement : target - num if j, ok : hashmap[complement]; ok { return []int{j, i} } hashmap[num] i } return nil }Go 的 map 取值的双返回值写法很实用第一个是 value第二个是是否存在。很多刚从 Python 转 Go 的人容易忘记判断ok直接忽略第二个返回值这会导致取到零值时不知道到底是存了零值还是key 不存在。在这个题目里如果数组真的包含值为 0 的元素且下标为 0取到hashmap[0]时返回 value0 和 oktrue但如果只取 value 不取 ok你无法区分下标 0和不存在逻辑就会出错。双返回值判断是 Go 工程实践里的基本素养。3.4 手写模拟一次完整执行流程拿 LeetCode 官方示例一来说nums [2, 7, 11, 15]target 9。i0, num2complement7hashmap 为空查不到存入 hashmap[2]0i1, num7complement2查 hashmap 得到 value0命中返回 [0, 1]你注意看整个过程只有两步。这个例子太顺了很多人就以为这题本来就是这么简单直接忽略了很多边界情况的处理。再手推一个稍微绕一点的例子nums [3, 2, 4]target 6。i0, num3complement3hashmap 空查不到存入 hashmap[3]0i1, num2complement4hashmap 无 4存入 hashmap[2]1i2, num4complement2查到 hashmap[2]1命中返回 [1, 2]这里就体现出一次遍历的价值了i0 时数字 3 的 complement 恰好也是 3但此时哈希表里没有已扫描的 3所以不会误判成自己和自己配对。你要是用两次遍历版本跑这个 case第一次遍历存完 hashmap[3]0、hashmap[2]1、hashmap[4]2然后 i0 查 complement3命中 hashmap[3]0但 index0 等于当前 i必须跳过再继续找。这也是为什么两次遍历版本需要额外加判断的原因本质上是哈希表包含了当前元素自身导致的信息污染。3.5 常见边界用例测试清单我刷题这么多年每次写完解法都会固定跑一批边界用例确保逻辑严谨。两数之和的核心边界用例至少包括数组只有两个元素且恰好匹配nums [1, 2], target 3包含负数nums [-1, -2, -3, -4], target -3包含零nums [0, 4, 3, 0], target 0重复元素且是答案nums [3, 3], target 6重复元素但不是答案nums [3, 3, 1], target 5最大值和最小值组合nums [2147483647, -2147483648, 1], target -1答案在数组收尾两端其中最大值和最小值组合最容易被人忽略。int 类型相加时如果 target - num 的运算发生在 int 范围内一般没风险但如果你换一种写法比如直接判断 nums[i] nums[j] target在极端整数边界下可能溢出。用complement target - nums[i]这种减法思路从源头上规避了溢出问题这也是工程上一种很常见的写法习惯能减就不加能比较就不求和。4. 常见问题与排查技巧实录4.1 问题一返回的下标顺序有讲究吗我见过很多人在讨论返回[hashmap[complement], i]还是[i, hashmap[complement]]。LeetCode 原题对顺序没有严格要求两组下标都算正确。但实际工程里如果你写的是一个返回查询结果的接口返回顺序最好跟数组遍历顺序一致这样日志排查更直观。我自己的习惯是返回[hashmap[complement], i]因为表里存的互补数下标一定出现在更早的位置。这样的顺序符合先出现的数下标在前的自然逻辑后面写扩展功能时更容易保持一致。4.2 问题二哈希表到底该存什么很多初学者会把下标存成 key值存成 value然后犯迷糊。记住一个通用原则你最终要取什么什么就应该放在 value 的位置。这题要返回下标所以下标是 value数本身是 key。这个原则几乎适用于所有哈希表优化的题目。存的时候问自己一句我在查询时想拿到什么思路就清晰了。4.3 问题三重复元素导致哈希表覆盖一次遍历版本的关键点是查不到才存入。也就是说后出现的重复元素不会覆盖先出现的元素因为查到的瞬间已经 return 了。两次遍历版本则必须注意覆盖问题前面讲过在 [3, 3] 的用例中hashmap[3] 最终等于 10 被覆盖。虽然不影响最终返回正确结果但你的代码需要额外判断hashmap[?] ! i来防止自我配对。工程上我建议一次遍历做首选。如果面试官追问重复元素场景你可以把查不到才存这个设计逻辑讲清楚既展示代码能力也展示边界思考。4.4 问题四真的需要先排序吗有人会想到排序后双指针。数组排序后用左右两个指针相向移动也能在 O(n log n) 时间解决。但注意排序打乱了原始下标题目要求返回原始下标你还需要额外存一份下标映射。哈希表的 O(n) 时间和 O(n) 空间对这道题来说综合最优。不过这里我要提一个很多人忽略的点如果数组本身已经有序双指针法只占 O(1) 额外空间比哈希表更优。LeetCode 后面有一道两数之和 II - 输入有序数组就是专门考察这个场景的。所以两数之和这题的价值不只是本身的解法更重要的是为后面的变体题铺路。4.5 问题五LeetCode 提交报错的常见原因根据我看到的刷题群里的讨论两数之和提交报错的典型原因有这么几类忘记 import 类型比如 Python 里用了List[int]类型注解但没from typing import List。LeetCode 的默认模板有时候已经帮你 import 了但本地跑的时候容易翻车。返回空数组的时机循环结束后忘记写 return导致函数没有返回值编译器报错。打印调试代码没删干净很多人本地调试时用 print 打印过程提交时忘记删虽然一般不影响结果但会拖慢执行时间大测试用例下可能超时。死循环如果你用 while 手动控制索引而非 for 循环步长忘记更新会导致死循环。这在初学者里很常见。角落用例考虑不周前面列的边界测试清单里的场景漏掉任何一个都可能直接 WA。4.6 独门调试技巧如果你本地调试时不确定哈希表的状态变化我会建议你手动打一张表跟踪每一轮循环的四个变量当前下标 i、当前值 num、期望互补数 complement、哈希表现状。这个操作在参加 LeetCode 周赛和做困难题时尤其有用养成把关键中间状态可视化出来的习惯排查 bug 的速度会快很多。另外一个技巧是先用暴力解法跑一遍结果再拿哈希表解法跑一遍两个结果互相对拍。很多老刷题人手里都有这个习惯尤其是修改过代码逻辑之后拿暴力版当标准答案来验证优化版是否行为一致。5. 从两数之和延伸出去5.1 三数之和的升级路径两数之和做完下一道经典升级题就是三数之和给定一个数组找出所有和为 0 的三元组。朴素的解法是三重循环 O(n³)显然太慢。标准做法是排序后固定一个数再用双指针找剩下两个数整体 O(n²)。这和两数之和的哈希表思路完全不同因为三数之和要去重哈希表反而麻烦。两数之和是你接触哈希表优化查找的第一课三数之和是你接触排序 双指针的第一课。这两道题连着刷你对数组类题目的两大主流优化范式就有了基本盘。5.2 两数之和 II 的精准变体我前面提到过两数之和 II - 输入有序数组。它和原题的唯一区别是输入数组已经有序且要求使用常数级别的额外空间。这就是在引导你用双指针而不是哈希表。如果你做完原题再花十分钟把有序变体写了你会发现双指针的思路原来这么自然左指针指向开头右指针指向结尾两数之和大于 target 就右指针左移小于 target 就左指针右移。5.3 工程场景里的真实映射两数之和这个逻辑在工程里不是玩具题。缓存系统里就有一种经典场景你需要检查某个用户和另一个用户的会话是否构成某种交易组合如果把所有用户的会话 key 存进哈希表再遍历一次查互补 key过程一模一样。再比如风控系统里判断两笔交易金额是否满足某个组合规则也是同样的模式。哈希表存已扫描数据的索引/信息这个思路可以说是所有需要快速查找的历史数据匹配问题的通用解法。两数之和教会你的不是一道题而是一种迁移能力凡是需要在一堆历史数据里快速找目标信息的场景先想到哈希表。5.4 面试追问怎么接面试官在你写完两数之和后大概率会追加几个问题。我最常被问到的如果数组很大内存装不下哈希表怎么办——回答思路是外部排序 双指针或者分治处理。如果要求返回所有解而不是一组解——需要遍历完整张哈希表注意去重。如果数据流是实时进来的——用流式处理的思路维护一个哈希表每到一个数就查一次。如果 target 会变化频繁——预处理哈希表只做一次利用空间换时间。这些问题没有标准答案但万变不离其宗理解哈希表、理解复杂度、理解数据流特征。答的时候先分析限制条件再给方案别上来就背诵。6. 我的个人刷题心得把 LeetCode Hot 100 里这道开篇题当作刷题旅程第一站的读者我的建议是不要只满足于 ACAC 之后多花十分钟做三件事——第一把暴力解法写一遍对比复杂度差异第二把边界用例全部跑一遍第三想一想如果题目改了条件有序数组、要返回所有解、不允许用额外空间你的解法要怎么调整。这道题我跟身边很多同事讨论过每次都能从别人那里听到一个自己之前没注意的角度。有一次一个后端同事提到 MySQL 的索引查找本质上也是哈希查找的变体用主键索引快速定位记录和两数之和查互补数如出一辙。这让我意识到刷题刷的不是题目本身而是底层思维模型。还有个细节值得单独拿出来说LeetCode 上同一个题有 Python、Java、Go、C 多种语言提交每次周赛或日常练习我都会刻意用不同的语言去写同一道题。原因很简单语言特性会影响你对数据结构和算法的表达方式。Python 的字典太好用了你会倾向于依赖它Go 让你被迫考虑ok判断C 的 unordered_map 要手动管内存。多语言交叉学习能让你的算法功底从会用一个语言刷题升级为理解算法本身。最后分享一个实用小习惯在你刷题笔记里给每个题目标注考察的核心数据结构 核心思想。比如两数之和这题就标注哈希表 空间换时间。这样当你刷完 Hot 100 回头复习时靠标签就能快速定位知识点薄弱区而不必每一题都重新读一遍。我的 Hot 100 笔记坚持用这个方式记录复习效率高了不少。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。