☰
两数之和C语言源码解析:暴力解法与哈希表优化避坑指南
2026/10/14 21:38:47 网站建设 项目流程

简介:这是一份用C语言解决LeetCode经典题“两数之和”的源码包,主要面向刚入门算法题的C语言学习者和准备编程笔试的开发者。题目要求在给定整数数组里找出和等于目标值的两个数,并返回它们的下标;代码已经满足“每个输入只有一种答案,且同一元素不能重复使用”的约束条件,逻辑清晰,附有中文注释,可以直接编译运行。整个zip压缩包共包含35个文件,大小约1.05MB,核心部分是cpp源文件以及配套的VS工程配置文件,包括sln、vcxproj、filters等,同时还带有编译生成的exe可执行程序、pdb调试符号、obj目标文件和资源文件,打开工程即可查看完整的项目组织并运行调试。目前该资源已有5676人学习浏览,热度较高。通过这份资料,读者既能快速拿到两数之和的C语言实现方案,又能对比暴力枚举与哈希查找两种解法的差异,同时熟悉Visual Studio环境下C语言项目的配置与断点调试技巧,为后续算法练习打下扎实基础。

1. 两数之和这道题,为什么值得用 C 语言啃一遍

刷题平台上,LeetCode 两数之和C语言源码几乎是每个转 C 的开发者都会搜一次的题目。它表面上只是个拿下标换目标值的入门题,可一旦用 C 实现,算法之外的东西全冒出来了:返回指针是栈上地址还是堆上地址、returnSize有没有赋值、哈希表碰到负数取模会不会直接越界、重复元素该保留哪个下标。我见过不少人用 Python 十几行跑通,换 C 却反复提交失败,问题不在思路,而在 C 的内存模型和类型细节。这篇文章不打算只贴一份能过测的答案,而是把暴力解、手写哈希表、边界翻车和本地验证一条线讲完,让你看完不仅能复现,还能说清楚每一步为什么这么写。适合校招刷题、从脚本语言转 C、以及想练手写数据结构的开发者。

2. 先把暴力解法写对:双重循环与下标返回的细节

2.1 暴力解法的最小 C 实现

暴力解是这道题最直观的起点:固定一个数,往后找另一个数,两者相加等于target就返回两个下标。C 语言里没有 Python 那种enumerate,也没有容器能同时存值和下标,所以双重循环是最接近直觉的写法。

#include <stdlib.h> int* twoSum(int* nums, int numsSize, int target, int* returnSize) { for (int i = 0; i < numsSize - 1; i++) { for (int j = i + 1; j < numsSize; j++) { if (nums[i] + nums[j] == target) { int* ret = (int*)malloc(2 * sizeof(int)); if (ret == NULL) { *returnSize = 0; return NULL; } ret[0] = i; ret[1] = j; *returnSize = 2; return ret; } } } *returnSize = 0; return NULL; }

这段代码的关键点在循环边界:内层循环从i + 1开始,而不是从 0 开始,这样既避免比较自己和自己,也把[i,j]和[j,i]这类重复组合去掉了一半。i最大到numsSize - 2,所以外层循环条件写成i < numsSize - 1,否则当数组只有两个元素时,内层循环还有一次有意义的执行,不能提前结束。

returnSize是输出参数,平台靠它知道返回数组里有多少个元素,所以每个返回路径都必须赋值。找到答案时设成 2,没找到时设成 0 并返回NULL。虽然题目保证一定有解,但写成防御式代码能避免在本地测试时被无效输入坑到。时间复杂度是 O(n²),空间复杂度是 O(1),只多了一个返回值数组。这种写法在numsSize很小的时候完全够用,也是后面理解哈希表优势的参照物。

2.2 动态分配返回值,为什么不能返回局部数组

很多第一次写 C 版两数之和的人会卡在这里:明明函数内部建了一个数组,返回后却拿不到正确数据。看下面这段错误写法:

int* badTwoSum(int* nums, int numsSize, int target, int* returnSize) { int ret[2] = {0, 0}; // 找到后填值 ret[0] = i; ret[1] = j; *returnSize = 2; return ret; // 错误:返回了栈上局部数组 }

局部数组ret的生命周期在函数返回时就结束了,它占据的栈内存随时可能被后续调用覆盖。平台拿到这个指针后去读数据,读到的是被破坏的垃圾值,轻则答案错误,重则直接段错误。C 语言里数组不能作为函数返回值,所以常见做法是返回一块通过malloc分配的堆内存,它的生命周期由调用方控制。

malloc(2 * sizeof(int))分配的是两个int的连续空间,这块内存在你主动free之前一直有效。LeetCode 的判题系统拿到返回值后,会根据returnSize读取内容,判完不再需要你管;但在本地测试时,你要记得在断言之后free(ret),否则每调一次twoSum就泄漏一次。这里还要注意一个细节:malloc可能失败,返回值是NULL。严谨的做法是在分配后判断一下,失败时设置*returnSize = 0并返回NULL,而不是直接解引用空指针。

3. 用哈希表把 O(n²) 降到 O(n):C 语言手写开放寻址

3.1 哈希表结构体与初始化参数

暴力解法的问题是每固定一个数,都要在后面的元素里线性扫描。反过来想,如果能把已经扫过的数存起来,让查询在平均 O(1) 时间里完成,那整个算法就是 O(n)。C 语言标准库没有哈希表,所以我们需要自己实现一个。常见做法是开放寻址加线性探测,理由是代码量小、内存连续、对嵌入式场景友好,不用维护链表节点。

哈希表节点需要三个字段:key存数组元素值,value存该值第一次出现或最后一次出现的下标,used标志这个槽位是否被占用。这里不能用某个特殊值代替used,因为key本身可以是任意int,包括0和负数,没有一个绝对安全的哨兵值。

typedef struct { int key; int value; int used; } HashNode; typedef struct { HashNode* nodes; int capacity; } HashTable;

初始化时用calloc而不是malloc,因为calloc会把分配的内存清零,正好让所有节点的used变成 0。malloc不会清零,遍历节点时你无法判断槽位是否为空。

HashTable* createTable(int capacity) { HashTable* table = (HashTable*)malloc(sizeof(HashTable)); if (table == NULL) { return NULL; } table->nodes = (HashNode*)calloc(capacity, sizeof(HashNode)); if (table->nodes == NULL) { free(table); return NULL; } table->capacity = capacity; return table; }

capacity是哈希表的总槽位数。为什么不是numsSize而是numsSize * 2,这关系到负载因子,后面单独讲。这里先记住一点:开放寻址的哈希表不能装到满,越满碰撞越严重,预留一半空位是工程上的常见选择。

3.2 插入和查找的完整 C 代码

负数是这道题绕不开的测试点。C 语言里-7 % 10的结果是-7,直接拿这个负数当数组下标访问nodes会越界。解决方法是把key先转成unsigned int再取模,转换后负数会变成一个很大的正整数,取模结果天然落在[0, capacity - 1]范围内。

static int hashKey(int key, int capacity) { return ((unsigned)key) % capacity; }

插入函数的逻辑是:先算出哈希位置,如果槽位空闲就直接放;如果槽位被占且key相同,就更新value,因为数组里可能有重复元素,我们希望保留最新遇到的下标;如果槽位被占但key不同,就线性向后探测,直到找到空位。

void insert(HashTable* table, int key, int value) { int idx = hashKey(key, table->capacity); while (table->nodes[idx].used) { if (table->nodes[idx].key == key) { table->nodes[idx].value = value; return; } idx = (idx + 1) % table->capacity; } table->nodes[idx].key = key; table->nodes[idx].value = value; table->nodes[idx].used = 1; }

查找函数和插入的探测方向一致:先算起始位置,遇到空位就停止,说明 key 不在表里;遇到 key 相同的节点就返回对应的下标。

int find(HashTable* table, int key, int* outValue) { int idx = hashKey(key, table->capacity); while (table->nodes[idx].used) { if (table->nodes[idx].key == key) { *outValue = table->nodes[idx].value; return 1; } idx = (idx + 1) % table->capacity; } return 0; }

主函数的核心顺序是“先查后插”。对于当前元素nums[i],先查target - nums[i]在不在表里,如果不在,再把当前元素插入。如果反过来先插再查,当target恰好等于当前元素的两倍时,就会查到自己刚插入的那个数,返回[i, i],这是新手最容易踩的坑。

int* twoSum(int* nums, int numsSize, int target, int* returnSize) { if (numsSize <= 1) { *returnSize = 0; return NULL; } int capacity = numsSize * 2; HashTable* table = createTable(capacity); if (table == NULL) { *returnSize = 0; return NULL; } for (int i = 0; i < numsSize; i++) { int complement = target - nums[i]; int otherIndex = -1; if (find(table, complement, &otherIndex)) { int* ret = (int*)malloc(2 * sizeof(int)); if (ret == NULL) { freeTable(table); *returnSize = 0; return NULL; } ret[0] = otherIndex; ret[1] = i; *returnSize = 2; freeTable(table); return ret; } insert(table, nums[i], i); } freeTable(table); *returnSize = 0; return NULL; }

freeTable用来释放哈希表自身和内部节点数组,防止本地测试时内存泄漏:

void freeTable(HashTable* table) { if (table != NULL) { free(table->nodes); free(table); } }

这里的otherIndex初始化为-1,如果find没找到就不会被写入,所以不会误用。整体结构是:遍历数组,查补数,查到就组答案返回,查不到就把当前值存进去。每个元素最多被插入一次,平均情况下查找和插入都是 O(1),总复杂度降到 O(n)。

3.3 为什么长度取两倍、负载因子怎么设

负载因子是哈希表里已存元素数量与容量的比值。开放寻址的线性探测里,负载因子越高,查找一个不存在的 key 时需要的探测次数越多。当负载因子接近 1 时,哈希表会退化成近似全表扫描,O(n) 就白降了。常见做法是让负载因子维持在 0.5 左右,所以容量取numsSize * 2。

numsSize * 2这个参数不是拍脑袋定的。假设哈希函数分布均匀,线性探测的期望探测次数大约在1 / (1 - α)附近,α 是负载因子。α = 0.5 时平均探测两次左右,α = 0.9 时可能要到十次。翻一倍容量,空间换时间,换来的是稳定的查询速度。如果题目给的numsSize很大,比如十万,哈希表内存约为capacity * sizeof(HashNode),按三个 int 算也就是两倍数组大小,通常可以接受。

有人会把容量改成大于等于numsSize * 2的最近质数,试图减少不均匀 key 的碰撞。这种做法在 key 分布规律性很强时确实有效,但代码会多一段找质数的逻辑。LeetCode 官方测试数据没有刻意针对二次探测制造最坏情况,所以numsSize * 2够用。如果你在工程里手写哈希表,我一般会建议预留更多余量,比如 1.5 到 2 倍,并在达到阈值时做扩容。但在这道题里,最大插入量就是numsSize,预分配两倍已经不需要扩容逻辑,代码最简洁。

4. 边界条件与复杂度权衡:特殊数组和内存占用

4.1 重复元素、负数、目标值和溢出

重复元素是哈希表解法最容易让人疑惑的点。看[3, 3]这个用例,target = 6。遍历到第一个 3 时,target - nums[0] = 3,哈希表里还没有 3,于是把第一个 3 和下标 0 插入。遍历到第二个 3 时,查 3 找到了下标 0,返回[0, 1]。这里的顺序保证了不会出现[0, 0],也保证用到了两个不同位置的元素。

如果数组是[3, 3, 4],target = 7,遍历到第二个 3 时,target - nums[1] = 4,表里还没有 4,于是插入第二个 3 时会把 value 从 0 更新成 1。等遍历到 4 时,查target - 4 = 3,拿到的是最新下标 1,返回[1, 2]。这个行为说明insert里更新 value 的逻辑不是多余,是为了让重复元素保留“最近一次出现”的下标,避免和更早的下标混淆。

负数的情况分两块:哈希函数已经用unsigned取模处理了下标问题;在比较相加是否等于target时,负数参与加法没有任何问题,因为只要不溢出,nums[i] + nums[j] == target是严格的数学判断。真正需要警惕的是target - nums[i]可能超出int范围。题目保证答案存在且target是int,所以实际测试数据不会出现这种溢出,但如果你要在本地做压力测试,可以用long long接一下:

#include <limits.h> long long need = (long long)target - nums[i]; if (need < INT_MIN || need > INT_MAX) { continue; } int complement = (int)need;

这段代码的作用是防御:如果need超出int能表示的范围,说明当前元素不可能和另一个int组成target,跳过它。因为 C 语言有符号整数溢出是未定义行为,与其让减法溢出再祈祷编译器别优化出怪东西,不如先用long long算出来再检查。LeetCode 官方数据不会触发这个分支,但这属于“写了不会错,不写有隐患”的边界保护。

4.2 暴力与哈希的取舍边界

在小规模数据上,哈希表不一定更快。malloc一大块节点数组、初始化、逐次探测,这些操作都有固定成本;暴力双循环只是几条 CPU 指令。所以选择解法之前,先看数据规模。

维度暴力双重循环手写哈希表
平均时间复杂度O(n²)O(n)
空间复杂度O(1)O(n)
代码量少多
小数组性能好一般
大数组性能差好
内存受限场景适合不适合

我一般把分界线放在n = 100左右。如果数组中元素不超过几十个,暴力解写起来五分钟,哈希表再加五分钟调 bug,反而不划算。但在 LeetCode 上,numsSize可能到几千甚至上万,哈希表才是能稳定跑进优秀区间的答案。

另外要考虑哈希表本身的占用。HashNode如果三个int成员,大小是 12 字节,容量2n,合计约24n字节。n = 100000时约 2.4MB,对现代计算机不算什么,但如果你在单片机上实现,内存可能只有几十 KB,那暴力解的空间优势就非常明显。这也是为什么我会在项目里保留两种解法,而不是无脑哈希。

4.3 内存释放与判题逻辑

哈希表解法里,twoSum函数内部freeTable(table)是为了不让临时结构泄漏。返回给平台的ret数组不能在这里释放,因为判题程序还要读它。LeetCode 的 C 语言判题流程通常会在读完成果后自己处理内存,不会因为你没释放返回值就报错;但如果你在本地连续跑一万个测试用例,每一次都泄漏一个ret,进程的内存会肉眼可见地涨。所以本地测试代码里,拿到结果后要用完即free。

有一个细节容易被忽略:当malloc失败返回NULL时,判题系统拿着NULL指针去读下标,可能直接段错误。从工程角度,保留失败分支是对的;从竞赛角度,比赛环境内存充足,这个分支几乎不会执行。我更建议写成防御式代码,因为代码块里多两行判断不会影响可读性,但能避免你本地用内存受限环境复现时莫名其妙崩溃。

5. 两数之和C语言实现避坑:常见问题与排查

5.1 本地能跑,提交后乱码:返回了局部数组

现象:在本地用printf打印返回值,有时候对有时候不对;提交到刷题平台,输出变成一串乱码,或者直接报段错误。原因:函数返回了栈上局部数组,函数退出后那块内存已经被回收,内容随时可能被改写。解决:改用malloc分配返回值数组,让它的生命周期持续到调用方手动释放。如果你看到代码里写了return ret;,而ret是int ret[2]定义的局部数组,不用往下看,先改这里。

5.2 returnSize 没赋值,判题系统越界读

现象:答案看起来是对的,但提交报AddressSanitizer错误,或者返回数组长度是随机值。原因:C 语言里returnSize是指针,指向调用方提供的变量,函数里不赋值,调用方读到的就是原来的垃圾值。判题程序拿到错误的长度,自然会越界读内存。解决:在所有返回路径上赋值。找到答案设*returnSize = 2; return ret;,没找到或分配失败设*returnSize = 0; return NULL;。不要只在一个分支里赋值。

5.3 哈希表解法返回 [i,i]:自己配了自己

现象:得到的两个下标完全相同,比如[2, 2]。原因:先插入了当前元素,再查找补数,当target等于当前元素的两倍时,哈希表里刚好是自己。解决:改成先查后插。这道题要求两个不同下标,所以任何自己匹配自己的路径都要封死。顺带强调,nums = [3, 3]时先查后插能正确返回[0, 1],先插后查会返回[1, 1],因为插入会更新 value 到当前下标。

5.4 负数 key 导致哈希数组越界

现象:数组里有负数时程序崩溃,调试发现idx是负数。原因:C 语言的取模运算结果符号和被除数一致,-7 % 10等于-7,直接用负数当下标访问数组自然越界。解决:哈希函数用((unsigned)key) % capacity。这里要注意,转成unsigned不是把负数变正数,而是把二进制位重新解释成无符号数,再取模结果必然非负。也可以用(key % capacity + capacity) % capacity,但多一次加法和取模,不如unsigned简洁。

5.5 空数组或容量算成 0:除零崩溃

现象:numsSize为 0 时,capacity = numsSize * 2也是 0,hashKey里对 0 取模直接除零崩溃。原因:没有对空输入做保护。解决:在twoSum开头判断numsSize <= 1,直接返回空结果;或者初始化容量时取(numsSize * 2 > 0 ? numsSize * 2 : 1)。题目保证有解,但本地测试你可能会传空数组,防御一下不会错。

6. 几种进阶验证技巧和代码风格

6.1 用本地测试框架覆盖典型用例

写完twoSum后,不要急着提交。写一个简单的check函数,把题目里能想到的边界一次跑完。

#include <stdio.h> #include <assert.h> #include <stdlib.h> void check(int* nums, int n, int target, int a, int b) { int retSize = 0; int* ret = twoSum(nums, n, target, &retSize); assert(retSize == 2); assert((ret[0] == a && ret[1] == b) || (ret[0] == b && ret[1] == a)); free(ret); } int main(void) { int case1[] = {2, 7, 11, 15}; check(case1, 4, 9, 0, 1); int case2[] = {3, 2, 4}; check(case2, 3, 6, 1, 2); int case3[] = {3, 3}; check(case3, 2, 6, 0, 1); int case4[] = {-3, 4, 3, 90}; check(case4, 4, 0, 0, 2); puts("all passed"); return 0; }

这段验证代码允许ret[0]和ret[1]的顺序调换,因为题目不要求按下标顺序返回。assert的好处是失败时直接告诉你哪一行出事,不需要人肉对比输出。

6.2 编译时打开严格告警

C 代码写多了你会发现,很多隐形 bug 在编译器告警里已经露头。编译时我习惯加-Wall -Wextra -Werror,把告警升级成错误。

gcc -std=c11 -Wall -Wextra -Werror -O2 two_sum.c test_two_sum.c -o test

-std=c11是为了让for (int i = 0; ...)这种声明合法;-O2模拟刷题平台的优化级别;-Werror会卡住未声明malloc、类型不匹配这类低级问题。如果这段命令编译零告警,再提交你的答案,能筛掉一半低级失误。

6.3 工程上可加的小优化

如果你还想再进一步,可以把哈希表容量从numsSize * 2改成一个固定质数,比如找不小于numsSize * 2的质数,让分布更均匀。另一个优化是把取模运算换成位运算,前提是容量取 2 的幂,比如capacity = 1; while (capacity < numsSize * 2) capacity <<= 1;,然后hashKey用key & (capacity - 1)。但这两个优化都是锦上添花,对这道题的性能影响很小,反而会降低可读性。我的习惯是:先把朴素哈希表跑通,再针对你实际要处理的数据规模决定要不要优化。写 LeetCode 源码也好,写工程组件也好,能让人一眼看懂的代码,比微优化更值钱。

这套写下来,我自己最大的收获是养成了一个习惯:任何返回指针的 C 函数,先想清楚内存是谁分配的、谁负责释放、空指针路径有没有返回值。两数之和只是开始,后面遇到转置矩阵、合并有序数组,都是一样的套路。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询