从小到大,看着LeetCode题库里"两数之和"长期挂在第一题的位置,我一直觉得它像一道门槛——跨过去的人会觉得哈希表真香,跨不过去的人则容易被C语言里那一堆结构体、指针、malloc劝退。说句实话,这道题在C语言解法里是最能体现"为什么需要用哈希表"的入门题之一:它不涉及复杂的递归、贪心或者动态规划,纯粹考察一件事——当你需要在遍历中快速查找某个值是否存在时,哈希表为什么是效率最优的选择。
如果你正在用C语言刷LeetCode,或者刚刚开始接触哈希表数据结构,这篇文章会从暴力解法开始,逐步拆解哈希表的原理,最终给出一份可以直接提交的完整C代码。我会把哈希桶的设计、哈希函数的选择、内存分配这些实操细节全部摊开讲,并且分享一些我在LeetCode上反复提交时踩过的坑。不管是刚入门的小白,还是想看C语言工程化写法的老手,应该都能从中拿到点东西。
1. 一道"简单题"的前置认知:暴力解法为什么被嫌弃
1.1 题目到底在问什么
先把题目复述一遍:给你一个整数数组nums和一个整数目标值target,你需要在数组中找出和为目标值target的两个整数,并返回它们的数组下标。假设每种输入只会对应一个答案,且同一个元素不能使用两次。
LeetCode给C语言的函数签名长这样:
int* twoSum(int* nums, int numsSize, int target, int* returnSize);nums:传入的整型数组numsSize:数组长度target:目标和returnSize:你要告诉调用者返回数组的长度,这里固定填 2
可能第一次接触这个签名的人会懵:为什么还要一个returnSize指针?因为C语言无法直接返回数组的长度信息,返回的只是一个int*,调用者得靠额外的参数才知道你返回了几个元素。这个设计在LeetCode的所有C语言题解里几乎都会出现,建议一开始就养成习惯:不管函数内部做了什么,最后一定要给*returnSize赋值,否则判题系统无法正确读取结果。
1.2 暴力解法的效率账
拿到这道题,正常人第一反应都是两层循环:
int* twoSum(int* nums, int numsSize, int target, int* returnSize) { int* result = (int*)malloc(2 * sizeof(int)); for (int i = 0; i < numsSize; 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; } } } *returnSize = 0; return NULL; }逻辑完全正确,功能也没问题。但问题出在效率:外层循环跑n次,内层循环在极端情况下要跑接近n次,总复杂度是 O(n²)。当n是几千的时候,程序还能勉强跑完;但当n到达几十万甚至上百万时,O(n²) 的执行时间会迅速膨胀到无法接受的程度。
我打个比方。你在一个能容纳一万人的体育馆里找两个人,要求他们的年龄加起来等于某个数。暴力做法是:站到第一个人面前,然后挨个问剩下的九千九百九十九个人的年龄;没找到,再去问第二个人,再把剩下的九千九百九十八个人问一遍……一万个人,你要开口问大约五千万次。这当然能完成,但没人觉得这是聪明的做法。
LeetCode之所以把题目难度标成 Easy,不是因为暴力法能过,而是因为存在 O(n) 的思路。所以才说,这道题真正的考点是:你知不知道有比暴力查找更快的索引方式。
2. 为什么哈希表能带来质变:从O(n²)到O(n)的思维转变
2.1 暴力解法浪费在哪里
仔细看暴力解法,内层循环做了一件很笨的事:每次都在剩余的数组元素里线性查找target - nums[i]。这个查找过程每次都是 O(n),而查找又是整个算法的核心操作,所以整体复杂度上不去。
如果我们能把"查找一个元素是否存在于集合中,并且快速拿到它的下标"这一步从 O(n) 优化到 O(1),那么整个遍历只要一遍就能完成:走到nums[i]时,只要 O(1) 看一眼之前有没有存过target - nums[i]即可。于是总复杂度就是 O(n)。
问题来了,怎么做到 O(1) 查找?答案就是哈希表。哈希表本质上是一个"空间换时间"的结构——我们额外用一块内存来记录已经遍历过的元素,从而把查找的时间降下来。
2.2 哈希表的核心机制
哈希表,也叫散列表,它的核心思想是:通过一个哈希函数,把关键字(key)直接映射为数组下标,然后把值(value)存储在这个下标对应的位置上。这样查找时只要重新计算哈希函数,就能直接定位到数据所在的位置,不需要逐个比较。
打个比方。小区门口有快递柜,柜门上贴着编号。快递员一开始就把每个柜子的编号通过一种固定规则关联到收件人的手机尾号。等你去取件时,只要报出手机尾号,快递员一算就知道你的件在哪个柜子,不用把一百个柜子全打开找一遍。哈希函数就是这个"从手机尾号到柜号"的映射规则。
当然,生活不会永远这么完美。两个不同关键字可能映射到同一个下标,这就是哈希冲突。解决冲突有很多种办法,C语言手写时最常用的有两种:
- 开放寻址法:冲突了就往后找空位
- 链地址法:每个桶里不是存一个元素,而是存一个链表的头结点,冲突的元素依次挂到链表后面
对于LeetCode这种判题环境,我用得最多的是链地址法。原因很简单:实现直观、删除简单、代码容易检查。后面的完整代码就是基于链地址法实现的。
还有一个概念叫装填因子:哈希表中已有的元素个数除以桶的数量。装填因子越小,冲突越少,查找效率越高,但内存浪费也越多;装填因子越大,空间利用越充分,但冲突会变多,链表变长,性能退化。工程上一般控制在 0.75 左右,这是经验值。刷题时为了简单,桶数直接取数组长度的两倍,装填因子就是 0.5,冲突概率很小,做起来很舒服。
2.3 在这道题里哈希表到底存什么
哈希表里存的key和value分别是:key = 数组元素值,value = 该元素的下标。
算法流程是这样的:
- 创建一个空的哈希表
- 从头到尾遍历数组,假设当前元素是
nums[i] - 在哈希表里查找
target - nums[i]:- 如果找到了,说明之前已经遍历到某个下标
j满足nums[j] = target - nums[i],直接把[j, i]返回即可 - 如果没找到,就把
(nums[i], i)插入哈希表,然后继续往后走
- 如果找到了,说明之前已经遍历到某个下标
- 遍历结束还没找到,按题意不会发生;但严谨起见还是返回空并设置
returnSize = 0
为什么一定要一边遍历一边插入,而不是先把所有元素插进去再查找?原因在于:如果先把所有元素插入哈希表,再回头找target - nums[i],那么需要额外判断"查到的下标不能等于 i 自身",防止同一个元素用了两次。而边遍历边插入的方案天然避免了这个问题——哈希表里存的都是"之前"遍历过的元素下标,不可能与当前下标相同。这是实现上很巧妙的一个细节。
3. C语言版哈希表怎么落地:手写链地址哈希表
3.1 C语言为什么"没有"现成的哈希表
用过C++的人都知道unordered_map,用过Java的人都知道HashMap,Python里甚至有现成的dict,它们在底层就是哈希表。但C语言的标准库里没有这些高级容器,LeetCode的判题环境也不允许你随手引入第三方库。所以在C语言题解中,哈希表必须自己从头实现。
这算不算麻烦?确实比写map[key] = value要麻烦得多。但换个角度想,正因为C语言没有现成的,你才有机会把一个哈希表的每个细节都看得清清楚楚:哈希函数怎么选、冲突怎么解决、内存怎么分配和释放。这些底层细节搞明白了,以后用任何高级语言的哈希表,都只是API熟练度的问题。
以我在LeetCode上刷题的经验,C语言的劣势是代码量大,优势是一旦把结构体、指针、内存这些都理顺,对数据结构的理解深度会远高于直接调API的人。
3.2 哈希桶结构体的设计
链地址法需要两个东西:一个Node结构体表示链表节点,一个Node**数组表示桶数组。
typedef struct Node { int key; int val; struct Node* next; } Node;key:存数组元素值val:存数组下标,也就是最终要返回的值next:指向下一个冲突节点的指针
桶数组用Node** buckets表示,每个元素是一个链表头指针。初始化时需要让所有桶都指向 NULL,这一点特别重要。C语言的局部数组不会自动清零,malloc出来的内存内容也是不确定的,必须显式初始化,否则后面遍历链表时会拿着野指针乱撞。
哈希函数的选择上,最常用的做法是取模:
int hash(int key, int size) { if (key < 0) { return (key % size + size) % size; } return key % size; }有人会问:为什么要对负数做两次取模?因为C语言里负数取模的结果可能是负数。比如-7 % 5在C语言里结果是-2。如果直接用这个负数当数组下标,程序会崩溃。(key % size + size) % size的做法能保证结果一定落在[0, size - 1]之间。相比直接用abs(key) % size,这个写法更稳妥,因为abs(INT_MIN)在某些编译器上会返回负数,而上面的取模写法不会踩到这个坑。
3.3 插入与查找的完整实现
插入用头插法,就是把新节点挂到链表的最前面。头插法的好处是时间复杂度 O(1),而且代码特别简洁:
void insert(Node** buckets, int size, int key, int val) { int index = hash(key, size); Node* newNode = (Node*)malloc(sizeof(Node)); newNode->key = key; newNode->val = val; newNode->next = buckets[index]; buckets[index] = newNode; }先让新节点的next指向原来的链表头,再把桶的头指针更新为新节点,两步就完成了头插。
查找函数就是走到哈希函数算出来的桶下标位置,然后沿着链表逐个比较key:
int find(Node** buckets, int size, int key, int* foundVal) { int index = hash(key, size); Node* cur = buckets[index]; while (cur != NULL) { if (cur->key == key) { *foundVal = cur->val; return 1; } cur = cur->next; } return 0; }找到返回 1,并通过指针参数把value传出去;没找到返回 0。这个设计避免了用特殊值(比如 -1)表示"没找到"时产生的歧义——万一某个元素的下标真的就是 -1 呢?在C语言里,能用指针回传结果就尽量用指针,不要用哨兵值。
3.4 释放内存:被很多人忽略的最后一步
写完题目,很多人直接提交就算完事,完全不释放哈希表的内存。在LeetCode上确实不影响判题结果,但会造成内存泄漏。特别是当判题系统在一个进程里连续跑几百个测试用例时,泄漏会累积起来,最终可能导致内存不足。
我一般会在返回结果之前,把哈希表整张释放掉:
void freeTable(Node** buckets, int size) { for (int i = 0; i < size; i++) { Node* cur = buckets[i]; while (cur != NULL) { Node* tmp = cur; cur = cur->next; free(tmp); } } free(buckets); }每个链表节点都要单独释放,最后释放桶数组本身。这个过程虽然麻烦,但能帮你养成一个好习惯:每次malloc都和free配对出现。实际工程中内存泄漏是非常隐蔽的bug,从刷题开始就养成规范意识,后面写项目时能少掉很多头发。
4. 完整可提交的C代码:一次遍历版twoSum
4.1 代码全文
把上面的组件拼起来,再加上twoSum主体逻辑,就是一份可以直接提交到LeetCode的完整代码:
#include <stdlib.h> #include <string.h> typedef struct Node { int key; int val; struct Node* next; } Node; int hash(int key, int size) { if (key < 0) { return (key % size + size) % size; } return key % size; } void insert(Node** buckets, int size, int key, int val) { int index = hash(key, size); Node* newNode = (Node*)malloc(sizeof(Node)); newNode->key = key; newNode->val = val; newNode->next = buckets[index]; buckets[index] = newNode; } int find(Node** buckets, int size, int key, int* foundVal) { int index = hash(key, size); Node* cur = buckets[index]; while (cur != NULL) { if (cur->key == key) { *foundVal = cur->val; return 1; } cur = cur->next; } return 0; } void freeTable(Node** buckets, int size) { for (int i = 0; i < size; i++) { Node* cur = buckets[i]; while (cur != NULL) { Node* tmp = cur; cur = cur->next; free(tmp); } } free(buckets); } int* twoSum(int* nums, int numsSize, int target, int* returnSize) { int tableSize = numsSize * 2; Node** buckets = (Node**)calloc(tableSize, sizeof(Node*)); for (int i = 0; i < numsSize; i++) { int complement = target - nums[i]; int foundVal; if (find(buckets, tableSize, complement, &foundVal)) { int* result = (int*)malloc(2 * sizeof(int)); result[0] = foundVal; result[1] = i; *returnSize = 2; freeTable(buckets, tableSize); return result; } insert(buckets, tableSize, nums[i], i); } freeTable(buckets, tableSize); *returnSize = 0; return NULL; }4.2 逐步拆解主体逻辑
这段twoSum的核心流程就是前面讲的"边遍历边查找边插入":
tableSize = numsSize * 2:桶数组长度取数组长度的两倍,保证装填因子不超过 0.5,冲突很少calloc(tableSize, sizeof(Node*)):calloc会把内存清零,也就是把所有桶初始化为 NULL,这一步很省心- 遍历到
nums[i]时,先算complement = target - nums[i],然后到哈希表里查找 - 找到就分配结果数组并填好下标,然后释放哈希表,返回结果
- 没找到就执行
insert,把当前元素存入哈希表,继续循环 - 如果循环走完还没找到(按题目的约束不会发生),释放资源后返回 NULL,并设置
*returnSize = 0
每一步的顺序都是精心安排过的:先查再插,保证了不可能出现"同一个元素和自己匹配"的情况。
4.3 边界条件与隐藏细节
C语言的题解里最容易翻车的不是算法思路,而是内存和指针的边角细节。以下几个点是我反复踩过、后来形成肌肉记忆的地方:
- 结果数组必须用 malloc 分配。C语言函数里如果返回一个局部数组的首地址,函数结束后栈帧被回收,这个指针就成了悬垂指针。LeetCode的判题系统读到的是垃圾数据,程序行为不可预测。
*returnSize一定要赋值。如果你忘了设置它,判题系统就不知道返回数组有多长,可能会越界读取内存。哪怕返回 NULL,也要把*returnSize设为 0。- 负数的哈希处理。上面用的是两次取模方式,比较稳妥。有些题解用
abs(key) % size,一般情况下没问题,但要意识到abs的边界风险。 calloc和malloc + memset是等价的。我选择calloc纯粹是可以少写一行初始化代码。如果要用malloc,必须立刻补一句memset(buckets, 0, sizeof(Node*) * tableSize)。
5. 我在LeetCode上反复提交时踩过的坑
5.1 哈希桶初始化遗漏:野指针崩溃现场
有一段时间我写C语言代码图省事,初始化哈希表时用了malloc却忘记清零,直接在后面插入节点。结果程序运行到链表遍历时,访问了一个完全随机的地址,直接段错误。
排查了很久才意识到:malloc返回的内存是不确定的内容,它不会自动替你清零。所以桶数组初始状态可能是一些乱七八糟的"垃圾指针"。你往链表头部插入节点时,newNode->next = buckets[index]就把这个垃圾地址挂到了链表上。等到查找时遍历链表,程序就会按垃圾地址去访问内存,崩溃是必然的。
后来我强制自己遵守一个规则:只要分配了哈希桶数组,下一步必做清零操作。用calloc也好,用memset也好,总之这一步不能省。
5.2 内存释放顺序搞反:先释放桶数组再释放链表节点
还有一个我犯过不止一次的错:释放哈希表时,先free(buckets),再想着去释放链表节点。这完全把顺序搞反了——桶数组都没了,链表头指针从哪来?程序在第二次释放时就会访问已释放的内存。
正确的顺序一定是:先遍历所有桶,把每个桶下面的链表节点逐个释放干净,最后再释放桶数组本身。也就是我前面freeTable里的顺序。实在记不住的话,可以想象成拆房子:得先搬走屋里的家具,才能拆承重墙,最后拆除大楼的立柱。
5.3 哈希函数负数取模踩坑:下标变成负数
LeetCode的两数之和题目里,nums中的元素是允许出现负数的。我第一次提交的哈希函数是这样写的:
int hash(int key, int size) { return key % size; }当时数组里碰巧全是正数,测试样例都过了。后来我换了一套包含负数的测试数据,程序直接数组越界,判题系统报了 Runtime Error。
原因前面提到过,C语言的求余运算在处理负数时结果可能是负数。-3 % 5在C语言里等于-3,如果你拿这个当数组下标,当然越界。从那之后我的哈希函数就固定成了(key % size + size) % size这种写法,不为别的,就为稳妥。
5.4 返回值里下标顺序不一致导致提交失败
还有一次提交失败,不是因为算法错,而是因为返回的[j, i]顺序和请求的下标顺序不一致。详情记不太清了,只记得最后是通过仔细读题才发现的。这里给大家的提醒是:LeetCode对返回的两个下标要求是可以乱序的,你的代码里先找到哪个下标就把哪个放前面,这个完全由自己控制,但要保证最终返回的两个值确实来自两个不同的数组位置。
我在代码里习惯先返回之前存进哈希表的下标foundVal,再返回当前遍历到的下标i。这个顺序写清楚后,就没有再出过类似问题。
5.5 LeetCode判题环境中的内存泄漏问题
可能很多人觉得LeetCode跑完一个测试用例就会回收内存,不释放也没关系。但我在刷题时遇到过个别题目,如果每次调用都泄漏一点内存,多次调用之后程序会变得异常慢甚至崩溃。
LeetCode的判题常常会用一个进程连续调用你的函数多次。twoSum这种函数每调用一次,哈希表就泄漏一次。虽然单次量很小,但如果测试用例有几万个,那泄漏的内存就会非常可观。因此,哪怕LeetCode不报错,我也会在返回之前释放掉所有动态分配的内存。这是代码洁癖,更是工程素养。
6. 这道题之外:哈希表的变体和后续刷题方向
6.1 从Two Sum到Three Sum:哈希表还管用吗
很多人在做完两数之和后,会自然地想:三数之和是不是也能用哈希表?结论是可以,但一般不建议这么做。Three Sum 的经典解法是"排序 + 双指针",时间复杂度 O(n²),如果硬要用哈希表,去重问题会非常麻烦。
但哈希表思想在两数之和的变体题目中依然非常实用,比如:
- LeetCode 167. Two Sum II - Input Array Is Sorted:数组有序,可以用双指针,也可以用哈希表
- LeetCode 1. 这道题的多个版本:HashMap存储"值到下标的映射"的通用模板可以套到很多题目里
我的建议是:两数之和用哈希表,三数之和用排序+双指针,四数之和则需要双指针加一层循环或者用哈希表辅助。不同的题目有不同最适合的解法,这个需要自己在刷题中慢慢积累感觉。
6.2 哈希表在LeetCode高频题中的位置
哈希表绝不只是为这道题准备的。我简单梳理了一下LeetCode上高频考察哈希表的题型,供你做一个后续刷题计划:
| 题目 | 难度 | 哈希表的作用 |
|---|---|---|
| 1. Two Sum | Easy | 值到下标的映射,O(1)查找补数 |
| 387. First Unique Character in a String | Easy | 统计字符出现次数 |
| 242. Valid Anagram | Easy | 统计字符频率做对比 |
| 49. Group Anagrams | Medium | 排序后的字符串作为哈希表key |
| 3. Longest Substring Without Repeating Characters | Medium | 记录字符最后一次出现的位置 |
| 560. Subarray Sum Equals K | Medium | 前缀和 + 哈希表,非常经典 |
| 128. Longest Consecutive Sequence | Medium | 用哈希集合判断连续序列的起点 |
其中 560 题是我个人认为最能体现哈希表"空间换时间"威力的题目,强烈推荐在掌握本题之后去挑战一下。
6.3 学习建议:C语言刷题到底值不值得
这个问题我经常被问到。说实话,如果你现在时间紧迫、面试在即,用C++或Python刷题效率绝对更高,因为内置的哈希表可以随手调用。但如果你是想扎实地打数据结构基本功,那我非常推荐用C语言过一遍简单题和中等题。
原因很简单:高级语言把哈希表的复杂度封装在标准库里,你调用时只能感受到"快",感受不到"为什么快"。而手写一遍哈希表后,你会彻底理解"key通过哈希函数映射到桶下标""冲突时链地址法如何工作""为什么查找平均 O(1)"这些概念。以后再回头看 C++ 的unordered_map或者 Python 的dict,你会觉得它们就是封装好的同一套东西。
我的个人经验是:用C语言刷题的前一百道会非常痛苦,因为每个基础数据结构都要自己造轮子,光是链表、哈希表、栈、队列这四件套就能写几百行代码。但一旦熬过去这个阶段,后续面对任何算法题时,你对"底层发生了什么"是有画面感的。这种底层认知,是刷多少道调用API的题都换不来的。
结尾
从两数之和这道题延伸出去,我最大的体会是:算法题最值钱的不是答案,而是你在写答案过程中建立起来的底层直觉。我第一次看到哈希表时也是一头雾水——为什么要额外开一块内存?为什么取模能定位到桶?直到自己动手把链地址法一个节点一个节点地串起来,这些疑问才真正消失。如果你也是C语言初学者,被这道题的代码量吓到了,别灰心。这些结构体、哈希函数、内存释放的模板,每多写一次就熟练一分。先把这道题的完整代码在自己机器上跑通,再亲手把哈希表清空函数加上,动手敲一遍比看十遍题解都有用。等你把这道题彻底吃透,再回头看LeetCode top 100里那些考哈希表的中等题,会觉得心里有底很多。