☰
两数之和C++解法拆解:从暴力枚举到哈希表优化
2026/10/12 10:46:51 网站建设 项目流程

两数之和这道题,在力扣平台上的编号是第一题,路径是“力扣——1”,也是绝大多数人打开刷题生涯的那扇门。它本身并不难,但作为C++入门选手,第一道题往往决定了你后续刷题的姿势和手感,所以我一直觉得有必要把它拆透了讲。这篇不说废话,直接围绕题面、三种层级的解法演进、C++代码逐行实现、本地环境配置、常见坑点展开,把这道题吃干抹净。

1. 题面与核心需求拆解

1.1 题目到底在问什么

原题是这样的:给定一个整数数组nums和一个整数目标值target,请在数组中找出和为目标值target的那两个整数,并返回它们的数组下标。

示例:

输入:nums = [2,7,11,15], target = 9 输出:[0,1] 解释:因为 nums[0] + nums[1] == 9,返回 [0, 1]

注意这里问的不是“值”,而是“下标”。很多人第一次做的时候答案对了,但输出的是数组元素本身,结果直接判错。力扣要求返回的是vector<int>,里面装的是两个索引,这个细节必须盯死。

题目还给了两个隐含保证:一是每种输入只会对应一个答案,这意味着你不用处理“多组答案”的情况,看到一组配直接返回就行;二是数组中同一个元素不能使用两遍。这句话很多人没读懂,我解释一下,它不是说你不能选两个相同的值,而是说一个下标不能用两次。比如nums = [3, 3],target = 6是合法输入,答案是[0, 1],两个3是不同的下标;但如果你拿着同一个下标算两次,比如nums[i] + nums[i],那是被禁止的。

1.2 从题面里挖出的约束条件

这道题的输入规模在力扣上现在已经加强过了,数组长度最多能到10^4甚至10^5级别。这意味着什么?意味着 O(n^2) 的暴力解在最坏情况下要做大约 10^8 到 10^10 次操作,放到老版本的数据上也许能蒙混过关,放到现在的评测数据里基本会超时。

目标值target和数组元素都是整数,可正可负,不排除有负数的情况。很多人拿到题目就默认所有数都是正的,结果测试一跑就挂在负数用例上。实际上target - nums[i]完全可能是负数,这不影响哈希表的查找,但会影响你自己手写“剪枝”之类的优化时的心态。

还有一个隐含信息:返回值顺序没有要求,你可以返回[0, 1]也可以返回[1, 0]。这个自由度给了哈希表解法一定的容错空间,也让我们在实现时不用纠结先存谁后取谁。

2. 解法演进:从暴力到哈希表

2.1 暴力枚举:最直觉但最容易超时的方案

很多人的第一反应是两层循环扫描,外层固定一个数,内层从它后面找配对的数。代码大概长这样:

class Solution { public: vector<int> twoSum(vector<int>& 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 {}; } };

这个方案的时间复杂度是 O(n^2),空间复杂度 O(1)。它简单、直观、不容易错,但有一个致命问题:数组一大就歇菜。力扣现在的数据规模下,n = 10^5时n^2就是 10^10,哪怕每次比较只消耗几个时钟周期,整体也要几十秒量级,评测系统直接判超时。

我见过不少初学者死磕暴力解,提交超时后一脸茫然,其实这里需要的不是“更快的暴力”,而是换一种查找思路。暴力解慢的根本原因是:内层循环每次都在线性扫描,判断“有没有一个数等于 target - nums[i]”这件事花费了 O(n) 的时间。如果我们把“查找某个值是否存在”的时间从 O(n) 降到 O(1),总复杂度自然就下来了。

2.2 哈希表:用空间换时间的经典思路

哈希表,或者说 C++ 里的unordered_map,干的事情就是把值映射到它的位置。你可以理解为:它给所有数字建了一张“索引表”,每次想找某个数有没有出现过,按键查一下就行,平均只需 O(1) 时间。

基于这个思路,算法变成:

  • 遍历数组,每到一个元素nums[i],先算complement = target - nums[i];
  • 去哈希表里查这个complement是否存在;
  • 存在,说明之前已经遍历到过这个数,直接返回它对应的下标和当前下标i;
  • 不存在,就把nums[i]和它的下标i存进哈希表,继续遍历。

这里的关键是先查后存,顺序不能乱。原因后面在避坑部分会详细说,这里先记住这个原则。

2.3 为什么一次遍历就够

很多人对“一次遍历”感到不踏实,总觉得得先把全部元素放进哈希表,再从头查一遍才能保证找全。其实大可不必。

举个例子,nums = [2, 7, 11, 15],target = 9。遍历到2的时候,complement = 7,哈希表里没有 7,于是把2 -> 0存进去;遍历到7的时候,complement = 2,一查哈希表,2在 0 号位存着呢,直接返回[0, 1]。

你发现没有,当你遍历到任何一个元素的时候,它前面的所有元素都已经入表了。如果配对的数是它前面的某一个,那么一定能查到;如果配对的数在它后面,那现在是查不到的,但等遍历到后面那个数的时候,当前元素已经入表,到时照样能查到。所以一次遍历就够了,不存在漏掉的情况。

这个“一边查一边存”的模式,在力扣里大量出现,比如后面会遇到的三数之和、两数之和系列变体,本质上都是这个套路。把第一题的哈希思维吃透,后面会轻松很多。

3. C++代码的逐行实现与细节

3.1 完整代码和提交模板

我们直接上最终版本,这是我在本地和力扣上都验证过的写法:

#include <vector> #include <unordered_map> class Solution { public: vector<int> twoSum(vector<int>& nums, int target) { unordered_map<int, 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 {}; } };

在力扣的提交环境里,vector和unordered_map的头文件已经默认包含,所以很多题解里不会写#include。但你在本地跑的时候必须自己补上,否则编译直接报错。我建议即使是在力扣页面写代码,也养成把头文件写全的习惯,这样切到本地调试的时候不用额外改。

这里有几个C++的点需要解释一下,尤其是新手容易卡住的地方。

3.2 为什么用 unordered_map 而不是 map

C++ 标准库里有两个容器都能做键值映射,一个是map,底层是红黑树,插入查找都是 O(log n) 的;另一个是unordered_map,底层是哈希表,插入查找平均 O(1)。

这道题只要求“能不能快速找到某个值”,不要求按顺序遍历键。所以选择unordered_map是更合适的,它在n较大时优势明显。map虽然也能通过,但 O(log n) 和 O(1) 在数据量大的时候差距会体现出来。

还有一个隐藏区别是头文件不同:unordered_map需要#include <unordered_map>,map需要#include <map>。别搞混。

3.3 返回值与边界处理

函数签名是vector<int> twoSum(vector<int>& nums, int target),返回类型是vector<int>。所以我们返回的时候可以用花括号初始化列表:

return {it->second, i};

这个写法是 C++11 之后支持的,本质上是用initializer_list构造一个临时vector并返回。如果你用的是更老的编译器标准,可能会报错,所以本地编译的时候我建议至少开-std=c++11,我个人习惯直接用 C++17。

至于最末尾的return {};,其实是返回一个空 vector。正常情况下不会执行到这里,因为题目保证有且只有一个答案,但这行代码能让编译器满意,覆盖掉“函数可能没有返回值”的告警。有些强迫症可能会直接写return {-1, -1};或者return {0, 0};,也行,不影响正确性,就是语义上不如空 vector 干净。

再强调一下auto it = hash.find(complement)。find返回的是一个迭代器,类型写全了是unordered_map<int, int>::iterator,太长了,用auto省心。判断是否存在就看它是否等于hash.end()。这里新手最容易犯的错是:直接写hash[complement],结果查一个不存在的键时,operator[]会悄悄把这个键插入,并赋默认值 0,导致下标返回 0,逻辑直接搞乱。记住:查用find,插用operator[]或insert,不要拿operator[]干查找的活。

4. 实操过程:从本地环境到提交通过

4.1 把 VSCode 调成刷题顺手的环境

力扣网页端可以直接写代码提交,但很多人还是喜欢在本地跑。原因很简单:本地能断点调试、能加打印、能反复实验,比在网页端看着报错干瞪眼强太多。虽然题目写完了都能通过,但调试的过程才是真正学到东西的过程。

我用的是 VSCode,配合 C/C++ 扩展,配置一次之后刷题非常顺手。你需要准备:

  • VSCode
  • 一个 C++ 编译器,Windows 上可以用 MinGW-w64 或者 MSVC,macOS 上可以用 Xcode Command Line Tools 自带的 clang
  • C/C++ 扩展

编译器安装完之后,在终端里输入g++ --version,能输出版本号就说明环境没问题。

然后要创建一个tasks.json,负责编译。我通常的做法是给刷题建一个单独的目录,比如leetcode,里面每个题一个文件夹,第1题就在0001_two_sum下,放一个main.cpp。

main.cpp里除了放Solution类,再写一个简单的测试main函数,把示例和边界用例都跑一遍:

int main() { Solution sol; vector<int> nums1 = {2, 7, 11, 15}; vector<int> res1 = sol.twoSum(nums1, 9); for (int x : res1) cout << x << " "; // 输出 0 1 cout << endl; vector<int> nums2 = {3, 3}; vector<int> res2 = sol.twoSum(nums2, 6); for (int x : res2) cout << x << " "; // 输出 0 1 cout << endl; vector<int> nums3 = {-3, 4, 3, 90}; vector<int> res3 = sol.twoSum(nums3, 0); for (int x : res3) cout << x << " "; // 输出 0 2 cout << endl; return 0; }

至于编译运行为什么不直接在终端敲命令?其实可以敲,但不是每个人都想背一长串g++ -std=c++17 main.cpp -o main。配置好tasks.json之后,按Ctrl+Shift+B就能一键编译,再按 F5 就能启动调试。VSCode 的launch.json里把externalConsole或者internalConsole配好,命中断点之后能清清楚楚看到hash表里存了什么值、complement是多少、迭代器指向哪里。

这个调试过程对新手尤其有帮助。我自己刷题前两年,相当一部分容易混淆的概念,比如迭代器失效、引用传参、返回值优化,都是在断点面板里反复看出来的。

4.2 关键的调试用例设计

很多人刷题只跑一遍示例就提交,过了就算数,没过就干瞪眼。稍微好一点的做法是准备几组典型的测试数据,每组数据都带着不同的边界含义。

这道题我建议至少准备四组:

测试用例target期望结果验证点
[2, 7, 11, 15]9[0, 1]官方示例,标准情况
[3, 3]6[0, 1]两个相同数值,不同下标
[-3, 4, 3, 90]0[0, 2]存在负数,target减完为负
[1]2空数组过短,题目不会出现但代码要兜底

第一组测的是最基本的功能;第二组测的是“同一元素不能使用两遍”这个约束是否被正确处理;第三组测的是负数键在unordered_map里能否正常索引;第四组是防御性测试,虽然题目保证一定有答案,但万一你代码里存在空指针或者越界风险,这组能帮你提前暴露。

4.3 如果一切顺利,提交时还有什么要检查

本地跑通了之后,去力扣页面上提交,有几点容易忽略:

第一,提交的时候类名和函数签名必须和题目完全一致,大小写、参数类型、返回类型都不能改。力扣的评测系统是拿固定的测试代码调用你的Solution::twoSum,你改了签名就编译不过。

第二,返回值顺序没有要求,但有些同学本地测试时自己用vector<int>比较结果严格匹配[0, 1],由于题目允许[1, 0],这种比较方式并不严谨。如果你想写自动化验证,最好把结果排序后再比较,或者用set存下两个值再比对。

第三,提交前把头文件相关的#include删掉其实不影响,因为力扣环境自动包含了。但如果你连std::前缀都省了,可能会导致命名冲突。稳妥起见,全部按标准写法来。

5. 常见问题与避坑实录

5.1 最容易踩的几个坑

第一个坑:把整个数组先塞进哈希表,再开始查找。这个做法错在哪?看这个用例:nums = [3, 3],target = 6。如果你第一步把整个数组遍历一遍存进哈希表,因为键是值、值是下标,两个 3 的键相同,后面的下标会把前面的覆盖掉,最终hash[3] = 1。然后你从头再来一遍,遍历到下标 0 时,complement = 3,一查哈希表发现 3 存在,返回的答案是[1, 0]。这道题答案允许任意顺序,所以这个用例侥幸通过了。但换个场景,nums = [3, 2, 3],target = 6,同样先塞后查,最终hash[3] = 2,遍历到下标 0 时返回[2, 0],依然能通过。看起来好像总是能通过?但这不是因为逻辑正确,而是因为你的值被“覆盖成最后一个同值下标”后,碰巧仍满足题目“返回任意顺序”的要求。可一旦题目改成“下标也必须按原顺序返回”或者存在多个同值元素时,这个覆盖就会造成错误。所以规范的做法永远是“先查再存”,这不仅是性能问题,更是逻辑严谨性的问题。

第二个坑:用hash[complement]判断键是否存在。我上面提过,operator[]在键不存在时会自动插入默认值 0。假设nums = [2, 7],target = 9这种简单例子,第一次循环查complement = 7不存在,hash[7]会被插入一个值为 0 的条目。第二次循环查到 2,它返回的哈希表值是 0,恰好是正确下标,所以你误以为代码没问题。但假如complement的值在数组里根本不存在,hash[complement]会返回 0,让你误以为下标 0 就是答案,直接返回了错误的[0, i]。这就是为什么必须用find,实在要用count也可以,但不要直接用operator[]查。

第三个坑:返回下标时把顺序写反。正确逻辑是:complement存在哈希表里,it->second是配对值之前入表时的下标,i是当前下标。有些同学逻辑跑通后,下意识写成return {i, it->second},测试用例里如果两个下标恰好是对称的,可能也能通过,但一旦顺序反了且测试用例要求特定顺序,就会挂。这道题不要求顺序,但为了养成好习惯,还是按照“先查到的放前面,当前的放后面”来写。

第四个坑:认为nums.size()的返回值可以直接拿来当int用。size()返回的是size_t,在 64 位系统上是unsigned long long。刷题时基本不会出问题,但如果你写for (int i = 0; i < nums.size(); ++i),编译器会有符号比较的告警。我一般直接写int n = nums.size();后再用n循环,或者用size_t i。

5.2 话不多说,关联扩展题该往哪走

两数之和只是一系列问题的起点。刷完这道题,你最应该趁热打铁做以下几类:

  • 力扣 167:两数之和 II - 输入有序数组。同样是两数之和,但数组有序,可以用双指针做到 O(1) 空间,这和哈希表解法思路完全不同。
  • 力扣 15:三数之和。把“两个数的和”扩展成“三个数和为0”,核心思路是先排序再双指针,哈希表的地位被削弱了。
  • 力扣 121:买股票的最佳时机。这个题不是两数之和的变体,但同样是“一次遍历维护状态”的经典场景,和哈希表的“边查边存”有异曲同工之处。

我一直觉得,刷题不能只满足于 AC 一道题。把一道题吃透,再沿着它的变体往下走,整个知识网络就串起来了。两数之和背后那个“查找表”的抽象,其实在真实开发中也经常用到,比如缓存键值对、去重、索引构建,本质都是哈希表的应用。

6. 一点私货:我的刷题习惯

说了这么多,最后分享一个我自己的习惯。每次 AC 之后,我从来不会急着做下一道,而是会花十分钟做三件事:第一,把题解里最优解法的思路不看代码地重新写一遍;第二,把这个解法的时间空间复杂度写在题目标题旁边;第三,想想如果题目条件变了,比如要求返回所有下标、要求不能用额外空间、要求数组有序,解法会不会变化,会怎么变化。

这个习惯让我在刷到第 200 题左右的时候,明显感觉到套路感增强了。很多题看一眼大概率知道用哈希表还是双指针还是贪心,不是因为记了答案,而是因为每一次复盘都在强化思路的连接。

两数之和是第一题,也是很多 C++ 同学进入算法世界的第一个入口。希望这篇拆解能帮你把这道题理解得比“背答案”更透一点。把第一个台阶踩稳了,后面的楼梯就好走了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询