LeetCode两数之和怎么答?从暴力循环到哈希表与双指针的优化全解
发布时间:2026/10/2 9:48:30来源:尧图网络
1. 从一道“简单题”说起两数之和到底在考什么但凡刷过LeetCode的人不管你是刚入门还是已经面过十来家公司都绕不开这道题。Two Sum编号0001排在Hot 100的第一位难度标着Easy。很多新手点开一看两分钟暴力循环写完提交通过然后觉得自己已经会了。等真正到了面试现场面试官问一句“你这个解法还有没有优化空间”当场就卡住了。我做了这么多年技术面试官可以很负责任地讲**两数之和这道题Easy只是它伪装的外衣。**它真正考察的不是你会不会写循环而是三个层次的能力第一层能不能想到暴力解也就是两层循环遍历所有组合第二层能不能意识到暴力解的瓶颈并引入哈希表把时间复杂度从O(n^2)降到O(n)第三层能不能在面试官的追问下说清楚为什么先查哈希表再插入而不是先全部插入再查询以及在有序数组场景下双指针为什么能省空间。这三个层次恰好对应了工作里处理数据时的三种思维模式遍历思维、空间换时间思维、利用数据特性思维。所以我常说把这道题吃透比盲目刷十道同类型的Easy题有价值得多。另外这两年LeetCode本身也在持续迭代热榜题目的官方题解写得越来越详细周赛题目也跟着换了一轮又一轮。但万变不离其宗Hot 100里的基础题仍然是大厂笔试面试的高频起点。今天我借“两数之和”这一题把从读题到最优解的完整推理链条重新捋一遍顺便把那些教科书上不会写的坑、面试官心里默认你知道的细节一次性讲明白。2. 暴力解的思考路径与复杂度真相为什么O(n^2)让人不踏实先看题目描述用最简单的话说给定一个整数数组nums和一个整数目标值target要求在数组里找出两个数使得它们的和等于target返回这两个数的下标。注意每个输入只对应一个答案而且同一个元素不能用两次。2.1 暴力解不是蠢是思维的起点我第一次写这道题的时候脑子里冒出来的就是两层循环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 {}; }这个解法的正确性不用怀疑你拿任何测试用例去试它都能在有限时间内算出来。j从i1开始而不是从0开始这个细节要特别注意它的作用是避免出现(i, i)这种自己加自己的情况同时也避免(i, j)和(j, i)这种重复组合。那这个解法的复杂度是多少呢外层循环走n次内层循环平均走n/2次所以总比较次数大约是n*(n-1)/2时间复杂度O(n^2)空间复杂度O(1)。算法题里有一个朴素的判断标准如果n的规模是10^4以下O(n^2)通常还能扛住一旦n到了10^5甚至10^6O(n^2)就是灾难。2.2 一个具体的规模感练习拿LeetCode上这道题的约束来说nums.length的范围是10^4到10^5这个量级。假设n10^5两层循环意味着要做大约5×10^9次加法比较。在普通开发机上每秒能执行的简单操作大概是10^8到10^9量级也就是说暴力解可能要跑几秒到几十秒。这在在线评测系统里基本就会触发超时。有人说我机器快几秒也能等。但面试官看重的不是你这几秒而是你有没有意识到输入规模一旦扩大暴力解就会失效。实际工作里接口数据量、日志数据量、用户行为数据量哪个不是百万级起步如果写出来的算法都是O(n^2)产品一上线就等着报警吧。2.3 暴力解真正留下的遗产是什么虽然暴力解性能不行但它给我们提供了一个非常重要的东西——正确性基准。我在实际刷题和做代码评审的时候经常先用暴力解法跑通一个“参考实现”再拿它去验证优化解法的结果。如果优化解法和暴力解法的输出不一致那你就要排查是不是哈希逻辑写错了。这个习惯我强烈建议保留别总觉得暴力解没用它在测试阶段就是你的对照标尺。另外一个收获是暴力解的循环结构天然告诉了我们题目等价于在数组中寻找一对元素满足某个条件。这个“寻找”的动作才是后续所有优化的核心。**“查找”这个动作越频繁优化查找本身就越有价值。**顺着这个思路走你自然就会想到哈希表——因为哈希表就是为“快速查找”而生的数据结构。3. 哈希表解法的完整推导先查还是先存差别比想象中大3.1 从“找搭档”类比到哈希表思路暴力解为什么慢因为它每次都在数组里“重新找一遍”。我们可以把问题换一种表述遍历到第i个元素时我们已经知道目标值是target那么当前元素需要的“搭档”就是target - nums[i]。问题变成了之前遍历过的元素里有没有这个搭档这时候就轮到哈希表出场了。哈希表在C里是unordered_mapJava里是HashMapPython里是dict能把“查找某个值是否出现过”的时间从O(n)降到O(1)。这就好比你从在一个没有索引的图书馆里一本一本翻书变成了先查书目卡片直接定位到书架层。3.2 两种写法的细微差别网上常见的哈希表写法有两种。第一种是先建表再查询再插入第二种是先查询再插入。看代码写法一先查再放推荐vectorint twoSum(vectorint nums, int target) { unordered_mapint, int hash; for (int i 0; i nums.size(); i) { int need target - nums[i]; if (hash.find(need) ! hash.end()) { return {hash[need], i}; } hash[nums[i]] i; } return {}; }写法二先把所有元素放进表里再查vectorint twoSum(vectorint nums, int target) { unordered_mapint, int hash; for (int i 0; i nums.size(); i) { hash[nums[i]] i; } for (int i 0; i nums.size(); i) { int need target - nums[i]; if (hash.find(need) ! hash.end() hash[need] ! i) { return {i, hash[need]}; } } return {}; }两种写法都能过测试但里面藏着的边界逻辑完全不同。**写法二在遇到重复元素时后出现的下标会覆盖先出现的下标。**举个例子nums [3, 3]target 6。正确的答案应该是[0, 1]。但写法二在第一次遍历结束后hash[3]存的是第二次出现的下标1。第二遍循环时i0查到hash[3]1且1不等于0所以返回{0, 1}刚刚好碰到这只是侥幸。那如果是nums [3, 3, 3]呢hash[3]最终变成2。查询i0时返回{0, 2}表面上也符合要求因为确实有两个3加起来等于6。但如果题目限定“返回任意一对满足条件的下标”这种结果可以接受如果题目变种为“返回所有满足条件的下标对”写法二就直接崩了。写法一没有这个隐患因为它在遍历过程中同步查询和插入。看到当前元素时哈希表里只存储了当前元素之前出现过的元素。重复元素出现时之前那个还没被覆盖可以直接找到。这也是面试官希望你给出的写法。3.3 为什么顺序是“先查再插”不能反过来有同学会问那如果我先把自己插入哈希表再去查会出什么问题假设nums[0] 3target 6你先插入hash[3]0再查询need3结果发现hash[need]0也就是自己找到了自己下标相同这就违背了“同一个元素不能用两次”的约束。所以标准写法必须是先查再插。如果非要用“先插再查”就必须额外加一个hash[need] ! i的判断像写法二那样。多加判断条件反而更容易写错没有任何收益。3.4 复杂度分析与空间代价说明哈希表解法的时间复杂度是O(n)因为一次遍历每次查找和插入都是O(1)期望时间。空间复杂度是O(n)因为你最多需要存储n个元素的下标信息。这里的取舍非常明确**用额外的空间换来了时间的数量级下降。**在实际工程里这个取舍通常很划算因为现代服务器的内存是按GB算的而CPU的耗时直接影响响应速度。不过话说回来哈希表也不是银弹。如果数据量小到n10以下暴力解可能比哈希表更快因为哈希表本身有计算哈希值、处理冲突的开销。但这个优化极其微小日常刷题和面试都不必纠结。我的建议很简单写代码默认用哈希表因为它的时间收益稳定而且可控。4. 面试追问的隐藏考点有序数组、重复元素和双指针4.1 如果数组是有序的解法会变吗面试官经常在你说完哈希表解法后追加一个变体问题如果输入的数组是排好序的你还用哈希表吗这时你最好能答出双指针方案。双指针的思路用一句话说左指针指向数组开头右指针指向数组结尾计算nums[left] nums[right]。如果和大于target说明右边的数太大了右指针左移如果和小于target说明左边的数太小了左指针右移。直到两个指针相遇或者找到答案。vectorint twoSum(vectorint nums, int target) { int left 0, right nums.size() - 1; while (left right) { int sum nums[left] nums[right]; if (sum target) { return {left, right}; } else if (sum target) { left; } else { right--; } } return {}; }为什么双指针在这种情况下是更优解因为排序本身就是一种预处理排序的O(n log n)成本如果已经由题目条件省掉了那双指针又只需要O(n)时间和O(1)空间。相比哈希表O(n)的空间双指针把空间省到了极致。4.2 双指针的坑排序会弄丢下标这里必须强调一个非常关键的细节**两数之和原题要求返回的是原数组下标不是排序后的下标。**如果你先对数组排序那原来的下标信息就全乱了。直接排序再双指针返回的left和right是排序后的位置根本不是原数组的位置。所以面试里答双指针时你需要多解释一句如果原题还要求返回原下标就需要额外用一个结构保存原下标排序时带着原下标一起移动。比如C里可以定义vectorpairint, intpair的first存值second存原下标然后按值排序。这个额外的处理会让代码长度明显增加。一般面试官问这个变体真正想看的是你有没有理解“排序双指针为什么能在有序数据上省空间”这层逻辑而不是真的要你写一版完整代码。所以你可以口头回答思路然后主动提一句“如果要返回原下标我需要额外记录下标信息”这样会让面试官觉得你考虑到了实现层面的细节。4.3 重复元素与“多个答案”的处理差异原题明确说“假设每种输入只对应一个答案”所以遇到重复元素你不用纠结是否要输出所有组合。但很多人在工作场景里用这个思路时需求往往是“找出所有不重复的组合”。比如一个数组[1, 2, 2, 3]target是4那13和22都成立但两个2只算一种组合。这种变体哈希表就不再是首选了因为要去重你一般会先排序然后用双指针配合跳过重复元素。我建议把两数之和当成一个“概念原型”面试或做题时先确认清楚三个问题数组是否有序答案是否需要去重是否要求返回下标 这三个问题直接决定了你该选哪种解法。很多人刷题刷得多但面试还是翻车往往就是没有培养出“先确认需求再动手”的习惯。5. 边界条件与溢出陷阱LeetCode最爱埋的雷很多题解只讲主流程不讲边界但这恰恰是实际写代码最容易出错的地方。整理两数之和相关的高频边界我总结出三个雷区。5.1 整数溢出的隐患假设nums里的元素不是int而是更大的整数类型或者target特别大nums[i] nums[j]直接溢出变成负数或别的值暴力解法就可能误判。在C里int的范围是-2147483648到2147483647。如果nums[i] 2000000000nums[j] 2000000000相加直接溢出。这时候最稳的写法是把判断条件改成if (nums[i] target - nums[j])这个变形把“两数相加等于target”转成了“一个数等于target减去另一个数”避免了加法的溢出风险。在实际工程里数值溢出是非常隐蔽的bug来源测试用例不覆盖到就永远发现不了。5.2 空数组与单元素数组当nums为空或只有一个元素时正确答案应该是空结果。两种主流解法的循环都能正确处理因为空数组根本进不了循环单元素数组在哈希表解法里第一次循环时哈希表为空查不到需要就直接插入自己的值然后循环结束也不会误命中。所以这一条其实不用改代码但要心里有数。5.3 负数与零的参与nums里元素完全可能是负数比如[-3, 4, 3, 90]target是0。负数参与求和逻辑上没有任何特殊之处仍然找need target - nums[i]即可。但有些初学者会在脑子里默认数组都是正数导致手动模拟时出错。写测试用例时千万记得覆盖负数和零。我把边界用例列成一张表方便自测场景输入target期望输出常规[2,7,11,15]9[0,1]重复元素[3,3]6[0,1]负数[-3,4,3,90]0[0,2]零参与[0,4,3,0]0[0,3]无解[1,2,3]7空大数[2000000000,2000000000]4000000000注意溢出其中“大数”这一行特别说明一下如果像C里int存不下4000000000这个target本身就无法用int表示。所以LeetCode原题一般不会给这种用例但你自己设计单元测试时可以用long long类型的数组来做压力测试。6. 从Hot 100到周赛变体一道题背后的系列扩展LeetCode周赛430那一阵子我注意到讨论区里有一个高频现象很多人做周赛题打出“两数之和”的哈希表模板就开始套结果发现变体题对不上。比如那道被热议的“073爱吃香蕉的狒狒”表面上是二分答案套路的题跟两数之和八竿子打不着但底层思维其实一脉相承——核心都是通过某种手段把“查找/验证”这个动作变快。6.1 两数之和与三数之和、四数之和的递进关系两数之和学完下一个经典延伸是三数之和。三数之和的难点不在哈希表而在去重。标准解法是排序双指针外层固定一个数内层用双指针找剩下的两个数。如果直接套两数之和的哈希表思路去重逻辑会非常痛苦。四数之和则是在三数之和基础上再来一层循环本质是“排序 多层双指针”。这类题看起来是有套路的但套路的前提是你真正理解了双指针为什么能在有序数组上高效工作而不是死记代码。6.2 哈希表思想在工程里的泛化使用跳出刷题两数之和的“查表法”思维在工程里非常常见。举几个例子缓存设计你有一个计算代价很高的函数输入参数和计算结果之间建立映射下次相同参数直接取缓存这就是查表。接口鉴权用户token到用户信息的映射本质上就是一张哈希表查得到就放行查不到就拒绝。数据去重给一批日志去重时用一个集合记录已经出现过的关键ID新ID查不到才写入。这就是“先查后插”逻辑。所以你刷这道题不只是为了面试更是为了建立一个高频的工程思维习惯。我认识不少转行做后端的朋友他们简历上写着“熟悉常用数据结构”但面试时连两数之和的空间复杂度都讲不清楚。这种基础不扎实后面补起来很困难。6.3 周赛热题“073爱吃香蕉的狒狒”带来的启发周赛题“爱吃香蕉的狒狒”其实是在提醒大家LeetCode热榜的题目在持续扩充但算法内核不会脱离几个常见范式。那道题的场景是狒狒吃香蕉每小时吃若干根求能在规定时间内吃完的最小速度。它考的是“在单调区间上二分查找”核心是判断函数canFinish怎么设计。这种题目跟两数之和的关系在哪里两者都要求你先把一个“判定条件”写清楚两数之和的判定是两个数相加是否等于target狒狒的判定是某个速度能否在H小时内吃完。一旦你习惯于把问题抽象成“枚举或查找判定函数”再难的变体题也会变得有抓手。所以我建议大家在刷Hot 100时不要只看题目顺序而是按“底层思想”分类刷。两数之和归入“查找与映射”三数之和归入“排序双指针与去重”爱吃香蕉的狒狒归入“二分答案与判定函数”。分类之后你会发现题和题之间的联系远比想象中紧密。7. 实践建议如何用这道题建立自己的刷题闭环最后从我个人的刷题和面试经验出发给你一套可以直接照做的练习方案。7.1 五分钟法则先想再看题解很多人在LeetCode上刷题有个坏习惯题目读完大脑空白马上去看讨论区。看懂了觉得自己“会了”关掉页面下一篇。这种“假性学习”几乎等于白刷。我建议强迫自己执行五分钟法则读完题后不管想不想得出来先自己动手写哪怕写一个错误的暴力解也要写。五分钟后如果还没有任何思路再去看题解。两数之和这道题尤其合适因为暴力解几乎人人能写。写完之后停下来问自己三个问题这个解法的瓶颈在哪里每次查找的时间能不能降低我需要额外空间换时间吗 这一套自问自答就是面试官在屏幕另一头等着听的东西。7.2 单元测试比提交通过更重要在本地编辑器里写完解法后我建议自己多补几组测试用例不只依赖LeetCode自带的那几个。把我刚才列的表里的数据手动跑一遍特别是有负数、重复元素、无解情况的用例。LeetCode原题保证有唯一解但你自己练习时加上“无解返回空”的用例能让你写出更健壮的代码。很多人在白板面试里因为没考虑无解情况而被扣分就是这个环节没练到家。7.3 用语言特性优化代码的细节不同的语言表达哈希表的方式不同细节上也有差异C里map是红黑树实现查找O(log n)unordered_map才是哈希表查找O(1)。除非你要按顺序遍历键值否则一律用unordered_map。Python的dict天然就是哈希表直接用。但要注意dict.get(key)返回的是None时你要判断该值是否可能是合法的下标0所以我建议用if need in dict这种写法而不是if dict.get(need)。Java里HashMap允许键值对为null用之前想清楚null会不会引发歧义。这些都是很小的细节但面试时如果能在写完代码后主动提“我这里用unordered_map是因为它平均O(1)查找map虽然有序但没必要”会很加分。7.4 建立错题记录的三个字段题目刷完可以顺手建立一份错题记录。格式不必复杂三个字段够了**错误点、原因、下次怎么做。**比如我在两数之和上曾经踩过“先插后查导致重复元素被覆盖”的坑那我就会写错误点是插入顺序原因是没理解“当前元素之前”这个语义下次先画一个简单的数据模拟再写代码。这个习惯坚持两三个月你会明显感觉到写代码时思路更清晰、犯错的次数在减少。我自己的体会是两数之和这道题的价值远不是那几分钟的AC而在于它强迫你去思考“查找”这个基本操作。无论你以后做后端、做算法工程师、还是搞数据开发你早晚会在某个系统里面对类似的查询优化问题。到时候你可能会想起这个经典题目然后在键盘上敲下一个哈希表顺手解决一个线上性能问题——这大概就是刷题的意义所在。
网站建设高端定制企业官网