
两数之和这道题在力扣平台上的编号是第一题路径是“力扣——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]注意这里问的不是“值”而是“下标”。很多人第一次做的时候答案对了但输出的是数组元素本身结果直接判错。力扣要求返回的是vectorint里面装的是两个索引这个细节必须盯死。题目还给了两个隐含保证一是每种输入只会对应一个答案这意味着你不用处理“多组答案”的情况看到一组配直接返回就行二是数组中同一个元素不能使用两遍。这句话很多人没读懂我解释一下它不是说你不能选两个相同的值而是说一个下标不能用两次。比如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: 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 {}; } };这个方案的时间复杂度是 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: 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 {}; } };在力扣的提交环境里vector和unordered_map的头文件已经默认包含所以很多题解里不会写#include。但你在本地跑的时候必须自己补上否则编译直接报错。我建议即使是在力扣页面写代码也养成把头文件写全的习惯这样切到本地调试的时候不用额外改。这里有几个C的点需要解释一下尤其是新手容易卡住的地方。3.2 为什么用 unordered_map 而不是 mapC 标准库里有两个容器都能做键值映射一个是map底层是红黑树插入查找都是 O(log n) 的另一个是unordered_map底层是哈希表插入查找平均 O(1)。这道题只要求“能不能快速找到某个值”不要求按顺序遍历键。所以选择unordered_map是更合适的它在n较大时优势明显。map虽然也能通过但 O(log n) 和 O(1) 在数据量大的时候差距会体现出来。还有一个隐藏区别是头文件不同unordered_map需要#include unordered_mapmap需要#include map。别搞混。3.3 返回值与边界处理函数签名是vectorint twoSum(vectorint nums, int target)返回类型是vectorint。所以我们返回的时候可以用花括号初始化列表return {it-second, i};这个写法是 C11 之后支持的本质上是用initializer_list构造一个临时vector并返回。如果你用的是更老的编译器标准可能会报错所以本地编译的时候我建议至少开-stdc11我个人习惯直接用 C17。至于最末尾的return {};其实是返回一个空 vector。正常情况下不会执行到这里因为题目保证有且只有一个答案但这行代码能让编译器满意覆盖掉“函数可能没有返回值”的告警。有些强迫症可能会直接写return {-1, -1};或者return {0, 0};也行不影响正确性就是语义上不如空 vector 干净。再强调一下auto it hash.find(complement)。find返回的是一个迭代器类型写全了是unordered_mapint, 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 或者 MSVCmacOS 上可以用 Xcode Command Line Tools 自带的 clangC/C 扩展编译器安装完之后在终端里输入g --version能输出版本号就说明环境没问题。然后要创建一个tasks.json负责编译。我通常的做法是给刷题建一个单独的目录比如leetcode里面每个题一个文件夹第1题就在0001_two_sum下放一个main.cpp。main.cpp里除了放Solution类再写一个简单的测试main函数把示例和边界用例都跑一遍int main() { Solution sol; vectorint nums1 {2, 7, 11, 15}; vectorint res1 sol.twoSum(nums1, 9); for (int x : res1) cout x ; // 输出 0 1 cout endl; vectorint nums2 {3, 3}; vectorint res2 sol.twoSum(nums2, 6); for (int x : res2) cout x ; // 输出 0 1 cout endl; vectorint nums3 {-3, 4, 3, 90}; vectorint res3 sol.twoSum(nums3, 0); for (int x : res3) cout x ; // 输出 0 2 cout endl; return 0; }至于编译运行为什么不直接在终端敲命令其实可以敲但不是每个人都想背一长串g -stdc17 main.cpp -o main。配置好tasks.json之后按CtrlShiftB就能一键编译再按 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你改了签名就编译不过。第二返回值顺序没有要求但有些同学本地测试时自己用vectorint比较结果严格匹配[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 同学进入算法世界的第一个入口。希望这篇拆解能帮你把这道题理解得比“背答案”更透一点。把第一个台阶踩稳了后面的楼梯就好走了。