简介:这份资源是面向C语言初学者与LeetCode刷题者的「两数之和」题解源码包,针对在整数数组中查找和为目标值的两个元素这一经典算法问题,提供可直接编译运行的完整工程。压缩包共35个文件,约1.05MB,以Visual Studio工程文件为主,包含sln解决方案、vcxproj项目配置、cpp源码、rc资源脚本,以及pdb、obj、ilk等编译调试中间产物和tlog、log日志文件,方便读者在本地还原开发环境、逐步调试与验证结果。资源描述中给出了nums=[2,7,11,15]、target=9返回[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 从 i+1 开始,避免重复使用同一元素 if (nums[i] + nums[j] == target) { result[0] = i; result[1] = j; return result; } } } *returnSize = 0; return NULL; }这段代码里有两个细节值得说。第一,j从i+1开始而不是从 0 开始,因为题目要求同一个元素不能使用两次,从i+1开始天然避免了这种情况。第二,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被设为 0,result数组就没用了,应该 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为 0,result为 NULL,不会进入断言分支。用例 3 验证了负数取模的正确性,如果hashFunc没处理负数,这里会直接崩溃。
4.2 用 valgrind 检查内存问题
如果你在 Linux 环境下,可以用 valgrind 跑一遍测试程序,检查有没有内存泄漏或越界访问。命令很简单:
gcc -g -o two_sum_test two_sum.c test.c valgrind --leak-check=full ./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 的子数组」。如果能在半小时内迁移过去,说明你真的掌握了,而不是背了一道题。
我自己的习惯是:每学一个新的数据结构,就用它去刷三到五道不同类型的题。两数之和用哈希表,三数之和用排序加双指针,四数之和用哈希表加剪枝。同一个数据结构在不同题目里的表现差异,比看十篇教程都管用。希望帮到你。
本文还有配套的精品资源,点击获取