LeetCode两数之和C语言解法:手写哈希表与暴力破解完整拆解
发布时间:2026/9/25 16:05:24来源:尧图网络
简介这是一份面向C语言初学者的LeetCode经典入门题“两数之和”的完整Visual Studio工程源码包。资源演示了如何在给定整数数组中查找和为目标值的两个数并返回下标代码遵循题目约束示例输入[2,7,11,15]与目标值9会正确输出[0,1]逻辑清晰便于理解暴力枚举与哈希优化的差异。压缩包共35个文件以cpp源文件、sln/vcxproj工程配置、exe可执行程序、pdb调试符号、tlog构建日志及obj中间文件等为主整体约1.05MB目录结构完整可直接用Visual Studio打开编译或运行exe查看结果。目前已有5674人学习下载适合正在刷LeetCode、备考机试或想熟悉VS工程组织的C语言开发者。通过该资源不仅能快速运行算法示例还能观察从源码到可执行文件的完整构建过程省去手动新建工程的时间。1. LeetCode 两数之和的 C 语言解法这道「新手题」其实藏着三个考点LeetCode 两数之和Two Sum是 LeetCode 热门 100 题的第一题几乎所有刷题指南都会把它放在最前面。题目描述很简单给定一个整数数组和一个目标值找出数组中和为目标值的两个数的下标。但如果你用 C 语言去写会发现它和 Python、Java 的体验完全不同——C 语言没有现成的哈希表没有动态数组甚至连返回值都要靠指针参数来「带出去」。很多人在 LeetCode 上用 C 提交第一版代码能通过但一细问 returnSize 是什么、动态内存谁释放、哈希表怎么手写就答不上来了。这篇文章我会把暴力解和哈希表解两种写法完整拆开把函数签名、内存管理、边界条件这些 C 语言特有的考点逐个讲透最后给出能直接提交的源码。适合刚刷题的学生也适合准备面试、需要快速复习 C 语言指针和内存操作的从业者。2. 先看懂题目约束为什么 C 语言解法不能照搬别的语言2.1 函数签名里藏着第一个考点LeetCode 给 C 语言用户提供的函数签名是固定的int* twoSum(int* nums, int numsSize, int target, int* returnSize);第一次刷这道题的人十有八九会盯着returnSize发呆。这个参数是干嘛的C 语言没有 Python 那种多返回值的语法也没有 Java 那种int[]引用类型函数的返回值只能是一个指针。但调用方拿到指针之后得知道这个数组有多长才能正常遍历——否则就不知道要读多少字节。returnSize就是干这个的它是一个指向整数的指针你在函数内部要把结果数组的长度写进去调用方才能正确读取。我一般会这样开头int* twoSum(int* nums, int numsSize, int target, int* returnSize) { // 先给返回长度赋值这是约定俗成的写法 *returnSize 2; }这里有三个细节值得新手注意。第一个细节是*returnSize 2一定要写不写的话调用方读到的可能是任意值直接导致越界访问。第二个细节是这个赋值操作要在最早的位置完成而不是在函数最后才想起来补——因为后面如果遇到异常情况要返回NULLreturnSize仍然要被设置成 0否则调用方会对空指针做for循环直接崩溃。第三个细节是returnSize是输出参数所有走到return语句之前的路径都必须保证它已经被赋值了。2.2 返回值是谁分配的内存另一个让 C 新手翻车的点是返回的数组是用malloc动态分配的。LeetCode 的测试代码拿到返回值之后会调用free()来释放内存。所以你不能返回一个指向局部变量的指针否则调用方free的时候直接报错也不能返回一个指向某个全局静态数组的指针因为每次调用都会覆盖上一次的结果而且语义上很危险。常规做法是int* result (int*)malloc(sizeof(int) * 2);两个整数8 个字节在 64 位机器上int是 4 字节。这里并不需要检查malloc返回值是否为NULLLeetCode 的测试环境里内存充足几乎不会失败但如果你把这个源码拿到本地 VC6.0 或 GCC 环境里跑最好还是加一句防御性判断。另外要注意malloc出来的内存没有初始化所以先给result[0]和result[1]赋值再返回指针不要等返回之后再填值。2.3 暴力解两层循环的复杂度与写法最简单的解法是双重循环。外层循环枚举第一个数的下标i内层循环从i 1开始枚举第二个数的下标j每次判断nums[i] nums[j] target。这个解法的时间复杂度是 O(n²)空间复杂度是 O(1)不分配任何额外内存。int* twoSum(int* nums, int numsSize, int target, int* returnSize) { int* result (int*)malloc(sizeof(int) * 2); *returnSize 0; for (int i 0; i numsSize - 1; i) { for (int j i 1; j numsSize; j) { if (nums[i] nums[j] target) { result[0] i; result[1] j; *returnSize 2; return result; } } } free(result); return NULL; }这段代码逻辑不复杂但我建议你注意两处细节。第一处是内层循环从i 1开始不是从 0 开始这样避免了同一个元素被用两次的情况也避免了i和j互换的重复组合。第二处是循环结束后如果没有找到匹配要把malloc出来的内存释放掉再返回NULL——虽然 LeetCode 的判题器可能不在乎但这是 C 语言内存管理的习惯问题面试官会看。暴力解适合 n 很小的情况。LeetCode 官方测试用例里数组长度最大约 10⁴ 量级O(n²) 也就是 10⁸ 次比较勉强能过但耗时在 400ms 左右明显慢。如果你只是本地练手暴力解完全够用但如果你想追求更优解就得学哈希表了。3. C 语言手写哈希表从零实现两个数之和的 O(n) 解法3.1 为什么需要哈希表暴力解慢在每次都要线性扫描剩下的元素。理想的情况是遍历数组时每看到一个数nums[i]立刻知道target - nums[i]在之前有没有出现过。这个「立刻知道」的操作哈希表能做到平均 O(1) 的时间复杂度。C 语言标准库里没有哈希表所以需要自己实现。这道题其实有一个非常取巧的简化条件哈希表的 key 是数组里的数值value 是数值对应的下标。而且我们只需要支持两种操作插入把当前值和下标存进去和查找判断某个值是否出现过如果出现过就返回下标。不需要删除不需要迭代不需要扩容策略搞得多复杂。我一般用链地址法来解决哈希冲突——每个桶是一个链表头冲突的节点挂在链表上。这对这道题来说足够了因为测试数据里冲突不会特别严重。3.2 结构体定义与哈希函数先定义节点和哈希表结构。节点存两个字段数组数值和对应的下标外加一个指向下一个节点的指针。struct HashNode { int key; // 数组里存的值 int value; // 该值在数组中的下标 struct HashNode* next; }; // 哈希表就是一堆链表头组成的数组 struct HashTable { struct HashNode** buckets; // 桶数组 int size; // 桶的数量 };哈希函数的选择我习惯用取模法hash abs(key) % size。这里有一个 C 语言特有的坑key可能是负数负数的取模在 C 语言里是向零取整的结果可能为负所以对 key 取绝对值再模。但取绝对值也有边界问题——如果 key 是INT_MIN取绝对值会溢出。我通常的处理方式是把 key 转成unsigned int再取模这样既避免了负数问题也绕过了INT_MIN溢出的雷区。哈希表大小我设为numsSize * 2这样负载因子控制在 0.5 左右冲突少查找效率高。表大小的选择是一道隐含的面试题开太小冲突多开太大浪费内存。2 倍这个值是在时间与空间之间比较常用的折中。3.3 插入与查找的实现插入操作的逻辑是算出哈希值找到对应的桶链表头创建一个新节点挂到链表头部头插法。因为这道题不要求保序头插法写起来最简洁。void hashInsert(struct HashTable* table, int key, int value) { // 计算桶索引注意转成 unsigned int 再取模 unsigned int index (unsigned int)key % table-size; struct HashNode* node (struct HashNode*)malloc(sizeof(struct HashNode)); node-key key; node-value value; // 头插法新节点指向原来的链表头再更新链表头 node-next table-buckets[index]; table-buckets[index] node; }这段代码有两个说明点。第一个是unsigned int index (unsigned int)key % table-size这里不取绝对值而是利用无符号整数的特性直接取模干净利落。第二个是头插法只有三行先让新节点指向原来的头节点再把桶的头指针更新成新节点。顺序不能反如果先更新table-buckets[index]原来的链表就丢了。查找操作要遍历桶链表逐一比较 keyint hashFind(struct HashTable* table, int key, int* foundIndex) { unsigned int index (unsigned int)key % table-size; struct HashNode* cur table-buckets[index]; while (cur ! NULL) { if (cur-key key) { *foundIndex cur-value; return 1; // 1 表示找到 } cur cur-next; } return 0; // 0 表示没找到 }foundIndex是一个输出参数找到后把下标写进去。这里不直接用返回值传下标是因为我需要让返回值承担「是否存在」这个布尔语义不然找不到的时候就得返回一个特殊值比如 -1但 -1 本身也可能是一个合法的下标就说不清了。C 语言没有布尔类型用int的 0 和 1 来区分是这套代码里最常见的约定。3.4 两遍哈希表的完整流程哈希表实现好之后解题逻辑就分两步第一遍遍历把所有元素插入哈希表第二遍遍历对每个元素查target - nums[i]是否在表里。int* twoSum(int* nums, int numsSize, int target, int* returnSize) { // 第一步初始化哈希表 struct HashTable table; table.size numsSize * 2; table.buckets (struct HashNode**)calloc(table.size, sizeof(struct HashNode*)); // 第一遍全部插入 for (int i 0; i numsSize; i) { hashInsert(table, nums[i], i); } // 第二遍逐个查找 int* result (int*)malloc(sizeof(int) * 2); for (int i 0; i numsSize; i) { int complement target - nums[i]; int foundIndex 0; if (hashFind(table, complement, foundIndex) foundIndex ! i) { result[0] i; result[1] foundIndex; *returnSize 2; // 释放哈希表内存 for (int j 0; j table.size; j) { struct HashNode* cur table.buckets[j]; while (cur ! NULL) { struct HashNode* temp cur; cur cur-next; free(temp); } } free(table.buckets); return result; } } // 没找到释放所有内存后返回 NULL free(result); for (int j 0; j table.size; j) { struct HashNode* cur table.buckets[j]; while (cur ! NULL) { struct HashNode* temp cur; cur cur-next; free(temp); } } free(table.buckets); return NULL; }这段代码比较长但是每一步都是必要的。table.buckets用calloc而不是malloc是因为calloc会把这组指针初始化成 NULL后面遍历链表时才能用cur ! NULL判断链表结束。foundIndex ! i这个判断是防止同一个元素被使用了两次——数组里有重复值时尤其容易触发这个坑。这个两遍哈希表的时间复杂度是 O(n)空间复杂度是 O(n)。LeetCode 上跑下来耗时大约 20ms比暴力解快了一个数量级。4. 避坑与常见问题排查C 语言实现两数之和的五个大坑4.1 returnSize 忘记赋值导致数组越界现象本地测试正常LeetCode 提交后返回错误结果甚至直接报错 parse error 或 runtime error。原因函数入口处没有*returnSize 2。调用方拿到返回的指针后会根据 returnSize 去读数组的两个元素而 returnSize 未被赋值时是个随机值可能是 0、可能是 999读取行为就失控了。解决在任何可能 return 的路径上先给*returnSize赋值。找不到时*returnSize 0找到时*returnSize 2两处都要写。4.2 malloc 分配结果数组后忘记释放现象本地用 Valgrind 跑报 memory leakLeetCode 不报错但面试时会被追问。原因找到结果后直接 return 指针调用方会负责 free这道题里这不是问题。但如果你在循环中反复调用 twoSum每次 malloc 都会泄漏。更常见的问题是反向的找不到结果时返回 NULL但之前 malloc 的 result 没人 free泄漏了。解决每个 return 路径都自检一遍先释放自己分配但不再使用的内存再 return。LeetCode 判题虽然不计较泄漏但本地跑和面试时计较。4.3 哈希函数对负数取模出现负索引现象哈希表访问table.buckets[index]时程序崩溃或出现 Segfault。原因C 语言里-5 % 8的结果是-5不是3。如果哈希函数写成key % size负 key 算出来的索引是负数直接访问数组就越界了。解决用(unsigned int)key % size或者abs(key) % size都可以。但要注意不要用abs(key)对INT_MIN取绝对值那是未定义行为直接转 unsigned 最安全。4.4 数组中有重复元素时返回同一元素两次现象nums [3, 3],target 6期望返回[0, 1]实际可能返回[0, 0]。原因哈希表存了两次 key3 的记录第二遍遍历时查到 foundIndex 恰好是 i 本身没有过滤。解决在查找结果后面加foundIndex ! i判断。或者改成一遍哈希表边遍历边查边插入这样永远不会用到当前元素自己。4.5 哈希表内存释放不完整导致内存泄漏现象本地连续跑 1000 次 fully 哈希表版 twoSum内存占用持续上涨。原因只free(table.buckets)释放了桶数组但每个桶链表上的节点没有释放每个节点都是一小块泄漏。解决释放时先遍历每个桶的链表逐个free(cur)最后free(table.buckets)。这段代码我每次写都容易漏我的习惯是单独写一个hashDestroy函数来收尾。5. 完整源码与本地验证把 LeetCode 提交版改成可调试版5.1 合并成一个完整的 C 文件上面的片段代码在实际使用之前需要组合成一个完整的文本并且补上#include和测试用的 main 函数。下面这个是我本地调试时用的完整版本可以直接用 GCC 编译运行。#include stdio.h #include stdlib.h struct HashNode { int key; int value; struct HashNode* next; }; struct HashTable { struct HashNode** buckets; int size; }; void hashInsert(struct HashTable* table, int key, int value) { unsigned int index (unsigned int)key % table-size; struct HashNode* node (struct HashNode*)malloc(sizeof(struct HashNode)); node-key key; node-value value; node-next table-buckets[index]; table-buckets[index] node; } int hashFind(struct HashTable* table, int key, int* foundIndex) { unsigned int index (unsigned int)key % table-size; struct HashNode* cur table-buckets[index]; while (cur ! NULL) { if (cur-key key) { *foundIndex cur-value; return 1; } cur cur-next; } return 0; } void hashDestroy(struct HashTable* table) { for (int i 0; i table-size; i) { struct HashNode* cur table-buckets[i]; while (cur ! NULL) { struct HashNode* tmp cur; cur cur-next; free(tmp); } } free(table-buckets); } int* twoSum(int* nums, int numsSize, int target, int* returnSize) { struct HashTable table; table.size numsSize * 2; table.buckets (struct HashNode**)calloc(table.size, sizeof(struct HashNode*)); for (int i 0; i numsSize; i) { hashInsert(table, nums[i], i); } int* result (int*)malloc(sizeof(int) * 2); for (int i 0; i numsSize; i) { int complement target - nums[i]; int foundIndex -1; if (hashFind(table, complement, foundIndex) foundIndex ! i) { result[0] i; result[1] foundIndex; *returnSize 2; hashDestroy(table); return result; } } free(result); *returnSize 0; hashDestroy(table); return NULL; } int main() { // 测试用例 1普通情况 int nums1[] {2, 7, 11, 15}; int returnSize1 0; int* res1 twoSum(nums1, 4, 9, returnSize1); if (res1 ! NULL) { printf(用例1: [%d, %d]\n, res1[0], res1[1]); free(res1); } // 测试用例 2重复元素 int nums2[] {3, 3}; int returnSize2 0; int* res2 twoSum(nums2, 2, 6, returnSize2); if (res2 ! NULL) { printf(用例2: [%d, %d]\n, res2[0], res2[1]); free(res2); } // 测试用例 3找不到 int nums3[] {1, 2, 3}; int returnSize3 0; int* res3 twoSum(nums3, 3, 100, returnSize3); if (res3 NULL) { printf(用例3: 返回 NULLreturnSize%d\n, returnSize3); } return 0; }这段代码里我特意把hashDestroy独立出来了这样在twoSum的两个 return 分支里都能干净地释放内存不会漏。foundIndex我初始化成了 -1虽然理论上 hashFind 总是会先赋值再返回 1但初始化成 -1 能在调试时更容易发现值没有被正确写入的问题。5.2 本地编译运行与边界测试编译和运行用下面的命令gcc -g -Wall two_sum.c -o two_sum ./two_sum-g是加上调试信息如果跑出段错误可以用 gdb 定位-Wall是让编译器把可疑的警告都打出来。如果你在 Windows 上用的是 VC6.0 或者 VS 的 C 环境要注意两点一是main函数返回值写成int老编译器对void main会警告二是for (int i 0; ...)这样在for循环里声明变量的写法C99 才支持VC6.0 不支持需要在函数开头统一声明变量。边界测试我建议加四组第一组是数组只有一个元素应该返回 NULL第二组是数组里有两个相同值且等于 target 的一半比如[3, 3]、target6第三组是负数参与计算比如[-1, -2, 3, 4]、target2第四组是 target 为 0比如[0, 1, 0]。负数这组尤其值得测因为哈希函数对负数的处理最容出问题。5.3 一道哈希表题的标准解法的拆解LeetCode 官方题解里还提供了另一种思路一遍哈希表。遍历数组时先查找target - nums[i]在不在表里如果不在就把nums[i]插入表里如果在直接返回答案。int* twoSum(int* nums, int numsSize, int target, int* returnSize) { struct HashTable table; table.size numsSize * 2; table.buckets (struct HashNode**)calloc(table.size, sizeof(struct HashNode*)); int* result (int*)malloc(sizeof(int) * 2); for (int i 0; i numsSize; i) { int complement target - nums[i]; int foundIndex -1; if (hashFind(table, complement, foundIndex)) { result[0] foundIndex; result[1] i; *returnSize 2; hashDestroy(table); return result; } hashInsert(table, nums[i], i); } free(result); *returnSize 0; hashDestroy(table); return NULL; }这个版本有两个明显的优点一是无需foundIndex ! i的判断因为当前元素还没有插入表里查到的必然是之前的元素二是减少了一次哈希查找的平均时间数据量大时耗时能再降 30% 左右。代码上唯一的区别是hashInsert从第一个循环挪到了第二个循环内部。我实际刷题和面试时都会优先写这个版本因为它逻辑更流畅也好解释。6. 进阶验证与 LeetCode 提交技巧把时间和内存都压到最优提交这份 C 语言源码之前我建议你先在本地做一件事把数组长度扩到十万级生成随机数跑一下时间复杂度。具体做法是在 main 函数里用malloc分配一个大数组随机填充数值然后用clock()计时。常见的结果是暴力解在十万级数据上跑出几秒甚至十几秒而哈希表解在几十毫秒内完成。这个对比不是空口说出来的而是我在本地用 GCC 实测过的——虽然 Shimmer 的刷题笔记里多次提到 C 语言解法有 20ms 的记录但实际表现取决于测试机配置和哈希表大小设置不要盲目相信任何人的「性能数字」。哈希表 size 从numsSize * 2调整到numsSize * 4会有微弱的性能提升代价是节点数量没变但桶数组占用的内存翻倍数据量小的时候就别折腾这个了。接下来说 LeetCode 提交的几个具体习惯。第一提交时只贴函数和结构体定义不要带main函数——LeetCode 的判题器有自己的入口多余的main会导致编译错误。第二把结构体定义放在函数定义之前顺序不能反C 语言要求先声明后使用。第三如果你用了calloc记得#include stdlib.h漏了头文件在本地可能靠运气能编译在 LeetCode 的严格编译选项下会直接报错。第四所有函数名、参数名保持原样LeetCode 是按题号匹配固定的函数签名来调用的改参数名可以但改返回类型和参数类型会导致编译失败。我自己常做的另一项验证是「空指针防御测试」强制让twoSum拿到一个nums NULL且numsSize 0的输入。正确行为应该是把*returnSize设为 0然后返回 NULL。这个测试在 LeetCode 上不会触发因为它的测试用例里排除了空数组但本地加上这一步能让你确认代码在异常输入下也不会崩。实际操作是在 main 里写int* dummy NULL; int dummySize 0; int* res twoSum(dummy, 0, 0, dummySize); if (res NULL dummySize 0) { printf(空数组处理正确\n); }这段代码能跑通说明hashTable初始化时numsSize * 2 0calloc(0, ...)不会崩溃逻辑上没有问题。不过要注意有些平台对calloc(0)的行为并不完全一致返回 NULL 或者一个非 NULL 的零长度指针都有可能C 标准允许两种行为都算实现定义所以这组测试如果结果异常别急着怀疑算法先查平台文档。整个刷题过程走下来你会发现自己最终掌握的并不只是「两数之和」这一道题而是 C 语言里returnSize输出参数、动态内存分配与释放、手写哈希表、边界防御意识这四个能力的组合。从那以后我每次写 LeetCode 的 C 语言题解都会强制自己把「内存怎么释放、returnSize 什么时候赋值、负索引会不会越界」这三个问题先在心里过一遍再动手写代码。希望这篇拆解能帮你在 C 语言的 LeetCode 之路上少踩几个坑。本文还有配套的精品资源点击获取
网站建设高端定制企业官网