1. 写在前面:为什么我坚持让每个开发者手写一遍双向链表
如果你去翻各大公司的校招题库、社招面试高频题,双向链表几乎每次都不缺席。但说实话,真正能把双向链表讲清楚、写利索的人并不多。很多人能背出“每个节点有prev和next两个指针”,可真到了要处理边界节点、删除当前节点、或者在O(1)时间内完成插入时,就开始犯迷糊。
我最初接触链表是在大学的数据结构课上,当时为了应付考试,把单向链表的头插法、尾插法背得滚瓜烂熟。直到后来做嵌入式开发,面对一块只有几百KB内存的MCU,需要在极有限的资源里管理动态内存块、维护定时器回调队列,才真正意识到链表不是课本上的死知识,而是工程里每天都在用的基础工具。再后来阅读Linux内核源码,看到内核里那套list_head链表设计,直接把我的认知又抬升了一个维度——原来链表还可以这样写,原来把链表结构嵌入到业务结构体里,比让业务结构体去适配链表要优雅得多。
这篇文章我会从最基础的双向链表讲起,逐步拆解Linux内核链表的精妙设计,最后把数组和链表的核心区别一次说透。内容偏实操,每个关键点都会配代码和注释,希望能帮你把这块知识真正打通。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双向链表基础:从结构定义到核心操作一次理清
2.1 为什么是双向而不是单向
先回答一个很多人问过我的问题:既然单向链表已经能实现基本的数据组织,为什么还要引入双向?
答案是“回退”的代价。在单向链表中,如果你想删除一个节点,只知道它的位置是不够的,你还需要知道它的前驱节点,这样才能把前驱的next指针绕过当前节点。而单向链表无法直接拿到前驱,唯一的办法是从头节点开始重新遍历,时间复杂度是O(n)。如果删除操作非常频繁,这个O(n)的开销会把整个程序的性能拖垮。
双向链表用“每个节点多存一个prev指针”的代价,换来了删除、插入操作的O(1)时间复杂度。这在频繁增删的场景里是质变。
更实际地说,在LRU缓存淘汰算法里,双向链表几乎是标配。为什么?因为LRU需要快速把某个节点移动到头(表示最近被访问),如果只有单向链表,这个“移动”操作复杂度很高;有了prev指针,把任意节点摘下来再挂到头部,只需要改几个指针指向,几步就完成了。
2.2 结构体设计与初始化
c复制struct node {
int data; // 数据域,可以扩展为任意业务数据
struct node *prev; // 指向前驱节点
struct node *next; // 指向后继节点
};
这个结构很直观。每个节点既知道自己前面是谁,也知道自己后面是谁。
但在动手写操作之前,有一个关键设计必须想清楚:头节点。
常见的做法是设计一个不存储有效数据的“哑头节点”(dummy head node),也叫“哨兵节点”。它的好处有两个:
- 统一插入和删除的逻辑,不需要对“空链表”“只有一个节点”这些边界情况单独写分支;
- 遍历时不需要单独判断头节点是否为NULL,循环结束条件统一为
p != head。
我见过不少人在初学阶段省掉哨兵节点,结果代码里到处都是if (head == NULL)这样的判断,又丑又容易出bug。工程实践中,哨兵节点基本是共识。
初始化一个带头节点的空双向链表:
c复制struct node head; // 栈上定义,或malloc动态分配
// 初始化:头节点的prev和next都指向自己
head.prev = &head;
head.next = &head;
这里有个细节值得注意:空链表的头节点自环,即head.next == &head且head.prev == &head。这是后续所有操作逻辑统一的基础,千万别把空链表初始化成NULL指针。
2.3 核心操作:插入、删除、遍历
插入操作(在指定节点p之后插入新节点n)
c复制n->prev = p;
n->next = p->next;
p->next->prev = n;
p->next = n;
四行代码,顺序很重要。我当年学的时候犯过一个经典错误:先把p->next改了,再想用p->next->prev取原来的后继,结果拿到的是刚插入的n自己。正确顺序是先操作n的两个指针,再修改旧后继的prev,最后才修改p的next。
注意:如果要在p之前插入,等价于在p->prev之后插入,所以只需要写好“后插”这一个函数,前插复用即可。
删除操作(删除节点n)
c复制n->prev->next = n->next;
n->next->prev = n->prev;
两行就搞定了。是不是比单向链表的删除清爽得多?不管n在哪个位置,哪怕是头节点旁边的节点,都不需要特殊判断,逻辑统一。
遍历
c复制struct node *p;
for (p = head.next; p != &head; p = p->next) {
// 处理p->data
}
注意结束条件是p != &head而不是p != NULL,因为头节点自环,遍历完最后一个真实的节点后,p会回到头节点。
2.4 一个经典问题:插入操作的四行代码顺序能不能换
网上有大量讨论这个话题。我的结论是:不能乱换。
核心原因在于,中间两条语句(p->next->prev = n和p->next = n)一旦顺序颠倒,就会导致p->next已经被覆盖,后续用p->next->prev访问到的就不再是原来的后继节点。这就类似你在记事本里先覆盖了文
