☰
C语言手写哈希表解两数之和:从暴力到O(n)的完整实现与避坑指南
2026/10/9 12:31:15 网站建设 项目流程

刷算法题的人应该都见过这道两数之和(Two Sum):给定一个整数数组和一个目标值,返回两个元素的下标,让它们加起来等于目标值。不少教程习惯用 Java、Python 直接调现成的 HashMap 或 dict,几行搞定。但我一直觉得,用 C 语言自己写一个哈希表来做这道题,才是真正把“哈希表”这颗数据结构之树连根拔起的过程——你需要自己设计哈希函数、处理冲突、管理内存,每一步都能踩出真实的经验。

这篇文我就完整拆一下 C 语言实现两数之和的全过程:从暴力解法的瓶颈讲起,到哈希表的原理和选型,再到完整的代码实现与调试实录,最后聊聊那些文档里不会写的坑。不管你是刚学完指针和结构体的 C 语言新手,还是在准备笔试面试想补数据结构基础,都可以在这篇里找到能直接抄作业的东西。

1. 从暴力解法讲起,看清两数之和的题眼

1.1 问题本身:看起来简单,其实有三道门槛

先把题目老老实实摆出来:给定一个数组nums,给定一个整数target,要求返回两个下标i和j,使得nums[i] + nums[j] == target。题目有几个容易被忽略的约束:每个输入只有唯一解;不能使用同一个元素两次;数组长度可以很大,大到 O(n²) 的算法可能直接超时。

我用 C 语言实现之前,一直觉得这道题“就这?”。实际上手之后才发现,考点根本不在于“会不会数学变形”,而在于三件事:一是能不能意识到暴力循环的慢,二是知不知道哈希表能把这个问题的复杂度从 O(n²) 降到 O(n),三是用 C 语言实现哈希表时,能不能把内存、哈希函数、冲突处理这些底层细节都收拾干净。

这三道门槛一过,两数之和就不再是“会写循环”就能糊弄过去的题,而是考察数据结构和工程习惯的试金石。

1.2 暴力解法为什么慢:看循环嵌套的实际成本

最简单的写法是双层循环,外层固定一个数,内层扫剩余的数:

int *twoSumBruteForce(int *nums, int numsSize, int target, int *returnSize) { for (int i = 0; i < numsSize; i++) { for (int j = i + 1; j < numsSize; j++) { if (nums[i] + nums[j] == target) { int *res = (int *)malloc(2 * sizeof(int)); res[0] = i; res[1] = j; *returnSize = 2; return res; } } } *returnSize = 0; return NULL; }

这个代码的语义没有问题,问题出在它的复杂度。当numsSize = n时,内层循环次数是等差数列求和:(n-1) + (n-2) + ... + 1 = n(n-1)/2。也就是说,当 n = 1 万时,要比较约 5000 万次;n = 10 万时,要比较 50 亿次。这个增长趋势是平方级的,在实际的笔试评测里,数组稍微给大一点就能让你眼睁睁看着超时。

你可能会想:“那我可以提前排序,再用二分查找。”确实可以,排序 O(n log n),二分 O(n log n),整体能压到 O(n log n)。但这个思路有个附加成本:排序会打乱元素顺序,你要想找回原始下标,就得用结构体把值和下标绑定再排序,代码会变得啰嗦。哈希表方案的优势就在于:既不改变原数组顺序,又能把查找时间降到常数级别。

1.3 空间换时间:为什么“边查边插”比“先插满再查”更聪明

哈希表的思路用一个生活类比来说就是查字典:暴力解法相当于把整本书从头翻到尾找某个字,哈希表方案则是先把“字 -> 页码”的索引建立起来,然后直接翻到那一页。

但两数之和有一个非常刁钻的细节:如果你先把整个数组一次性全部插入哈希表,再回过头来查,遇到数组里有重复元素时会出错。比如nums = [3, 3]、target = 6,两个 3 都在数组里,但同一个 key(3)在哈希表里只能存一个下标。如果先插入,后一个 3 会把前一个 3 的下标覆盖掉,最后你只能得到一个下标,找不到两个。

正确的做法是边遍历边查询:遍历到第 i 个元素时,先查target - nums[i]在不在表里;如果不在,再把nums[i]作为 key、i 作为 value 插入。这样既不会丢重复元素的下标,还能保证每次查询到的下标一定在 i 之前,天然满足“不能用同一个元素两次”的约束。

这个“边查边插”的细节,就是我开头说的题眼。很多初学者甚至一些网上的代码,都是先填满表再查询,遇到[3, 3]或者[2, 2, 2]这类用例就会翻车。从数据结构设计的角度看,这一个细节决定了代码正确性的边界。

2. C 语言实现哈希表的全套方案

2.1 为什么不用现成的哈希表库:C 语言没有“白嫖”的快乐

写 Java 和 Python 的人可能体会不到,C 标准库里没有通用哈希表。虽然有些项目会引入 glib、uthash 这类第三方库,但在刷题场景和很多嵌入式/底层开发场景里,要么不允许,要么不值得为一道题引入一个依赖。所以你想在 C 语言里用哈希表,只能自己写。

自己写的第一个好处是彻底搞清楚哈希表的内部结构:桶数组、节点链表、哈希函数、冲突处理,缺一不可。第二个好处是你能按需裁剪:两数之和只需要存 int 键和 int 值,那就不需要泛型,直接用两个 int 字段搞定。第三个好处是,如果将来面试官追问“这个哈希表能不能扩容”或者“冲突太多怎么办”,你至少知道答案在哪里。

这部分内容都可以在我的 GitHub 仓库里看到完整源码(见文章最后的链接),下面我先把最关键的设计决策讲清楚。

2.2 哈希函数怎么选:直接取模 vs 位运算扰动

哈希函数的价值是让不同的 key 尽可能均匀地落在桶里,减少冲突。最简单的做法是对容量取模:hash = key % capacity。但直接取模在两种情况下会很难看:一是 key 是负数时,C 语言的%运算结果也是负数,直接拿它当数组下标就会越界;二是当 capacity 是 2 的幂时,直接取模只保留低几位,如果 key 的最低几位呈现规律性(比如全是偶数),冲突就会扎堆,链表拉得老长。

我的做法是:先把 key 转成无符号整数,再做一个类似 MurmurHash 的扰动处理,最后对容量取模:

static int hash_key(int key, int capacity) { unsigned int h = (unsigned int)key; h ^= h >> 16; h *= 0x7feb352d; h ^= h >> 15; h *= 0x846ca68b; h ^= h >> 16; return (int)(h % (unsigned int)capacity); }

这种做法的原理是:h ^= h >> 16把高 16 位的信息扩散到低 16 位,两个乘法用的是大质数常量,让 bit 之间的互相影响更充分。处理完后再取模,无论 key 是正数、负数还是很大的数,结果都会均匀分布在[0, capacity)区间内。虽说两数之和的测试用例不至于这么刁钻,但把哈希函数写规范了,以后你把它抠出来还能用在别的地方。

如果追求极致性能,因为哈希表容量我会特意设计成 2 的幂,取模可以用位运算替代:h & (capacity - 1)。位与运算比除法快很多,这在循环密集的场景下有实际意义。

2.3 数据结构定义与内存布局:链表法和开放寻址二选一

哈希表的冲突处理有两个主流流派:链地址法和开放寻址法。链地址法在每个桶下面挂一条链表,key 冲突了就链到桶后面;开放寻址法不挂链表,而是顺着数组往后找空位。我选了链地址法,因为它实现直观,节点删除和遍历都好写,而且不受“装太满”的致命影响(只是链表变长,性能退化)。

对应地,结构体定义如下:

typedef struct HashNode { int key; // 数组元素值 int value; // 数组下标 struct HashNode *next; // 指向同桶下一个节点 } HashNode; typedef struct HashTable { HashNode **buckets; // 桶数组,元素是指针 int size; // 桶数量 int count; // 已存储节点数 } HashTable;

这里的重点理解在于HashNode **buckets的双重指针。buckets本身是一块连续内存,每个元素是一个HashNode *,这个指针要么是 NULL,要么指向一个链表头。分配桶数组时我用calloc,因为calloc会把内存清零,所有桶初始都是 NULL;如果用malloc,还要手动 memset 一遍,容易漏。

2.4 容量规划与扩容机制:为什么我建议容量取大一点

哈希表的性能受负载因子影响,负载因子 = 节点数 / 桶数。链地址法最简单实用的经验值是把负载因子控制在 0.75 以下,超过这个值冲突概率会快速上升。

因为两数之和已经知道numsSize,最省事的做法是让容量直接满足capacity >= 2 * numsSize,再向上取整到 2 的幂。比如numsSize = 5,那我至少需要容量 10,向上取到 16。这样负载因子最多 0.5,冲突很少,而且不需要实现扩容逻辑,代码干净。

如果做可用于更多场景的通用哈希表,那扩容逻辑就得保留:当count * 10 > size * 7(即负载因子超过 0.75)时,新建一个双倍大小的桶数组,把所有旧节点重新计算哈希并插入新桶。这一步叫 rehash,看起来简单,但漏了它,哈希表在数据量大了以后会慢到让你怀疑人生。

3. 完整代码与逐步拆解

3.1 核心函数清单:分四层写,逻辑才清晰

我习惯把代码拆成四块:哈希函数、哈希表基础操作(创建、销毁、插入、查询)、主流程 twoSum、测试用例。这样每个函数都短小精悍,出问题也容易定位。

整体流程是这样的:先根据numsSize算容量创建哈希表,然后从头到尾遍历数组,对于每个元素nums[i],先查target - nums[i]是否在表里;如果在,直接返回[查到的下标, i];如果不在,把nums[i] -> i插入哈希表。遍历完还没找到,就返回 NULL,并把returnSize置为 0。

3.2 初始化与释放:malloc 之后必须配对 free

我见过太多 C 语言同学写出能跑的哈希表,却在内存管理上翻车。初始化一个哈希表的完整代码如下:

HashTable *ht_create(int capacity) { HashTable *ht = (HashTable *)malloc(sizeof(HashTable)); if (!ht) return NULL; ht->buckets = (HashNode **)calloc(capacity, sizeof(HashNode *)); if (!ht->buckets) { free(ht); return NULL; } ht->size = capacity; ht->count = 0; return ht; }

注意第二层calloc失败时,不仅要把buckets释放掉,还要把ht也释放掉,否则就内存泄漏了。很多入门教程写代码只检查最后一层失败,中间的失败路径全漏了,这是坏习惯。

销毁函数更要仔细,必须遍历每一个桶,把链表上的每个节点都 free 掉,再释放桶数组本身,最后释放哈希表结构体:

void ht_destroy(HashTable *ht) { if (!ht) return; for (int i = 0; i < ht->size; i++) { HashNode *node = ht->buckets[i]; while (node) { HashNode *next = node->next; free(node); node = next; } } free(ht->buckets); free(ht); }

这里的顺序非常重要:先保存next再free(node)。如果你先 free 了节点再去访问node->next,那就是典型的 use-after-free,编译器不一定报错,运行起来却可能随机崩溃。

3.3 插入与查询的实现:两个操作围绕同一个头插法

插入操作ht_put的逻辑是先算出哈希桶下标,再遍历桶链表。如果链表中已有相同的 key,说明插入的是重复元素,此时应更新 value 而不是再建一个新节点,否则同一个 key 会占两个位置,查询时很难保证返回哪个。如果没找到相同 key,就新建一个节点,用头插法挂到链头:

void ht_put(HashTable *ht, int key, int value) { int idx = hash_key(key, ht->size); HashNode *node = ht->buckets[idx]; while (node) { if (node->key == key) { node->value = value; return; } node = node->next; } HashNode *new_node = (HashNode *)malloc(sizeof(HashNode)); new_node->key = key; new_node->value = value; new_node->next = ht->buckets[idx]; ht->buckets[idx] = new_node; ht->count++; }

查询操作ht_get和插入前半段几乎一样:算下标,遍历链表,比对 key。找到了就把 value 通过指针参数带出去,返回 1;没找到返回 0。这里把“是否找到”和“值是多少”分开,用返回值表达状态,用指针参数带出数据,是 C 语言里很常见的接口风格,比单纯返回 -1 或 0 要清晰得多。

3.4 主流程 twoSum:把查和插的顺序反一反,就是正确与错误的差别

主函数一点也不复杂,但顺序是精华:

int *twoSum(int *nums, int numsSize, int target, int *returnSize) { *returnSize = 0; if (numsSize < 2) return NULL; int capacity = 16; while (capacity < numsSize * 2) { capacity <<= 1; } HashTable *ht = ht_create(capacity); if (!ht) return NULL; int *res = (int *)malloc(2 * sizeof(int)); if (!res) { ht_destroy(ht); return NULL; } for (int i = 0; i < numsSize; i++) { int complement = target - nums[i]; int j = 0; if (ht_get(ht, complement, &j)) { res[0] = j; res[1] = i; *returnSize = 2; ht_destroy(ht); return res; } ht_put(ht, nums[i], i); } ht_destroy(ht); free(res); return NULL; }

为什么先查询再插入?我再强调一遍:因为要保证查询到的下标一定小于当前下标i。如果nums[i]和complement相等,比如[3, 3]这种情况,遍历到第二个 3 时,哈希表里存的第一个 3 的下标还在,查询能正确返回[0, 1];如果先插入再查询,第二个 3 会把第一个 3 覆盖成 1,查询到自己,就返回了错误结果。

capacity的起点是 16,然后不断自我翻倍直到不小于numsSize * 2。这个写法保证了桶数量始终是 2 的幂,并且负载因子不超过 0.5。capacity = 16的初始值意味着数组长度在 1 到 8 之间时都用 16 个桶,避免了频繁扩容。注意while (capacity < numsSize * 2)用的是<而不是<=,否则当numsSize * 2正好等于 capacity 时还会多扩一次倍,白白浪费内存。

3.5 测试用例跑一遍:不仅测正常输入,还要测边界

我不会只在 main 里测一个用例就收工,至少要覆盖这几类:普通用例[2, 7, 11, 15]、重复元素用例[3, 3]、负数用例[-3, 4, 3, 90]、找不到解的情况。测试代码本身不复杂,重要的是养成“写完算法先跑边界用例”的习惯。

int main(void) { int nums1[] = {2, 7, 11, 15}; int returnSize = 0; int *res1 = twoSum(nums1, 4, 9, &returnSize); printf("test1: res = [%d, %d]\n", res1[0], res1[1]); free(res1); int nums2[] = {3, 3}; res1 = twoSum(nums2, 2, 6, &returnSize); printf("test2: res = [%d, %d]\n", res1[0], res1[1]); free(res1); int nums3[] = {-3, 4, 3, 90}; res1 = twoSum(nums3, 4, 0, &returnSize); printf("test3: res = [%d, %d]\n", res1[0], res1[1]); free(res1); int nums4[] = {1, 2, 3}; res1 = twoSum(nums4, 3, 99, &returnSize); printf("test4: res = %p (NULL means not found)\n", (void *)res1); return 0; }

这里要特别强调free(res1)和 NULL 检查。你从twoSum拿到的结果是在堆上分配的,不用了就得 free。在第 4 个用例里,找不到结果时函数返回 NULL,printf("%p", res1)不会崩,但你如果直接res1[0]就会解引用空指针。真正常用的严谨做法是:调用 twoSum 后判断if (res1 != NULL)再访问数组,我在测试代码里省略了是为了示意,实操中不要学。

4. 实操踩坑实录:常见问题与排查技巧

4.1 内存泄漏与 use-after-free:最容易犯,也最难看出来

用 C 语言写哈希表,内存问题是老大难。我第一次跑通代码时,哈希表销毁函数写得特别随意:只释放了桶数组,没释放每个节点。结果程序跑完内存占用不释放,用 valgrind 一查,整屏都是 lost records。

排查这类问题的经验是顺手就用 valgrind。命令很简单:

gcc -g -o two_sum two_sum.c valgrind --leak-check=full ./two_sum

看到definitely lost: 0 bytes说明内存全部回收;如果有 lost,它会精确告诉你是在哪个函数里 malloc 的。我还会把-fsanitize=address也加上,它能在运行时就报越界和 use-after-free,比事后看日志更快定位。

老规矩,malloc / calloc 和 free 必须成对出现。我在twoSum函数里有三个退出路径:找到解返回、没找到返回、参数非法返回。每一个返回之前都要考虑哈希表释放了没有、结果数组释放了没有。这种“多返回值路径下的资源管理”是 C 语言的必修课,也是面试官最爱追问的点。

4.2 负数键与哈希函数的坑:不转无符号就等着崩

如果哈希函数写成key % size直接用,遇到负数键时会发生什么?C 99 之前的行为是实现相关的,可能是负数;目前的 C 标准里,%的符号由被除数决定,-3 % 16结果也是负数(具体是 -3)。拿负数当下标访问buckets[idx],轻则访问到错误位置,重则导致段错误。

我的解决办法是先用(unsigned int)key转换,再参与位运算。无符号整数的运算结果始终是非负的,最后再% (unsigned int)capacity得到的一定是[0, capacity)区间内的下标。这样无论原始 key 是-7、-100000还是2147483647,都不会发生越界。

这个转换的原理说起来也很简单:C 语言里无符号整数有自己的一套取模运算规则,相当于把负数的二进制补码当成了一个大正数来处理。对于哈希散列来说,我们本来就不关心它的数学含义,只关心 bit 模式和均匀性,所以这种转换是安全的。

4.3 重复元素和相同的 key:更新还是新建,必须想清楚

链地址法哈希表里,插入时如果发现 key 已经存在,是新建一个节点链上去,还是更新原节点的 value?这个问题我第一次写的时候没细想,直接无脑头插,结果同一个 key 在链表里出现了两个节点。查询时找到的是哪一个是随链表顺序决定的,两数之和的答案就变得不确定了。

正确语义应该是更新原节点的 value,因为从两数之和的角度看,同一个数组元素值只能对应最新的下标(遍历到哪个就存哪个)。插入时先遍历链表,命中了就node->value = value; return;,没命中才新建节点。

这个更新语义在其它哈希表应用里同样重要。比如做词频统计时,同一个单词第一次出现 value = 1,第二次出现应该在原 value 上加 1,而不是再插一个新节点。牢记:哈希表的语义是 key 唯一,value 可覆盖。

4.4 性能退化:哈希函数不行,桶再大也白搭

我做过一个很有意思的实验:把一个很差的哈希函数(比如直接取模,capacity 也是 2 的幂)应用在一个全是偶数的数组上。假设数组长度是 10000,容量是 16384,直接key & 16383,偶数 key 后三位永远是 0,结果所有元素都落到 0、8、16……之类的桶里,其他桶全是空的,链表长度几百上千,查找退化成线性扫描。

好的哈希函数要做的事情就是把低位的规律性打散。我前面写的那个扰动函数,本质就是“雪崩效应”:任何一位 bit 的变化,经过右移、异或、乘法之后,会影响结果的一半以上 bit。这样即使输入是连续的偶数,落在桶里的下标也会均匀分布。

如果你懒,可以用最简单但有效的方式:给 capacity 选一个大质数而不是 2 的幂,然后用直接取模,也能获得不错的分布。只是质数容量没法用位与替代取模,速度会略慢。这是空间和时间的权衡,看场景取舍。

5. 复杂度分析与方案对比,这道题还能玩出什么花

5.1 复杂度分析:从 O(n²) 到 O(n),代价是什么

暴力解法的时间复杂度是 O(n²),空间复杂度是 O(1)。哈希表方案把遍历一遍的过程中,每次查询和插入的平均耗时都压到 O(1),所以总时间降到 O(n);代价是额外空间 O(n),因为最多需要存储 n 个节点。

这里有个细节值得说清楚:哈希表的 O(1) 是平均情况,不是严格意义的最坏情况。最坏情况下,如果所有 key 都撞到同一个桶,链表长度是 n,查询复杂度退化到 O(n),整体又变成 O(n²)。所以哈希函数的均匀性和容量的合理性不是可选项,而是性能安全的保证。

我在代码里把容量设为不小于两倍数组长度,负载因子压到 0.5 以下,配合扰动哈希函数,最坏情况在实际测试数据里几乎不可能出现。这是工程思维的体现:算法理论上限很重要,但你要是真跑一个百万级数组,哈希分布好坏直接决定会不会超时。

5.2 和其它解法对比:排序加双指针、暴力、哈希表

排序加双指针是另一种经典思路:先把数组排成升序,再用两个指针从首尾往中间夹逼寻找目标。它的时间复杂度是 O(n log n),空间 O(1)。相比哈希表,它不需要额外内存,但不能直接用原数组下标,因为排序后下标变了。如果题目返回的是值而不是下标,排序解法是个不错的选择。

这三种方案适合的场景完全不同,我做了一个简单的对比:

方案时间复杂度空间复杂度是否保持原数组顺序适合场景
暴力双层循环O(n²)O(1)是数组很小,只为理解题意
排序 + 双指针O(n log n)O(1)否(需记录原下标)内存受限,值而非下标
哈希表O(n)O(n)是数组较大,笔试面试标准解

从刷题和面试的角度看,哈希表是默认首选。但懂一点排序解法也有价值,因为当面试官追问“如果内存很小怎么办”,你能立刻给出降空间的方案,这种对比展示比单纯背题更有说服力。

5.3 扩展思考:从两数之和到三数之和、四数之和

把问题扩展一下:如果要求返回三个数a + b + c = 0的所有组合,怎么办?一个通用做法是固定一个数,然后对剩下的区间做两数之和。最外层的数用循环枚举,内层的两数之和可以用哈希表或双指针。类似地,四数之和就是固定两个数,再对剩余区间做两数之和。

这种扩展题的目的不只是考你会不会套模板,而是考你有没有真正理解“如何把一个大问题分解成子问题”。两数之和的哈希表解法在这里的价值是:它让你直观体会“查找操作如何从线性变成常数”,这种体会放在任何后续题目里都能迁移。

哈希表在真实项目里的应用就更多了:缓存 LRU、全局唯一 ID 到对象映射、字频统计、数据库索引底层思想、布隆过滤器前置,可以说 C 语言开发的很多系统底层都少不了它。

5.4 通用化改造:让两数之和的哈希表变成你的工具库

两数之和的哈希表特化得比较厉害,key 和 value 都是 int。如果你希望这套代码能在更多场景用,可以考虑三步改造:一是把 value 从 int 换成任意类型,需要引入void *和比较函数指针;二是把容量规划做成自动扩容,负载因子到 0.75 时自动翻倍重建;三是把哈希函数做成可配置,支持字符串 key。

这已经不是“两数之和”的范畴了,而是写一个你自己的通用哈希表库。我建议有精力的人做一次这样的改造,因为 C 语言里任何自带的数据结构都会限制你思考深度,而自己写过的哈希表会让你对“查找”这件事的理解彻底不一样。以后再看 Java 的 HashMap、Redis 的 dict,你都会发现它们的设计思路和你手写的版本一脉相承。

我在实际使用 C 语言实现这个题目时,最大的体会是:刷题最怕的不是做不出来,而是以为自己做出来了。代码跑通给你造成的“我很会了”的错觉,往往会在真正考察细节的时候一击即溃。哈希表这道题能不能写得又快又稳,基本能反映你对数据结构、内存管理和边界条件的综合把握程度。建议你也亲手跑一遍上面的代码,再把负数、重复元素、大数组三个用例测完,然后试着改造成通用版本试试手——这个过程比看十篇教程都有用。

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

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

立即咨询