尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

两数之和与哈希表:从暴力到O(n)的算法优化实战

两数之和与哈希表:从暴力到O(n)的算法优化实战 差不多每一个刷过 LeetCode 的人第一个遇到的题基本都是它——两数之和Two Sum。这道题在 LeetCode 上是第 1 号题地位相当于编程世界的“Hello World”但你别因为它简单就小看它。这道题背后藏的哈希表思想几乎贯穿了你后面要遇到的所有算法题三数之和、LRU 缓存、字符串匹配、重复元素检测……可以说搞懂这一题你就拿到了打开数据结构与算法大门的钥匙。这篇博客我会完全从一个过来人的角度把这道题掰开揉碎讲清楚。包括暴力解法为什么慢、哈希表到底是个什么东西、为什么空间换时间在工程上这么值、C/Java/Python 三种语言怎么实现、以及面试官最喜欢追问的几个变形问题。不管你是刚转行准备刷题的小白还是已经刷了几十道题的初学者这篇文章都能给你一些不一样的理解。1. 两数之和到底在考什么先别急着写代码1.1 题面还原与分析题目描述很简洁给定一个整数数组nums和一个整数目标值target请你在该数组中找出和为目标值的那两个整数并返回它们的数组下标。听起来是不是特别简单但越是简单的题越能看出一个人写代码的基本功。这道题有几个隐含信息需要先读出来返回的是下标不是元素值本身。这意味着你不能在找到两个值之后直接返回还要通过某种方式把下标带出来。每种输入只会对应一个答案。这个条件很重要它保证了我们不需要处理“有多组解”的情况也让很多写法的正确性有了保障。同一个元素不能使用两次。比如nums [3, 3]target 6正确答案是[0, 1]你不能返回[0, 0]。很多初学者拿到题就直接写两层 for 循环这当然没问题因为题目本身确实可以用暴力法解决。但在面试中如果你只给出暴力解法面试官大概率会追问一句“能不能优化一下”这就是这道题真正的考点——你是否具备从 O(n²) 优化到 O(n) 的意识。1.2 暴力解法所有新手的第一站vectorint twoSum(vectorint nums, int target) { int n nums.size(); for (int i 0; i n; i) { for (int j i 1; j n; j) { if (nums[i] nums[j] target) { return {i, j}; } } } return {}; }这段代码的逻辑非常直白固定第一个数然后往后扫描第二个数看看两个数之和是不是 target。暴力解法的缺点在哪拿数据说话。当数组长度是 1 万时最坏情况下需要执行大约 5000 万次比较当数组长度来到 100 万这个数字就变成了大约 5000 亿。这个增长速度是平方级的用专业的说法就是时间复杂度O(n²)。一旦数据量上来程序会肉眼可见地卡死。所以问题就变成了我们能不能只遍历一遍数组就找到答案答案是可以但需要引入一个新的数据结构——哈希表。2. 哈希表空间换时间的经典操作2.1 哈希表底层到底是个什么东西哈希表Hash Table有些语言里叫散列表本质上就是一个“数组 哈希函数”的组合。数组你肯定懂就是按顺序排好的一排格子每个格子有编号下标通过下标访问元素非常快时间复杂度是 O(1)。但问题是数组的下标只能是整数而且必须是连续的。我们现在面对的是一堆任意数字怎么用 O(1) 的时间去定位它这时候哈希函数就出场了。哈希函数的作用就是把任意一个 key比如数字 3、字符串 abc映射成一个整数下标。你可以用现实中的快递柜来理解。快递柜有几十个格子每个格子有编号。你取快递的时候快递员告诉你一串取件码你输入取件码柜门就弹开了。哈希表就是这样一个“自动取件柜”你给一个 key哈希函数帮你算出它应该存在哪个“格子”桶里然后直接去取。当然哈希函数不可能做到完美的一一对应。不同的 key 可能映射到同一个桶这种情况叫“哈希冲突”。常见的解决方式是链地址法也就是让同一个桶里挂一个链表冲突的元素都放在链表上。这也是为什么哈希表在最坏情况下会退化成 O(n)——如果所有元素都冲突到同一个桶里链表就变成了一个长链。不过在实际工程中哈希表的负载因子元素个数除以桶的个数通常被控制在一个合理范围内所以平均情况下查找、插入、删除的时间复杂度都是 O(1)。2.2 哈希表如何解决两数之和核心思路是遍历数组时把已经访问过的元素和它的下标存入哈希表。对于当前元素nums[i]我们看看target - nums[i]之前有没有出现过。如果出现过说明这两个数就是要找的答案直接返回下标。这里有一个很微妙的点为什么要“边遍历边存储”而不是先把整个数组存进哈希表再开始查我先留个悬念后面在代码实现部分会详细对比两种写法的区别。你可能会问为什么这样就能从 O(n²) 优化到 O(n)因为暴力解法中对于每一个nums[i]我们都需要在数组中从i1扫描到末尾去“找另一半”这个“找”的过程是 O(n) 的。而有了哈希表查找“另一半”是否出现过变成了 O(1)整个算法的耗时就从“n 次 O(n) 查找”变成了“n 次 O(1) 查找”时间复杂度自然降到了 O(n)。2.3 前期的一个关键判断unordered_map 还是 map如果你用 C会面临一个选择是用unordered_map还是map我第一次刷这道题的时候用的是map为什么呢因为当时只知道map。后来查了资料才知道map底层是红黑树插入和查找的时间复杂度都是 O(log n)而unordered_map底层才是真正的哈希表插入和查找平均是 O(1)。所以这道题必须用unordered_map。这不是什么玄学而是数据结构的底层实现决定的。Java 里面对应的选择是HashMap和TreeMapPython 则直接用内置的dict它本身就是哈希表实现。3. 核心代码实现三种语言逐个写给你看3.1 C 实现unordered_map 的正确打开方式如果你用的是 C标准的题解写法是这样的#include vector #include unordered_map using namespace std; class Solution { public: vectorint twoSum(vectorint nums, int target) { unordered_mapint, int hash; // key: 元素值, value: 下标 for (int i 0; i nums.size(); i) { int complement target - nums[i]; // 在哈希表中查找 complement auto it hash.find(complement); if (it ! hash.end()) { return {it-second, i}; } // 没找到就把当前元素存进去 hash[nums[i]] i; } return {}; } };这里有几个细节值得注意第一查找用的是find而不是count。count返回的是 0 或 1表示键是否存在find返回的是迭代器如果找不到就返回end()。用find的好处是一旦找到你可以直接通过it-second拿到下标不需要再查一次哈希表。第二为什么答案要返回{it-second, i}而不是{i, it-second}这其实不影响正确性因为题目只要求返回两个下标顺序无所谓。但很多题解习惯把先出现的位置放在前面读起来更自然。第三注意hash[nums[i]] i这行。如果数组中出现了重复元素哈希表会覆盖之前存的下标。这在题目有“唯一解”这个前提下是安全的但我想让你意识到这个行为因为后续做变形题时这里会是一个坑后面会细说。3.2 Java 实现HashMap 的注意事项Java 的写法逻辑和 C 几乎一样但语法上有一些区别import java.util.HashMap; import java.util.Map; class Solution { public int[] twoSum(int[] nums, int target) { MapInteger, Integer hash new HashMap(); for (int i 0; i nums.length; i) { int complement target - nums[i]; if (hash.containsKey(complement)) { return new int[] { hash.get(complement), i }; } hash.put(nums[i], i); } return new int[] {}; } }Java 里面要注意的点和 C 不太一样HashMap的键不能是基本数据类型所以hash.put(nums[i], i)会自动把 int 装箱成 Integer。你不用管它JVM 有缓存机制这个开销可以忽略不计。判断键是否存在用containsKey取值用get。这里有个小性能点Java 实际调用时containsKey和get会各做一次哈希定位。严格来说比 C 的find多了一次查找过程但大部分场景下差别不大不需要过度优化。返回空数组时new int[] {}是合法的但如果你用return nullLeetCode 的判题机会报错。养成返回空集合的习惯代码更稳健。3.3 Python 实现字典用起来最顺手Python 的dict本身就是哈希表所以写起来极其简洁class Solution: def twoSum(self, nums: List[int], target: int) - List[int]: hash_table {} for i, num in enumerate(nums): complement target - num if complement in hash_table: return [hash_table[complement], i] hash_table[num] i return []Python 的几行代码里藏着不少基本功enumerate能同时拿到下标和值比range(len(nums))再nums[i]的方式更 Pythonic也更快。if complement in hash_table判断的是键是否存在时间复杂度也是 O(1)。返回列表[hash_table[complement], i]注意这里hash_table[complement]是第一次出现该值时的下标i是当前元素的下标顺序刚好符合题意的[小下标, 大下标]。3.4 一个完整的可运行测试示例光写核心逻辑还不够我建议你本地跑一遍完整的程序。这里给一个 C 的完整测试框架你可以直接复制运行#include iostream #include vector #include unordered_map using namespace std; vectorint twoSum(vectorint nums, int target) { unordered_mapint, int hash; for (int i 0; i nums.size(); i) { int complement target - nums[i]; auto it hash.find(complement); if (it ! hash.end()) { return {it-second, i}; } hash[nums[i]] i; } return {}; } int main() { vectorint nums {2, 7, 11, 15}; int target 9; vectorint result twoSum(nums, target); cout [ result[0] , result[1] ] endl; // 输出 [0, 1] return 0; }跑通了这段代码你就完成了从“看懂思路”到“能写出可运行程序”的第一步。4. 复杂度分析从数据上证明哈希表赢在哪4.1 时间复杂度的对比暴力和哈希两种方案的区别用一张表就能看得很直观方案时间复杂度空间复杂度数据规模 10000 时的操作次数暴力双层循环O(n²)O(1)约 5000 万次比较哈希表两次遍历O(n)O(n)约 2 万次哈希操作哈希表一次遍历O(n)O(n)约 1 万次哈希操作这里提到的“两次遍历”和“一次遍历”是哈希表解法的两种写法。两次遍历的思路是第一遍先把nums全部存入哈希表第二遍再遍历数组对于每个nums[i]去哈希表里查target - nums[i]是否存在。一次遍历就是我们前面写的版本边遍历边查询边查询边存入。为什么推荐一次遍历因为两次遍历有一个隐藏的小问题当你查target - nums[i]时如果恰好存在一个元素满足条件但它的下标就是i自己那你就得额外判断一下“是不是同一个下标”。一次遍历天然规避了这个情况——因为当前元素还没来得及存入哈希表所以查到的元素一定不是自己。4.2 空间换时间在工程中的意义哈希表的代价是额外占用了一块内存也就是 O(n) 的空间。在很多算法学习者眼里“额外空间”好像是个坏事但在工程实践中空间换时间是非常常见的取舍。举一个现实中的例子你在网上购物时输入收货地址系统需要判断这个地址是不是在一个“黑名单”里。如果每次请求都全表扫描一遍黑名单列表那延迟会高到无法接受。但如果把黑名单放进哈希表每次只需要几十纳秒就能完成判断。牺牲一点内存换来的是几百倍的性能提升这笔账怎么算都划算。这也是两数之和这道题真正想教给你的第一课当你发现程序开销主要在“查找”上时思考能不能用一个哈希表把“查找”从线性时间降为常数时间。这个思路会陪伴你走完整个算法学习生涯。5. 常见问题、边缘情况与排查实录5.1 元素重复的情况下哈希表会不会出问题前面提到过哈希表在存入重复元素时会覆盖下标。那如果nums [3, 3]target 6用一次遍历会发生什么走一遍流程i0complement3哈希表为空查询不到存入hash[3]0i1complement3哈希表里有 3下标 0返回[0, 1]完美。那如果用两次遍历呢第一遍结束后哈希表里hash[3]被覆盖为 1第二遍 i0complement3查到下标 1不等于自身返回[0, 1]结果也正确。所以这两种写法在这个场景下都不会有问题。真正的坑在于“判断相同下标”的逻辑如果你写的两次遍历版本缺少it-second ! i这个判断那遇到nums [3]target 6这种情况就会错误地返回[0, 0]。5.2 负数与大数问题有人会问如果数组里有负数哈希表还能正常工作吗当然可以。哈希表的 key 是元素值它不做任何关于大小的假设。-5 8 3这种组合哈希表一样能查到complement 3 - (-5) 8。还有一个容易被忽略的细节整数溢出。在 C 中int的范围大约是 -21 亿到 21 亿。如果target和nums[i]都接近极限值target - nums[i]可能会溢出。不过 LeetCode 这道题的测试数据一般不会触发这种情况面试时提一嘴“理论上需要考虑溢出”会让面试官觉得你思维缜密。5.3 面试官最喜欢追问的 4 个问题在原题做出来之后面试官通常不会善罢甘休。以下这几个追问我建议你提前准备好。追问一如果数组是有序的你还能用 O(1) 空间解决吗可以。有序数组可以改用双指针左指针指向开头右指针指向末尾。如果nums[left] nums[right] target说明太大右指针左移如果小于target说明太小左指针右移。时间复杂度 O(n)空间复杂度 O(1)。这也是“两数之和 II - 输入有序数组”这道变体题的解法。追问二如果要求返回所有不重复的组合而不仅仅是一组解怎么做原题说只有唯一解但变形题不一定。如果要求返回所有组合需要先排序然后用双指针或者哈希表去重。关键点在于跳过重复元素比如nums[i] nums[i-1]时直接跳过避免产生重复结果。追问三为什么哈希表查找是 O(1)因为哈希函数能直接把 key 映射到存储位置不需要遍历查找。但要注意这只是一个平均情况。如果哈希冲突非常严重退化到所有元素都在同一个桶里查找会退化为 O(n)。实际工程中Java 的 HashMap 在链表长度超过 8 时会转成红黑树就是为了避免这种退化。追问四空间复杂度是 O(n)你能不用额外空间吗可以但代价是时间复杂度回升到 O(n log n)。做法是先排序然后双指针。排序本身 O(n log n)双指针扫描 O(n)。这在空间有限制的场景下是更合理的方案。5.4 做题过程中我遇到过的 3 个真坑第一个坑是变量名。我在 Java 代码里写过MapInteger, Integer hash new HashMap();然后循环里又写了一个局部变量int hash 0;结果编译报错。后来我养成了一个习惯哈希表变量名统一叫hash或map循环内不要再用这个单词。第二个坑是 C 的unordered_map需要#include unordered_map。如果你漏了头文件编译器会报一个很长的错误新手很容易被吓住。顺带一提我建议把所有using namespace std;后面加一个空行养成规范代码的习惯。第三个坑是返回值问题。LeetCode 要求返回vectorint但有些 IDE 模板默认返回空数组很多同学写完return {};之后忘记改成正确答案提交时才发现输出为空。我个人的习惯是先在纸上或者注释里把思路写清楚再动手写代码这样能避免很多低级错误。6. 从两数之和延伸出去哈希表的应用地图6.1 一题通关三数之和与四数之和如果你完全吃透了朴素的两数之和完全可以自己推导出“三数之和”的解法。最简单的思路是外层循环固定第一个数内层就变成“在剩余区间里找两数之和等于 target - nums[i]”。这样三数之和的时间复杂度是 O(n²)四数之和是 O(n³)。但三数之和有个更经典的解法是先排序再用双指针能把空间复杂度压到 O(1)而且天然避开了重复组合的问题。所以你看两数之和不仅仅是“一道题”它是一个家族的原型题。理解了它后面很多题都是“换皮不换里”。6.2 哈希表在真实开发中的高光场景我经常跟初学者说哈希表不是只在算法题里用它是工程开发中最高频的数据结构之一。这里举三个最常见的场景缓存系统。给每个 key 存一个 value查询时直接 O(1) 命中比如 Redis 的字典、Java 的ConcurrentHashMap。去重。海量 URL 去重、用户 ID 防重复登录都可以用哈希表实现。词频统计。统计一篇文章里每个单词出现了多少次哈希表的 key 存单词、value 存次数一行代码就能完成累加。换句话说如果你在真实项目中写过一段“先查再存”的代码那你其实已经无意中掌握了两数之和的核心思想。6.3 数据结构与算法我更推荐的自学路线两数之和这道题在 LeetCode 的打卡表里通常被放在最前面但它涉及到的哈希表其实已经属于“中级数据结构”了。我的建议是不要死记题解而是按照“数组 - 链表 - 栈/队列 - 哈希表 - 树 - 图”的顺序学习数据结构的底层原理每学一种结构就去找对应的经典题练手。比如学完哈希表你可以顺手刷这几道题巩固LeetCode 1. 两数之和、LeetCode 217. 存在重复元素、LeetCode 242. 有效的字母异位词、LeetCode 49. 字母异位词分组。这几道题都不难但它们会从不同角度训练你对哈希表的敏感度。我个人在实际刷题中的体会是很多人不是不会用哈希表而是“想不到”用哈希表。这种“敏感性”只能靠量变引发质变。你见过了足够多的哈希表应用场景下一次再遇到“查找配对”“判断重复”“统计频率”这类问题时脑子里就会自动弹出哈希表这个选项。7. 实操心得这道题我刷了 5 遍才真正理解说了这么多最后分享一点我自己的学习经历。我第一次做两数之和时用的就是暴力解法当时觉得很简单甚至不理解为什么 LeetCode 会把这么简单的题放在第 1 位。后来面试被问了一次 “能不能不用 O(n²)”我才开始认真研究哈希表版本。坦白说光抄代码是没用的。我第 2 遍刷的时候把手写了三种语言的实现并且故意不参考题解逼自己在纸上画出哈希表的插入和查询过程。第 3 遍刷的时候我去读了 Java 8 中 HashMap 源码的一部分才理解了为什么哈希表的查找是平均 O(1) 而不是严格 O(1)。第 4 遍刷的时候我已经能不看任何资料就讲出“为什么用一遍遍历而不是两遍遍历”的差异。第 5 遍刷的时候我开始主动思考“如果数组非常大大到内存放不下哈希表怎么办”——这时候答案就变成了外部排序 双指针或者用位图法。这个过程让我意识到一道好题的价值不在于你能写出标准答案而在于你愿意围绕它往下挖多少层。哈希表的底层是数组和哈希函数哈希函数之上有负载因子和冲突解决策略再往上是各种语言中的实现差异和优化技巧最后还要结合具体的业务场景来权衡时间与空间的取舍。每一步都有大量值得研究的东西。如果你现在正处于刚接触算法的阶段我的建议非常简单不要急着追求刷题数量先把这一道两数之和吃透。把你的代码运行起来试着改一改测试数据试着用不同语言实现一遍试着回答上面那四个追问。当你能够像聊天一样跟别人讲清楚这道题的来龙去脉时恭喜你你已经正式迈入了算法的大门。接下来要做的就是沿着这条路一道题一道题地走下去。
返回列表