在数据结构体系中,顺序表与链表是两大最基础的线性存储结构 。顺序表依靠连续内存 实现随机访问,但插入、删除中间元素效率低下;而链表用离散内存 + 指针连接的方式,完美解决了顺序表的痛点,是 Linux 内核、操作系统、网络编程中高频使用的数据结构。
本文从零拆解链表的底层原理、分类、完整实现、核心优缺点、与顺序表的对比,同时结合 Linux 内核使用场景,彻底吃透面试高频考点。
一、先搞懂:什么是链表?
1.1 链表的核心定义
链表是非连续、非顺序的线性表,它不要求内存连续,通过 ** 指针(引用)** 将分散在堆内存中的一个个节点串联起来。
- 每个节点包含两部分:数据域 (存储业务数据)+ 指针域(存储下一个节点的内存地址)
- 节点分散在内存各处,不连续排列,靠指针维系前后关系
- 没有下标,不支持随机访问,只能从头节点开始逐个遍历
你可以把链表想象成一串带钩子的珠子:每个珠子存数据,同时带一个钩子勾住下一颗珠子;珠子不用挨在一起,靠钩子串联;想要找到第 N 颗珠子,必须从第一颗顺着钩子依次找。
1.2 链表的四大分类
日常开发 & 面试中,链表分为 4 种,层层递进:
- 单链表:最基础,只有后继指针,只能从头往后遍历
- 双向链表:有前驱 + 后继两个指针,可双向遍历,Linux 内核高频使用
- 循环链表:尾节点指针指向头节点,首尾相连
- 双向循环链表:双向 + 循环,功能最全,内核 list_head 底层就是它
1.3 链表与顺序表的核心对立点
顺序表:连续内存、随机访问快、中间增删慢、缓存友好链表:离散内存、随机访问慢、任意位置增删快、缓存不友好
二、单链表底层原理与完整实现
单链表是链表的基础,我们用 C++ 手写一个极简单链表,彻底搞懂底层逻辑。
2.1 节点结构定义
cpp
// 链表节点
template <typename T>
struct ListNode {
T val; // 数据域:存储数据
ListNode* next; // 指针域:存下一个节点地址
ListNode(T v) : val(v), next(nullptr) {}
};
val:存放业务数据next:指向下一个节点,末尾节点 next 为nullptr
2.2 单链表核心操作
1. 头插(O (1))
在头部插入节点,只需要修改指针,是链表效率最高的插入方式。
cpp
void push_front(T val) {
ListNode<T>* new_node = new ListNode<T>(val);
new_node->next = head; // 新节点指向原头节点
head = new_node; // 头指针更新为新节点
}
2. 尾插(O (n))
需要遍历到链表尾部,才能插入节点。
cpp
void push_back(T val) {
ListNode<T>* new_node = new ListNode<T>(val);
if(head == nullptr) { head = new_node; return; }
// 遍历到尾节点
ListNode<T>* cur = head;
while(cur->next != nullptr) cur = cur->next;
cur->next = new_node;
}
3. 指定位置插入(O (n))
找到前驱节点,修改指针指向,不需要移动任何元素。
cpp
void insert(int pos, T val) {
// 找到pos前一个节点
ListNode<T>* cur = head;
for(int i=0; i<pos-1; i++) cur = cur->next;
ListNode<T>* new_node = new ListNode<T>(val);
new_node->next = cur->next;
cur->next = new_node;
}
4. 删除节点(O (n))
找到前驱节点,跳过目标节点,释放内存即可,无需移动元素。
cpp
void erase(int pos) {
ListNode<T>* cur = head;
for(int i=0; i<pos-1; i++) cur = cur->next;
ListNode<T>* del = cur->next;
cur->next = cur->next->next;
delete del; // 释放堆内存,避免内存泄漏
}
5. 遍历查找(O (n))
链表没有下标,必须从头节点逐个往后遍历,无法直接跳转。
2.3 单链表致命缺点
- 只能单向遍历,无法反向查找
- 尾插 / 尾删效率极低,需要遍历整个链表
- 删除当前节点,必须找到它的前驱节点,操作繁琐
三、双向链表(Linux 内核最爱)
3.1 底层原理
双向链表在单链表基础上,给每个节点增加前驱指针 prev:
cpp
template <typename T>
struct DListNode {
T val;
DListNode* prev; // 前驱指针:指向前一个节点
DListNode* next; // 后继指针:指向后一个节点
};
- 每个节点可找到前驱、后继,支持双向遍历
- 尾插、尾删、删除当前节点,效率直接提升到 O (1)
- Linux 内核、epoll 就绪队列、进程调度队列,全部使用双向链表
3.2 核心优势
- 可正向、反向双向遍历
- 已知当前节点,可直接删除,无需遍历找前驱
- 头插、尾插、头删、尾删全部 O (1)
3.3 缺点
每个节点多存一个指针,内存开销比单链表大
四、循环链表 & 双向循环链表
4.1 循环链表
尾节点的next指针不再置空,而是指向头节点,首尾相连。
- 优势:从任意节点都可遍历整个链表,适合环形场景(环形队列)
- 缺点:遍历结束条件从
next==nullptr变为next==head,容易死循环
4.2 双向循环链表(Linux 内核 list_head)
双向 + 循环,功能最全、使用最广。
- 内核中实现:不存储数据,只存两个指针,嵌入任意结构体中,通用型极强
- epoll 的就绪队列、进程 task_struct 调度队列、定时器队列,全部基于此实现
五、链表核心优缺点(面试必背)
优点
- 增删效率极高 :任意位置插入 / 删除节点,只需要修改指针,无需移动大量元素,O (1) 时间复杂度(已知节点)
- 内存利用率高:按需分配节点,没有顺序表的预留冗余内存,无内存浪费
- 内存离散存储:不需要连续的大块内存,碎片化内存也能使用,适配海量动态连接场景
缺点
- 不支持随机访问:只能顺序遍历,查找元素 O (n),速度远慢于顺序表
- 缓存命中率低:节点内存离散,CPU 缓存无法预加载,顺序遍历也比顺序表慢
- 额外内存开销:每个节点需要存储指针,内存占用比顺序表高
- 容易内存泄漏:节点手动 new 创建,忘记 delete 会造成内存泄漏
六、顺序表 vs 链表(终极对比,面试必背)
表格
| 特性 | 顺序表 | 链表 |
|---|---|---|
| 内存分布 | 连续内存 | 离散内存 |
| 随机访问 | 支持,O (1) | 不支持,O (n) |
| 头部 / 中间增删 | O (n),需移动元素 | O (1),仅修改指针 |
| 尾部增删 | O (1)(均摊) | 双向链表 O (1),单链表 O (n) |
| CPU 缓存 | 缓存友好,速度快 | 缓存不友好,速度慢 |
| 内存开销 | 低,无指针冗余 | 高,每个节点带指针 |
| 适用场景 | 查找多、增删少 | 增删多、查找少 |
场景选择口诀
- 频繁查询、极少增删 → 选顺序表(vector)
- 频繁插入删除、动态扩容 → 选链表
- Linux 内核、高并发队列 → 选双向循环链表
七、链表在 Linux 网络编程中的实际应用
链表不是纸上谈兵,在你学习的 IO 复用、高并发服务器中,无处不在:
- epoll 就绪队列 :epoll 内核中用双向链表存储就绪的 fd 事件,epoll_wait 直接取链表头部,O (1) 获取就绪事件
- 进程 / 线程管理:Linux 内核用双向链表串联所有进程、线程,调度时快速增删节点
- 定时器管理:网络服务的定时器(心跳、超时检测),用链表管理超时事件
- 日志系统:日志缓冲区、异步队列,常用链表实现动态节点增删
八、高频面试总结
- 单链表:单向遍历,头插 O (1),尾插 O (n),实现简单,功能有限
- 双向链表:双向遍历,头尾操作 O (1),Linux 内核主流使用
- 循环链表:首尾相连,适合环形结构
- 双向循环链表:功能最全,epoll、内核调度核心数据结构
- 核心区别:顺序表胜在查询,链表胜在增删;顺序表缓存友好,链表动态灵活