数组和链表之争,几乎是每个写C语言的人早晚都要面对的一道坎。我见过不少刚入行的同学,能把“数组连续、链表离散”背得滚瓜烂熟,但真到写代码的时候,要么把链表用成了数组,要么被Linux内核里的list_head搞得一头雾水。这篇东西,我想把这些年折腾双向链表和内核链表的经验一次性讲透,不只是给结论,更要把背后的设计逻辑和踩过的坑都交代清楚。
文章适合三类人看:正在准备面试的开发者(Linux内核链表是高频考点)、做嵌入式或驱动开发的朋友(内核链表是基本功)、以及那些觉得“链表懂了但又没完全懂”的C语言爱好者。看完你至少能收获三样东西:解决“数组还是链表”的判断框架、能默写出来的双向链表操作、以及一套可以直接抄走用的内核风格通用链表实现。
1. 从本质说起:数组和双向链表的分岔路
1.1 数组的底层逻辑:连续内存带来的快与痛
数组最核心的特征就一句话:元素在内存里是紧挨着排的。这个特征决定了它的一切优点和缺点。
优点大家都很熟:随机访问是O(1),因为a[i]的地址就是a + i * sizeof(T),一条乘法指令直接算出来,不需要任何遍历。CPU缓存也友好得很,顺序遍历的时候,硬件预取器能把后面几十个元素一次性捞进缓存,实际跑起来比理论上的O(n)还要快上一大截。
缺点也很致命:插入和删除要搬动后续所有元素。往数组中间塞一个数,平均要移动n/2个元素;扩容的时候更麻烦,realloc一旦涉及到新地址,整个数组都要拷贝一遍。如果你在维护一个频繁增删的队列,用数组做就是灾难现场。
还有一个隐藏很深的坑:数组名会退化成指针。这个特性让无数C语言新手栽过跟头——以为sizeof(arr)能拿到数组大小,结果在函数参数里拿到的是8(一个指针的大小)。多维数组更是如此,int a[3][4]作为参数传递时,第二维必须显式给出,否则编译器根本没法计算行的偏移量。
1.2 双向链表的出现:牺牲随机访问换灵活性
如果数组的问题是“挪动成本太高”,那链表的思路就是“尽量不挪”。每个节点不要求内存连续,各自独立申请,节点之间用指针串起来。单向链表只有next指针,找前驱得从头遍历;双向链表加了一个prev指针,前后都能走,删除节点的时候不用遍历找前驱,这是它对单向链表最大的优势。
代价当然也有。每个节点多存一个指针,在64位系统上是8字节的额外开销;访问第n个节点必须从头一步步走,O(n)的随机访问和数组的O(1)没法比;节点分散在堆的不同地址,遍历时CPU缓存命中率低,Cache Miss一多,实际性能可能比数组差一个数量级。
1.3 一张表看明白两者的取舍
| 维度 | 数组 | 双向链表 |
|---|---|---|
| 内存布局 | 连续 | 离散 |
| 随机访问 | O(1) | O(n) |
| 插入/删除(已知位置) | O(n),需搬移 | O(1),改指针 |
| 缓存友好度 | 高 | 低 |
| 额外内存开销 | 几乎无 | 每节点至少一个指针 |
| 扩容 | 需要拷贝或realloc | 动态分配,无整体搬迁 |
| 适用场景 | 频繁查询、数据量稳定 | 频繁增删、数据量不确定 |
这个表不是我随便画的,是实际性能测试得出来的结论。我见过一个真实的优化案例:某个消息队列模块用数组实现,巅峰时每秒要处理上万条消息,每次入队都要memmove好几百个字节,CPU占用高得离谱。后来改成双向链表,入队出队只改几个指针,CPU占用直接降了40%。但也见过反例:一个频繁遍历查找的场景,链表改数组之后速度提升了近10倍,纯粹是因为缓存命中率的差距。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双向链表的经典实现:那些必须刻在脑子里的指针操作
2.1 节点结构定义
先摆最基础的结构体:
c复制struct node {
int data;
struct node *prev;
struct node *next;
};
很多教材还会定义一个头指针(指向第一个节点)或者头节点(一个不存数据的哨兵节点)。这两种写法我都用过,推荐带头节点的版本:头节点的prev指向最后一个节点,next指向第一个节点,这样就构成了循环链表。好处是首尾插入删除的操作逻辑完全统一,不需要为“第一个节点特殊处理”写分支。
2.2 插入操作:别搞反指针的顺序
插入一个节点到pos节点之后,核心代码是这样的:
c复制static inline void insert_after(struct node *pos, struct node *new_node) {
new_node->next = pos->next;
new_node->prev = pos;
pos->next->prev = new_node;
pos->next = new_node;
}
顺序为什么重要?我之前带过一个实习生,他写的代码先把pos->next改了,然后再用pos->next->prev = new_node,这时候pos->next已经变成新节点了,结果就是新节点的prev被赋成了它自己,链表直接断掉。
记住一个原则:先更新new_node自身的两个指针,再去动前后两个节点的指针。只要new_node的指针还没接好,中间哪一步出错了链表还能练,一旦先改别人的再把新节点晾在半空,就彻底乱了。
在头部插入就是insert_after(head, new_node),尾部插入就是insert_after(head->prev, new_node),循环链表的好处在这体现得淋漓尽致——不需要为头尾单独写函数。
2.3 删除操作:双向链表的优势时刻
删除一个已知节点:
c复制static inline void delete_node(struct node *del) {
del->prev->next = del->next;
del->next->prev = del->prev;
free(del);
}
注意两个指针的更新顺序在这里无所谓,因为del自身被删掉后不再参与连接。但有一点坑:函数结束后del这个指针本身还在,而且del->next、del->prev里保存的还是原来的地址,形成了所谓的“悬空指针”。如果后续不小心用了这个指针,轻则逻辑错误,重则崩溃。稳妥的做法是在free之后把调用方的指针置NULL,或者干脆在删除节点时把del->prev和del->next都赋为NULL,这样一旦误用会更容易暴露问题。
2.4 遍历的边界条件
遍历循环链表的标准写法:
c复制for (struct node *p = head->ne
