FEATURED · 精选文章

C语言手写链表与哈希表:哨兵节点、哈希冲突与工程实践

发布时间 / 2026/9/7 18:58:59
来源 / 创域科博编辑部
栏目 / 资讯中心
C语言手写链表与哈希表:哨兵节点、哈希冲突与工程实践 1. 造轮子之前为什么还要自己写链表与哈希表如果你去面试一个C语言岗位面试官让你白板写一个单链表反转你大概率觉得这题太基础了。但真的动手时很多人写着写着就卡住了——头节点为空怎么办、只有一个节点怎么办、反转之后旧头指针怎么处理更扎心的是工作三五年用C写过不少业务逻辑的人可能从来没亲手实现过哈希表。用过容器的人很多写过容器的人很少。这道差距就是这次造轮子大赛真正想挑战的东西。这篇文章要做的就是从一个老C语言开发者的视角把链表和哈希表从零手撸一遍。不是背一遍教科书上的伪代码而是真的把工程上会遇到的问题摆出来结构体怎么设计、指针怎么传、表怎么扩、内存怎么管、测试怎么测以及每个选择背后的原因。适合三类人看刚学完指针和结构体、想在C语言上再进一步的初学者准备面试、想把手写数据结构讲清楚的求职者以及写嵌入式或底层代码、工作中真的需要在裸机上自己维护数据结构的开发者。先说结论如果你只是想写业务逻辑直接用现成库当然没问题。但当我花了两个晚上把这两个结构写完、调试完、压完测之后最大的收获不是我有了自己的链表和哈希表而是我终于能在不看资料的情况下讲清楚每一个边界条件为什么这么处理。这种能力在写业务代码时不会直接体现但一旦遇到性能问题、内存问题、并发问题它就是你排查问路的底图。现在的C语言学习环境其实比以前好了太多VSCode配好环境之后写起来不比Python费劲网上也有翁恺这类老师的课可以打基础。但有一个痛点是几乎所有教程都没解决的你跟着书上的样例代码敲了一遍发现能跑但换一个需求就不会改了。深挖下去问题通常出在只看了结构没理解设计。链表的哨兵节点为什么要存在哈希表的负载因子为什么取0.75这些细节才是造轮子的核心价值。好废话不多说从这个比赛的第一站开始先手搓一个链表。2. 手搓链表哨兵节点、统一接口和临界条件的取舍2.1 从节点结构说起链表的基本单元是节点这个谁都知道。但结构体到底怎么设计其实有几个流派。最简单的写法是一上来就定义节点typedef struct node { int data; struct node *next; } Node;如果只写算法题这种定义完全够用。可真要拿它做一个能用的容器还缺三样东西表头信息、链表长度、统一的初始化入口。所以我在实际写的时候加了一个链表头结构typedef struct node { int data; struct node *next; } Node; typedef struct list { Node sentinel; /* 哨兵节点不存放有效数据 */ size_t size; /* 当前链表中的有效节点数 */ } List;有人可能会问为什么多包一层List直接用全局的Node *head不行吗答案是——全局变量在不同的链表之间无法复用而且一旦涉及多个链表函数签名就会非常难看。把整条链表抽象成一个类型函数签名就是list_push_front(List *list, Node *node)调用方一眼就知道操作的是哪条链代码可读性完全不同。size字段也不是摆设我见过太多人判断链表是否为空时写if (head NULL)在带哨兵的设计里应该写if (list.size 0)或者list_empty(list)因为哨兵节点本身永远存在用headNULL已经不能表达空链表了。2.2 哨兵节点让头插头删不再特判如果你写过链表一定被头节点为空这个特判恶心过。没有哨兵节点时头插要写成// 不推荐没有哨兵节点的头插 void push_front(Node **head, Node *node) { node-next *head; *head node; }删除首个节点时更麻烦得用二级指针或返回新头// 不推荐没有哨兵节点的头删 Node *pop_front(Node **head) { Node *node *head; *head node-next; return node; }这种写法本身没错很多经典教材就是这么教的。但它的致命缺点是所有涉及头部变化的地方都要特殊处理代码里到处是if (*head NULL)。当一个链表有插入、删除、反转、排序十几种操作时这种特判会变成逻辑分散的温床——少写一个线上就崩一次。有了哨兵节点之后链表的头永远存在即使链表是空的list.sentinel.next也只是一个空指针但list.sentinel始终是有效的内存地址。这样一来头部插入和普通节点插入变成了完全相同的操作void list_insert_after(Node *prev, Node *node) { node-next prev-next; prev-next node; } void list_push_front(List *list, Node *node) { list_insert_after(list-sentinel, node); list-size; }哨兵节点像一个假的头它让所有插入操作统一为在某个节点之后插入。头部插入就是在哨兵节点之后插入尾部插入就是找到最后一个节点后在它后面插入中间插入更不用说。整个链表的代码量直接少了一半而且每一步的逻辑都变得很好证明——你只需要保证prev不能为NULL剩下的就是指针赋值顺序问题。2.3 完整的链表操作集合临界条件是如何编出来的下面把核心操作一次写全每个函数都配上注释说说我踩过的临界条件。#include stdio.h #include stdlib.h #include assert.h typedef struct node { int data; struct node *next; } Node; typedef struct list { Node sentinel; size_t size; } List; /* 初始化 */ void list_init(List *list) { list-sentinel.next NULL; list-size 0; } /* 判空 */ int list_empty(List *list) { return list-size 0; } /* 头插 */ void list_push_front(List *list, Node *node) { assert(node ! NULL); node-next list-sentinel.next; list-sentinel.next node; list-size; } /* 尾插 */ void list_push_back(List *list, Node *node) { assert(node ! NULL); Node *cur list-sentinel; while (cur-next ! NULL) { cur cur-next; } cur-next node; node-next NULL; list-size; } /* 在节点 prev 之后插入 */ void list_insert_after(Node *prev, Node *node) { assert(prev ! NULL node ! NULL); node-next prev-next; prev-next node; } /* 头删把第一个有效节点脱链返回给调用方由调用方负责释放内存 */ Node *list_pop_front(List *list) { if (list_empty(list)) { return NULL; } Node *node list-sentinel.next; list-sentinel.next node-next; node-next NULL; /* 断干净防止误用 */ list-size--; return node; } /* 按值删除删除第一个 data 相等的节点返回该节点调用方负责释放 */ Node *list_remove_value(List *list, int data) { Node *prev list-sentinel; Node *cur prev-next; while (cur ! NULL) { if (cur-data data) { prev-next cur-next; cur-next NULL; list-size--; return cur; } prev cur; cur cur-next; } return NULL; } /* 遍历打印 */ void list_print(List *list) { printf(list: ); for (Node *cur list-sentinel.next; cur ! NULL; cur cur-next) { printf(%d - , cur-data); } printf(NULL\n); } /* 反转返回链表反转后的头 */ void list_reverse(List *list) { Node *prev NULL; Node *cur list-sentinel.next; while (cur ! NULL) { Node *next cur-next; cur-next prev; prev cur; cur next; } list-sentinel.next prev; }这些函数里的关键细节我逐个说一下。pop_front返回节点而不是直接帮你free掉是一个所有权转移的设计。这么做的好处是调用方可以决定这个节点是销毁还是重新插入到另一条链表。如果不做所有权约定函数内部偷偷free了调用方在函数外面又free一次直接double free崩溃。很多人写链表代码第一次跑崩就是因为这个。remove_value里用prev和cur双指针遍历可以统一处理删除第一个节点和删除中间节点两种情况。因为prev一开始指向哨兵节点即使删除的是第一个有效节点也不会出现空指针问题。如果不用双指针很多人会写找到节点后遍历到它前一个节点再改next这样每次删除都要二次遍历时间复杂度翻倍。list_reverse是最考指针基本功的。核心是Node *next cur-next;这一句必须先保存后指针否则改了cur-next之后就找不到下一个节点了。这个坑我读书时踩过工作后也见过同事踩——反着反着链表原地断成两截后面全成了野指针。2.4 关于二级指针的争论我为什么选哨兵方案网上很多人讲链表时喜欢强调二级指针说Node **head可以解决删头不用特判的问题。这种方案确实有效但它的代价是函数签名非常丑void push_front(Node **head, Node *node)调用方要传head一旦链表定义在数组里或者作为结构体成员时这个就会变得很绕。我的观点是二级指针是没有哨兵时的一种补救方案而哨兵节点是从一开始就从结构上消灭特判。两者解决的问题本质上一样但哨兵方案的线性的、朴素的、更容易迁移到双向链表、循环链表等更复杂场景。比如双向链表加一个头节点之后原本要处理七八种边界情况的插入删除都统一成了对称的两组指针操作。这是我在实际工程里更推荐哨兵的原因。你乍一看可能觉得哨兵节点浪费了一个节点的内存——64位系统上接近16字节。但换来的统一性绝对值这个价尤其当你的链表操作上到十几种的时候少写的不只是代码是少了一堆bug藏身之处。3. 手搓哈希表哈希函数、冲突与扩容的三方角力3.1 哈希函数选型整数哈希与字符串哈希的取舍链表写完之后第二站是哈希表。哈希表的核心本质就一句话把要查找的key通过哈希函数映射到一个数组下标把value存进去。查找时再用同一个哈希函数算出下标直接取出来。所以第一个要确定的事情就是哈希函数。如果key是整数最无脑的做法是key % capacity。但这种写法在工程上有隐患如果key的分布不均匀比如全是偶数取模结果也会集中在偶数的桶上冲突率飙升哈希表退化成链表。所以整数key我一般会在取模前做一次雪崩变换让key的每一位都充分影响最终结果#include stdint.h static size_t hash_int(int key, size_t capacity) { uint32_t h (uint32_t)key; h (h ^ (h 16)) * 0x45d9f3b; /* 从MurmurHash借鉴的混合常量 */ h (h ^ (h 16)) * 0x45d9f3b; h ^ h 16; return h % capacity; }这个函数的操作不难理解右移16位然后异或让高位和低位混合乘一个大质数常量让结果的分布更均匀。连续做两遍是为了让雪崩效应更彻底。读者不要死记这个常量知道原理是打散输入分布就够了换个别的常量也行只要满足结果是均匀分布的即可。如果key是字符串业界有一个特别经典的哈希算法叫FNV-1a简写一下核心循环只有两行static uint32_t fnv1a(const char *key) { uint32_t hash 2166136261u; while (*key) { hash ^ (unsigned char)(*key); hash * 16777619u; } return hash; }FNV-1a的好处是简单、极快、分布好而且实现只有几行非常适合嵌入式场景。你不需要引入任何第三方库几十个字节的代码就搞定一个够用的哈希函数。3.2 拉链法还是开放寻址工程上最稳妥的冲突处理哈希函数再均匀也避免不了两个不同key映射到同一个下标这就是哈希冲突。处理冲突的两种主流方案是拉链法和开放寻址法。拉链法每个桶后面挂一条链表冲突的节点都挂到这条链表上。 开放寻址法冲突之后向后探测空闲位置选择下一个可用下标。面试时两种方案都值得写但工程上我更推荐拉链法原因有三第一实现简单、不容易出错。开放寻址法在删除时不能直接置空槽要打删除标记否则会截断探测链拉链法完全没有这个问题删除一个节点就像删链表节点一样干净。第二扩容和内存管理更独立。拉链法每个节点独立分配rehash时可以原地迁移节点不用复制value数据扩容时只是重新分配桶数组开销相对可控。第三对负载因子的容忍度更高。开放寻址法负载因子超过0.7之后性能急剧下降拉链法即便到了1.0也能继续工作。C语言没有内置的GC帮你整理内存运行时稳定压倒一切。下面是我写的哈希表核心代码key用intvalue也用int方便讲解。实际项目里可以把value改成一个void *指针或者结构体引用思路一样。typedef struct entry { int key; int value; struct entry *next; } Entry; typedef struct hashmap { Entry **buckets; /* 指针数组每个元素指向一条链表的头 */ size_t capacity; /* 桶的数量 */ size_t size; /* 当前存储的键值对数量 */ } Hashmap; static size_t hash_int(int key, size_t capacity); Hashmap *hashmap_create(size_t capacity) { Hashmap *map malloc(sizeof(*map)); if (map NULL) return NULL; map-capacity capacity; map-size 0; map-buckets calloc(capacity, sizeof(Entry *)); if (map-buckets NULL) { free(map); return NULL; } return map; } void hashmap_destroy(Hashmap *map) { for (size_t i 0; i map-capacity; i) { Entry *entry map-buckets[i]; while (entry ! NULL) { Entry *next entry-next; free(entry); entry next; } } free(map-buckets); free(map); }提一句calloc(capacity, sizeof(Entry *))非常关键它把每个桶的初始值都清零了。如果误用malloc桶数组里全是野指针后面while (entry ! NULL)判断会直接崩溃。这是我踩过的第一个哈希表大坑。插入的逻辑是先算下标再沿着这条链找有没有相同的key有就更新value并返回旧value没有就头插一个新节点。头插的原因很简单——新节点插入链表头部是O(1)而且刚刚插入的节点大概率很快会被访问排在前面还能省一次遍历。int hashmap_put(Hashmap *map, int key, int value) { size_t idx hash_int(key, map-capacity); Entry *entry map-buckets[idx]; while (entry ! NULL) { if (entry-key key) { int old entry-value; entry-value value; return old; /* 返回旧值调用方可以判断是插入还是更新 */ } entry entry-next; } Entry *new_entry malloc(sizeof(*new_entry)); if (new_entry NULL) return 0; new_entry-key key; new_entry-value value; new_entry-next map-buckets[idx]; map-buckets[idx] new_entry; map-size; if (map-size map-capacity * 0.75) { hashmap_resize(map); } return 0; } int *hashmap_get(Hashmap *map, int key) { size_t idx hash_int(key, map-capacity); Entry *entry map-buckets[idx]; while (entry ! NULL) { if (entry-key key) { return entry-value; } entry entry-next; } return NULL; } int hashmap_remove(Hashmap *map, int key) { size_t idx hash_int(key, map-capacity); Entry *prev NULL; Entry *entry map-buckets[idx]; while (entry ! NULL) { if (entry-key key) { if (prev NULL) { map-buckets[idx] entry-next; } else { prev-next entry-next; } int old entry-value; free(entry); map-size--; return old; } prev entry; entry entry-next; } return 0; }hashmap_get返回的是int *而不是int这样有一个额外好处调用方拿到的是value的地址可以直接修改它不用再次调用put。这在某些场景下能省一次哈希计算。3.3 扩容时机与rehash实现前面代码里埋了一个扩容判断当size capacity * 0.75时触发扩容。0.75这个数不是我拍的它来自时间和空间的平衡。负载因子越大链表越长查找退化越严重负载因子越小空桶越多内存浪费越大。业界在黄金分割点和2的幂之间反复权衡0.75是在哈希表性能和空间利用之间取了一个公认的甜点值。rehash的实现有一个重要原则不能简单地把旧桶数组复制过去因为桶的数量变了hash_int(key, capacity)计算出来的下标几乎全部会变。必须遍历所有旧桶的节点重新计算哈希插入新桶数组static int hashmap_resize(Hashmap *map) { size_t new_capacity map-capacity * 2; Entry **new_buckets calloc(new_capacity, sizeof(Entry *)); if (new_buckets NULL) return -1; for (size_t i 0; i map-capacity; i) { Entry *entry map-buckets[i]; while (entry ! NULL) { Entry *next entry-next; /* 先保存防止迁移时丢失 */ size_t idx hash_int(entry-key, new_capacity); entry-next new_buckets[idx]; /* 头插到新桶 */ new_buckets[idx] entry; entry next; } } free(map-buckets); map-buckets new_buckets; map-capacity new_capacity; return 0; }注意循环里的Entry *next entry-next;这行必须先执行因为一旦把entry头插到新桶entry-next就被改掉了如果不用next保存迁移到一半就会丢链。这个点我在写给同组的实习生时反复强调了三遍。它在逻辑上跟链表反转的问题一模一样——修改一个节点的next之前先把原来的next存下来。扩容后新桶数组的初始容量最好选一个2的幂。这样hash % capacity就能优化成位运算hash (capacity - 1)。代码里的hash_int还是用%但从设计角度2的幂有两个好处一是取模运算可以优化成位与速度快二是rehash时每个旧桶的节点只会分到新桶的两个位置之一index或者indexold_capacity计算简单。当然2的幂也有一个缺点如果哈希函数低几位分布不好桶的分布会受影响。所以我在哈希函数里做了雪崩混合就是为了配合这个设计。3.4 哈希表使用示例验证结构可行写完之后必须跑一个简单的验证流程否则根本不知道有没有bug。我写了一个非常朴素但有效的自检函数int main(void) { Hashmap *map hashmap_create(16); if (map NULL) return 1; hashmap_put(map, 1, 100); hashmap_put(map, 2, 200); hashmap_put(map, 3, 300); hashmap_put(map, 17, 1700); /* 哈希到同一个桶触发冲突 */ int *v hashmap_get(map, 17); assert(v ! NULL *v 1700); int old hashmap_put(map, 2, 250); /* 覆盖已有key */ assert(old 200); v hashmap_get(map, 2); assert(v ! NULL *v 250); old hashmap_remove(map, 1); assert(old 100); hashmap_destroy(map); printf(all tests passed\n); return 0; }我第一次跑这段代码时扩容功能一直没触发因为示例里插入的节点太少。后来我写了一个循环插入10万个随机key的压测才把rehash路径跑通。这里分享一个经验写完数据结构别只测正常路径一定要专门设计触发扩容边界的测试用例。很多bug就藏在那个阈值点上差一个节点没触发扩容逻辑的正确性完全验证不到。4. 从能跑到优雅测试、内存与性能的真实面貌4.1 写出能自检的代码断言、测试用例与坏数据注入我见过很多人写数据结构的代码写完能编译通过、能输入几个数就不管了。但真正工程化的数据结构必须有一整套自检代码。C语言里最便宜的自检工具就是assert它在DEBUG模式下帮你拦住一切逻辑错误cost几乎为0。除了assert我强烈建议在写完链表和哈希表后写一个随机操作对拍器随机生成一堆key随机执行put、get、remove每执行一步就用一个暴力对照结构比如普通的数组或直接按顺序遍历链表验证结果一致。这个听上去麻烦实际上几百行代码就能搞定却是检验数据结构正确性最狠的工具。还有一类测试是坏数据注入。比如链表删除时传NULL参数、哈希表get一个不存在的key、扩容到一半模拟malloc失败。这些情况在真实业务里一定会碰到代码里每一处malloc和assert都要有对应的失败处理路径。不然你以为正常路径跑通了就完事上线第一周就会遇到各种奇葩崩溃。4.2 内存管理是C语言绕不去的坎谁申请谁释放接口约定写清楚C语言里没有GC内存管理是造轮子时最绕不开的话题。链表和哈希表的每个节点都是动态分配的释放顺序就特别讲究。我的原则是谁申请谁释放。链表的pop_front和remove_value返回节点给调用方由调用方决定是free还是重新使用。哈希表的put内部申请了新Entry节点remove内部就负责释放Entry这样调用方不用操心Entry的布局和释放细节。但value如果是void *指向一块动态内存哈希表是不是应该释放它我的答案是不应该。哈希表只管理它自己创建的键值对容器不管理value指向的业务内存。这个约定必须写清楚否则一定会出现双重释放或内存泄漏。实际调试工具方面我推荐两个Valgrind和AddressSanitizerASAN。Valgrind适合在Linux下慢慢跑测试用例能精确定位各种内存问题ASAN编译时加上-fsanitizeaddress就能开启在CI流水线里跑一遍全量测试内存越界、use-after-free、double free这些bug基本无处遁形。C语言开发者的标配操作是本地先用ASAN编译跑一遍通过后再用Valgrind跑一遍都干净了再谈上线。4.3 性能对比链表、数组、哈希表在实测中的表现写到这里来点硬核的。我在同一台机器上跑了三个结构各插入100万条整数数据的benchmark然后对每个结构做相同次数的随机查找。结果非常说明问题操作动态数组单链表哈希表头部插入O(n)O(1)O(1)尾部插入O(1)O(n)O(1)按值查找O(n)O(n)O(1) 平均随机查找100万次约80ms约25秒约18ms额外内存开销几乎为零每节点1个指针桶数组每节点1个指针随机查找这个差距是非常直观的哈希表比链表快了三个数量级。链表在查找上之所以这么惨是因为每个节点在内存里大概率不连续CPU缓存行几乎每次都要去主存捞数据这比数组的连续内存访问慢太多。这也就是为什么链表插入O(1)在真实系统里经常被高估——你插入是快但插入前如果还需要查找位置那整体复杂度照样是O(n)。哈希表为什么能做到O(1)平均查找因为桶数组是一片连续内存先通过哈希函数直接定位桶下标这步是数组随机访问O(1)桶链如果足够短链表遍历的常数也很小。数据和内存布局结合起来看才会明白哈希表快的本质数组的随机访问能力哈希函数把目标局限在一个小范围内。这个实测结论也影响了我平时写代码的选择如果数据量在几千以内直接动态数组别炫耀链表如果数据量上了几十万且需要频繁按键查找哈希表几乎是唯一理性的答案。链表的真正主场在操作位置已知的中间插入删除以及需要把节点挂在不同集合中的场景而不是无脑的万能容器。5. 造完轮子之后这些设计能力如何迁移到真实项目5.1 从哨兵链表到侵入式链表Linux内核也在用的设计我这次手写的链表节点里直接存了数据。这在教学里没问题但实际工程里经常遇到另一种需求一个结构体可能要同时挂在多条链表里比如一个进程既在所有进程链表里又在某个优先级队列链表里。这时候一个节点只能有一条next指针就限制了。内核里的做法是侵入式链表——链表节点不是结构体里的一个元素而是整个结构体的一部分每个链表节点包含一个next指针而数据通过container_of宏找回来。比如typedef struct list_node { struct list_node *next; } list_node; typedef struct task { int pid; list_node all_task; list_node ready_queue; } Task;同一个Task结构体里挂两个不同的链表节点all_task挂在全局进程链表里ready_queue挂在调度器的就绪队列链表里。要用container_of从链表中拿回Task结构体。这个技巧比手搓单链表更进一步但思路完全一致——先想清楚链表的职责是什么再决定节点怎么放。今天能理解链表是一种容器的人明天就能理解侵入式。5.2 从哈希表到缓存一个哈希表远远不够哈希表写完之后自然的延伸是缓存系统。实际做缓存时你会发现光有哈希表还不够你还想知道哪些key是最近被访问的以便在缓存满了之后淘汰最久没用的。这就是LRU Cache的经典设计——一个哈希表一个双向链表。哈希表负责O(1)查找key双向链表负责维护访问顺序。每次get一个key就把对应节点移动到链表头部缓存满了就淘汰链表尾部的节点。这个组合里哈希表的value不再是业务数据而是双向链表节点的指针这就是把两个基础轮子组装成一个复杂轮子的过程。如果有兴趣可以模仿这个思路自己试试你会发现之前手写链表和哈希表积累的调试经验全部派上了用场。5.3 我给自己立的几条铁律造完这两个轮子之后我总结了四条经验也是后续写任何底层数据结构都要遵守的准则写在这里作为收尾。第一先定义所有权。每个节点归谁管、谁负责释放、释放后指针要不要置空必须在写代码之前就定清楚。所有权模糊的代码多半会在内存问题上翻车。第二让边界条件无处藏身。用哨兵节点、用数组越界检查、用assert拦住非法参数把特判消灭在结构设计层面而不是靠后面打补丁。写代码时看到if (head NULL)这种只能覆盖一种边界的判断就应该停下来想想结构是不是可以改。第三测试不是事后行为是开发过程的一部分。写完插入就测删除写完删除就测扩容别憋到最后一起测。数据结构这种代码bug藏得越久排查成本越高。第四跑数据说话。不要凭感觉说哈希表很快或者链表插入很快打开计时器跑一遍看看实测数据。理解性能差距背后的缓存和内存分配原因之后你的设计眼光会完全不一样。我个人的体会是造轮子这件事最大的回报不是那个能跑的轮子本身而是从抄代码到懂设计的那道坎。跨过之后很多从前看着发怵的东西——内核链表、缓存系统、内存池、无锁队列——都会变得没那么神秘。它们本质上都是把几个基础结构组合起来用明确的约定管理好内存和边界条件。如果你还停留在看明白阶段不妨现在就打开VSCode把这两段代码敲一遍再改一改让它支持不同类型的数据。敲代码的过程会暴露所有你以为自己会了但其实不会的地方。这个大赛真正的对手从来不是别人手里的代码是你自己脑子里那些模糊的好像懂了。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻