
简介这份资源是面向C语言初学者与LeetCode刷题者的「两数之和」题解源码包针对在整数数组中查找和为目标值的两个元素这一经典算法问题提供可直接编译运行的完整工程。压缩包共35个文件约1.05MB以Visual Studio工程文件为主包含sln解决方案、vcxproj项目配置、cpp源码、rc资源脚本以及pdb、obj、ilk等编译调试中间产物和tlog、log日志文件方便读者在本地还原开发环境、逐步调试与验证结果。资源描述中给出了nums[2,7,11,15]、target9返回[0,1]的示例并强调每种输入只对应一种答案、同一元素不可重复使用读者可据此理解暴力枚举与哈希思路的差异。目前已有5676人学习下载适合刚接触C语言与算法题、需要一份可运行参考工程来对照练习、排查编译配置问题的读者使用。1. 两数之和为什么成了 C 语言学习者的第一道分水岭很多人第一次在 LeetCode 上点开「两数之和」看到题目描述里那句「给定一个整数数组和一个目标值返回和为目标值的两个数的下标」心里想的是这不就是两层循环的事吗。然后提交超时或者勉强过了但心里发虚。问题不在于这道题本身有多难而在于它恰好卡在「会写 C 语法」和「会用 C 解决实际问题」之间那道坎上。C 语言没有哈希表这种内置结构你得自己决定用什么方式把查找从 O(n) 降到 O(1)。这个决策过程就是这道题真正的价值。它逼着你面对内存布局、指针操作、冲突处理这些在脚本语言里被隐藏起来的东西。如果你正在学 C或者准备用 C 去刷算法题这道题是绕不过去的起点。下面我从暴力解法讲到哈希表实现把每一步的取舍和坑都摊开说。2. 从暴力枚举到哈希表两数之和的三种 C 语言实现路径2.1 暴力双重循环能过但别停在这最直接的写法就是两层 for 循环外层固定一个数内层找另一个数。代码短逻辑清晰适合刚接触数组和循环的人建立信心。// 暴力解法时间复杂度 O(n^2)空间复杂度 O(1) int* twoSum(int* nums, int numsSize, int target, int* returnSize) { // 分配返回数组调用者负责释放 int* result (int*)malloc(2 * sizeof(int)); *returnSize 2; for (int i 0; i numsSize; i) { for (int j i 1; j numsSize; j) { // 注意 j 从 i1 开始避免重复使用同一元素 if (nums[i] nums[j] target) { result[0] i; result[1] j; return result; } } } *returnSize 0; return NULL; }这段代码里有两个细节值得说。第一j从i1开始而不是从 0 开始因为题目要求同一个元素不能使用两次从i1开始天然避免了这种情况。第二returnSize是 LeetCode 判题系统用来知道返回数组长度的参数必须设置。暴力解法在数组长度小于 1000 时基本能过但一旦数据量上去O(n^2) 的耗时就会暴露。我一般会把它当作验证用例正确性的基准而不是最终提交版本。2.2 排序加双指针思路对但下标会丢有人会想到先排序再用双指针从两端向中间逼近。这个思路本身没问题时间复杂度 O(n log n)比暴力快。但在「两数之和」这道题里有个致命问题题目要求返回的是原数组的下标排序之后下标就乱了。如果你非要用这个思路得额外记录每个元素的原下标比如用一个结构体数组把值和原下标绑在一起排序。但这样代码复杂度上去了而且排序本身也有开销。常见做法是如果题目只要求返回值而不要求下标双指针是很好的选择但 LeetCode 这道题明确要求下标所以双指针不是最优路径。我见过不少人在这一步翻车提交后发现返回的下标对不上排查半天才发现是排序惹的祸。2.3 哈希表实现C 语言里怎么手搓一个能用的C 语言标准库没有哈希表所以你得自己实现一个。对于「两数之和」这道题我们需要的哈希表功能很单一插入键值对、根据键查找。不需要删除不需要遍历。这大大简化了实现难度。下面是一个基于链地址法的哈希表实现完整可编译运行。#include stdlib.h #include string.h #define HASH_SIZE 10007 // 选一个质数作为桶的数量减少冲突 // 哈希表节点存储键值对和冲突链的下一个节点 typedef struct HashNode { int key; // 数组元素的值 int value; // 该值对应的原数组下标 struct HashNode* next; // 冲突链 } HashNode; // 哈希表结构桶数组 typedef struct { HashNode** buckets; } HashMap; // 哈希函数取模法处理负数 static int hashFunc(int key) { // 确保负数也能得到正数索引 int h key % HASH_SIZE; if (h 0) h HASH_SIZE; return h; } // 创建哈希表 HashMap* hashMapCreate() { HashMap* map (HashMap*)malloc(sizeof(HashMap)); map-buckets (HashNode**)calloc(HASH_SIZE, sizeof(HashNode*)); return map; } // 查找返回对应值的下标找不到返回 -1 int hashMapGet(HashMap* map, int key) { int idx hashFunc(key); HashNode* node map-buckets[idx]; while (node) { if (node-key key) { return node-value; } node node-next; } return -1; } // 插入头插法简单高效 void hashMapPut(HashMap* map, int key, int value) { int idx hashFunc(key); HashNode* node (HashNode*)malloc(sizeof(HashNode)); node-key key; node-value value; node-next map-buckets[idx]; map-buckets[idx] node; } // 释放哈希表内存 void hashMapFree(HashMap* map) { for (int i 0; i HASH_SIZE; i) { HashNode* node map-buckets[i]; while (node) { HashNode* tmp node; node node-next; free(tmp); } } free(map-buckets); free(map); } // 主解法一次遍历 哈希表 int* twoSum(int* nums, int numsSize, int target, int* returnSize) { HashMap* map hashMapCreate(); int* result (int*)malloc(2 * sizeof(int)); *returnSize 2; for (int i 0; i numsSize; i) { int complement target - nums[i]; int idx hashMapGet(map, complement); if (idx ! -1) { // 找到了配对先放较小下标 result[0] idx; result[1] i; hashMapFree(map); return result; } // 没找到就把当前元素插入哈希表 hashMapPut(map, nums[i], i); } *returnSize 0; hashMapFree(map); free(result); return NULL; }这段代码的核心逻辑是遍历数组对每个元素计算它需要的「补数」然后去哈希表里查这个补数是否已经出现过。如果出现过说明之前某个元素和当前元素之和等于目标值直接返回两个下标。如果没出现就把当前元素和它的下标存入哈希表继续往后走。参数说明HASH_SIZE选 10007 是因为它是质数取模后分布更均匀。hashFunc里处理负数是因为数组元素可能为负C 语言里负数取模结果仍为负直接用作数组下标会越界。hashMapPut用头插法因为这道题不需要保持插入顺序头插 O(1) 最简单。hashMapGet返回 -1 表示未找到因为下标不可能是 -1可以安全用作哨兵值。时间复杂度 O(n)每个元素最多被插入一次、查找一次。空间复杂度 O(n)最坏情况下所有元素都进哈希表。这个版本在 LeetCode 上可以稳定通过而且不依赖任何非标准库。3. 哈希表实现里最容易翻车的四个参数与边界3.1 哈希表大小选多少10007 不是随便写的哈希表桶的数量直接决定了冲突概率。如果选 100当数组有 10000 个元素时平均每个桶挂 100 个节点查找退化成链表遍历O(1) 变成 O(n)。选 10007 是因为它大于常见测试用例的数组长度同时是质数能让取模结果更均匀。但也不是越大越好。桶数组本身要占内存calloc(HASH_SIZE, sizeof(HashNode*))在 64 位系统上每个指针 8 字节10007 个桶就是约 80KB完全可以接受。如果选 1000003就是 8MB对于一道算法题来说浪费了。我一般会根据题目数据范围来定如果数组长度上限是 10^4选 10007 刚好如果上限是 10^5选 100003。原则是桶的数量略大于最大元素数量且取质数。3.2 负数取模C 语言和数学定义的差异在数学里-1 mod 10007 等于 10006。但在 C 语言里-1 % 10007 等于 -1。如果你直接拿这个 -1 去索引数组程序会直接段错误。这是很多人第一次写哈希表时必踩的坑。解决办法就是在取模后加一个判断如果结果小于 0就加上HASH_SIZE。这个操作在hashFunc里完成所有调用方都不用关心负数问题。注意不要写成abs(key) % HASH_SIZE因为abs(INT_MIN)是未定义行为而加法修正没有这个问题。3.3 返回下标的顺序LeetCode 不检查但你自己要清楚题目没有要求返回的下标必须按升序排列但如果你用「先查后插」的策略找到时idx一定小于i因为idx是之前插入的。所以result[0] idx; result[1] i;天然就是升序。如果你反过来写虽然也能过但可读性差一些。另外注意当数组中有重复元素时比如[3, 3]目标值 6第一次遇到 3 时哈希表里没有 3插入(3, 0)第二次遇到 3 时查补数 3找到下标 0返回[0, 1]。逻辑正确。如果先插后查就会返回[1, 0]虽然也对但顺序反了。所以「先查后插」是更稳妥的写法。3.4 内存泄漏LeetCode 不查但面试会问LeetCode 的判题系统只检查返回结果不检查内存泄漏。但如果你在面试中手写这段代码面试官很可能会问「你 malloc 了哪些东西在哪里释放的」。上面的代码在找到结果后调用了hashMapFree(map)在返回 NULL 之前也调用了。但result数组是返回给调用者的不能在里面释放需要调用者负责。有一个容易忽略的点如果returnSize被设为 0result数组就没用了应该 free 掉再返回 NULL。上面的代码里我写了free(result)但很多人的版本会漏掉这一行。虽然不影响判题但属于代码质量问题。4. 用测试用例把边界情况跑一遍从空数组到极端值4.1 构造测试用例的四个维度写完代码只是第一步能不能覆盖边界情况才是区分「能跑」和「可靠」的关键。我一般从四个维度构造测试用例数组长度0、1、2、大量、元素符号全正、全负、正负混合、目标值等于某元素、等于两元素之和、不存在、重复元素有重复、无重复。下面是一个简单的测试框架可以直接编译运行。#include stdio.h #include assert.h // 声明 twoSum实际使用时链接上面的实现 int* twoSum(int* nums, int numsSize, int target, int* returnSize); void testCase(int* nums, int size, int target, int expected1, int expected2) { int returnSize 0; int* result twoSum(nums, size, target, returnSize); if (returnSize 2) { printf(nums[...], target%d - [%d, %d]\n, target, result[0], result[1]); assert(result[0] expected1 result[1] expected2); free(result); } else { printf(nums[...], target%d - no solution\n, target); } } int main() { // 用例1普通情况 int a[] {2, 7, 11, 15}; testCase(a, 4, 9, 0, 1); // 用例2重复元素 int b[] {3, 3}; testCase(b, 2, 6, 0, 1); // 用例3负数 int c[] {-3, 4, 3, 90}; testCase(c, 4, 0, 0, 2); // 用例4无解 int d[] {1, 2, 3}; testCase(d, 3, 100, -1, -1); // 用例5两个元素 int e[] {5, 5}; testCase(e, 2, 10, 0, 1); printf(All tests passed.\n); return 0; }这个测试框架里testCase函数封装了调用和断言。注意用例 4 无解时returnSize为 0result为 NULL不会进入断言分支。用例 3 验证了负数取模的正确性如果hashFunc没处理负数这里会直接崩溃。4.2 用 valgrind 检查内存问题如果你在 Linux 环境下可以用 valgrind 跑一遍测试程序检查有没有内存泄漏或越界访问。命令很简单gcc -g -o two_sum_test two_sum.c test.c valgrind --leak-checkfull ./two_sum_test输出里如果看到All heap blocks were freed -- no leaks are possible说明内存管理没问题。如果有Invalid read或Invalid write通常是哈希表下标越界回去检查hashFunc的负数处理。这个步骤在本地开发时花不了几分钟但能帮你提前发现很多隐藏问题。4.3 性能对比暴力 vs 哈希表用一个随机生成的 10000 元素数组分别跑两种解法暴力解法大约需要 50 毫秒到 100 毫秒哈希表解法通常在 1 毫秒以内。差距在两个数量级左右。这个对比不是为了证明哈希表「更好」而是让你直观感受到 O(n^2) 和 O(n) 在实际运行中的差距。当数据量再大一个数量级暴力解法就会从「慢」变成「不可用」。5. 避坑指南两数之和 C 语言实现的五个常见问题5.1 提交后报「超出时间限制」现象代码在本地跑小数组没问题提交后判题系统返回 TLE。 原因用了暴力双重循环而测试用例里包含长度上万的数组。 解决换成哈希表实现。如果不想手写哈希表可以用「排序二分查找」替代时间复杂度 O(n log n)也能过。但哈希表是更通用的解法建议掌握。5.2 返回的下标顺序反了现象返回[1, 0]而不是[0, 1]判题系统判错。 原因先插入当前元素再查找补数导致找到的是当前元素自己或者顺序颠倒。 解决严格按「先查后插」的顺序写。查到了直接返回查不到再插入。这样保证idx i结果天然升序。5.3 负数用例导致段错误现象数组里有负数时程序崩溃报 Segmentation fault。 原因hashFunc里直接用了key % HASH_SIZE负数取模结果为负用作数组下标越界。 解决在取模后加判断如果小于 0 就加上HASH_SIZE。不要用abs因为abs(INT_MIN)是未定义行为。5.4 重复元素只返回了一个下标现象数组[3, 3]目标值 6返回[0, 0]或只返回一个元素。 原因哈希表插入时没有处理重复键或者查找时找到了自己刚插入的节点。 解决确保「先查后插」。查的时候哈希表里只有之前插入的元素不会包含当前元素。插入时即使键重复也没关系因为每个键对应的值是不同的下标链地址法会把它们挂在同一个桶里。5.5 内存泄漏导致面试扣分现象面试官问「你的 malloc 和 free 配对了吗」你发现漏了释放哈希表。 原因只关注了返回结果忽略了辅助数据结构的释放。 解决在每个返回路径上都释放哈希表。找到结果时释放没找到时也要释放。返回数组由调用者释放不要在里面 free。养成「谁分配谁释放返回的除外」的习惯。6. 从两数之和延伸出去C 语言手写哈希表的通用模板两数之和只是一个入口。真正有价值的是你在这个过程中掌握了一套「在 C 语言里手写哈希表」的通用方法。这套方法可以直接迁移到三数之和、四数之和、子数组和等一类问题上。我一般会把哈希表实现拆成三个独立部分哈希函数、冲突处理、内存管理。哈希函数根据键的类型来定整数用取模法字符串用 BKDR 或 DJB2 算法。冲突处理用链地址法最通用实现简单且不会像开放地址法那样出现「聚集」问题。内存管理遵循「创建时分配、销毁时释放」的原则每个malloc都要有对应的free。下面是一个可以复用的哈希表模板骨架把键类型和值类型抽象出来改一改就能用在其他题目上。// 通用哈希表模板修改 KEY_TYPE 和 VALUE_TYPE 即可复用 #define KEY_TYPE int #define VALUE_TYPE int #define HASH_SIZE 10007 typedef struct Node { KEY_TYPE key; VALUE_TYPE value; struct Node* next; } Node; typedef struct { Node** buckets; } HashMap; static int hashFunc(KEY_TYPE key) { // 整数取模字符串需替换为 BKDR 哈希 int h (int)key % HASH_SIZE; if (h 0) h HASH_SIZE; return h; } HashMap* createMap() { HashMap* map (HashMap*)malloc(sizeof(HashMap)); map-buckets (Node**)calloc(HASH_SIZE, sizeof(Node*)); return map; } VALUE_TYPE* get(HashMap* map, KEY_TYPE key) { int idx hashFunc(key); Node* node map-buckets[idx]; while (node) { if (node-key key) return node-value; node node-next; } return NULL; } void put(HashMap* map, KEY_TYPE key, VALUE_TYPE value) { int idx hashFunc(key); Node* node (Node*)malloc(sizeof(Node)); node-key key; node-value value; node-next map-buckets[idx]; map-buckets[idx] node; } void freeMap(HashMap* map) { for (int i 0; i HASH_SIZE; i) { Node* node map-buckets[i]; while (node) { Node* tmp node; node node-next; free(tmp); } } free(map-buckets); free(map); }这个模板里get返回的是值指针而不是值本身这样调用方可以区分「找到但值为 0」和「没找到」。put不检查键是否已存在因为两数之和这类问题允许重复键。如果你需要去重在put里加一个查找逻辑即可。验证方法很简单把两数之和的代码用这个模板重写一遍跑通所有测试用例然后试着用它解决「三数之和」或「和为 K 的子数组」。如果能在半小时内迁移过去说明你真的掌握了而不是背了一道题。我自己的习惯是每学一个新的数据结构就用它去刷三到五道不同类型的题。两数之和用哈希表三数之和用排序加双指针四数之和用哈希表加剪枝。同一个数据结构在不同题目里的表现差异比看十篇教程都管用。希望帮到你。本文还有配套的精品资源点击获取