

目录
[4.1 核心定义](#4.1 核心定义)
[4.2 代码实现](#4.2 代码实现)
[4.2.1 单个节点定义](#4.2.1 单个节点定义)
[4.2.2 获取一个节点](#4.2.2 获取一个节点)
[4.2.3 头插与尾插](#4.2.3 头插与尾插)
[4.2.4 头删与尾删](#4.2.4 头删与尾删)
[4.2.5 指定位置插入删除](#4.2.5 指定位置插入删除)
[5.1 核心定义](#5.1 核心定义)
[5.2 代码实现](#5.2 代码实现)
[5.2.1 单个节点的实现](#5.2.1 单个节点的实现)
[5.2.2 初始化与销毁](#5.2.2 初始化与销毁)
[5.2.3 头插与尾插](#5.2.3 头插与尾插)
[5.2.4 头删与尾删](#5.2.4 头删与尾删)
[5.2.5 指定位置插入删除](#5.2.5 指定位置插入删除)
[5.2.6 查找/打印/判空](#5.2.6 查找/打印/判空)

一、前言
前面我们学习了用数组实现顺序表的相关步骤与要点,因为顺序表本质上就是一块连续的内存空间所以在物理上是连续的,在逻辑上也是连续的(因为我们可以通过一个数据找到另一个数据,数据之间是相邻的)。但是在实际情况下顺序表还存在以下的局限性:
1. 容量固定,扩容成本高
对于动态顺序表来讲,当我们向其中插入数据的时候如果空间不足就必须使用realloc在原有空间的基础上进行扩容操作。但是,在实际中如果系统内存分配紧张内存碎片化严重realloc就会采用异地扩容,再将原有空间中的数据拷贝到新空间中,这样的话时间复杂度就是O(n)。如果用户进行大量的数据插入操作同时内存碎片化严重,realloc就会频繁进行异地扩容和拷贝数据。
2. 插入 / 删除操作效率低
当对顺序表进行头插和中间位置的插入操作时必须对原有的数据进行遍历和移动以保持连续存储,时间复杂度是O(n)。频繁的插入 / 删除操作会导致大量数据移动,性能较差。
3. 内存利用率受限
- 静态顺序表若未存满,会浪费预分配的内存;
- 动态顺序表扩容后若元素减少,闲置空间无法自动释放需要用户手动缩容。
造成这种问题的本质原因是什么呢?
顺序表的上述缺点,本质根源在于其 "物理存储连续" 的核心特性------ 顺序表依赖一块连续的内存空间存储元素,这一特性是所有问题的底层逻辑
一方面,静态顺序表必须一次性申请固定大小的连续内存,一旦分配就无法灵活调整(要么不够用,要么浪费);动态顺序表扩容时,必须找到一块更大的连续内存(而非零散的小内存块),再把原数据拷贝过去。
另一方面,顺序表的 "连续" 是 "逻辑顺序 = 物理顺序" 的体现 ------ 每个元素的位置由其物理地址直接决定。当在头部 / 中间插入 / 删除元素时,为了维持 "连续" 的特性必须移动后续所有元素的物理位置。
连续内存的分配逻辑,天然存在 "要么过剩、要么不足" 的矛盾:
- 若预分配的连续内存偏大,未使用的部分会被 固定在顺序表中,无法被其他程序 / 数据复用(内存浪费);
- 若预分配偏小,扩容后又会产生 "扩容增量 - 实际需要量" 的闲置空间(比如扩容到 2 倍后只多存了 1 个元素,剩余的近一半空间闲置);
所以想要解决顺序表的一系列效率问题就必须解决逻辑连续与物理连续的的强相关,即使数据被存储在不同的内存空间中我们也可以通过一种手段将数据关联起来,形成一种在物理内存上看数据之间是不连续的(没有整体存放在一块连续的内存空间中)但是在逻辑层面数据之间彼此关联是连续的。所以我们引入了链表的定义:
二、什么是链表
链表是一种逻辑结构线性、物理结构离散的线性表数据结构,其元素(节点)通过指针(或引用)相互链接,无需依赖连续的内存空间存储。
在顺序表中,所有数据会被整体存放于一块连续的内存空间内。因此,访问完一个数据后,只需偏移对应元素大小的字节数,就能直接定位并访问下一个数据。但这种方式在链表中完全行不通 ------ 链表的各个节点在内存中可能分散在互不相邻的区域。为了解决这一问题,链表的每个节点除了存储有效数据外,还需额外配备一个指针,用于指向后继节点 的内存地址;若是双向链表,则还需增加一个指针,指向前驱节点。正是通过这种指针的索引与关联,链表得以实现 "物理存储分散但逻辑顺序连续" 的特性。

三、链表的分类
衡量链表的结构与功能主要有三个核心维度:是否带头(拥有头节点),是否循环,是单链表还是双链表(节点内指针的数量以及指向)。
| 维度 | 核心划分依据 | 两种核心状态 |
|---|---|---|
| 1. 指针方向(单向 / 双向) | 节点包含的指针数量与指向(核心结构) | ✅ 单向:仅含「后继指针(next)」,只能往后遍历;✅ 双向:含「前驱指针(prev)+ 后继指针(next)」,可前后遍历。 |
| 2. 是否循环 | 首尾节点的链接关系(闭环 / 开环) | ✅ 循环:尾节点指针指向头节点(单向)/ 头节点前驱指向尾节点(双向),无 NULL 终止;✅ 非循环:尾节点指针指向 NULL,遍历到尾即终止。 |
| 3. 是否带头节点 | 链表头部是否有 "空占位节点"(边界优化) | ✅ 带头:头部有一个无业务数据的 "头节点",其指针指向第一个有效节点;✅ 不带头:无空节点,头指针直接指向第一个有效节点(空链表时头指针为 NULL)。 |

举个例子,带头双向循环链表则指明了该链表拥有一个头节点(也叫做哨兵位),首尾节点的链接处于闭环而双向则说明链表的一个节点中含有两个指针(前驱指针+后继指针)可前后遍历。所以链表的形态如图所示:

这三个维度可自由组合,以下是开发中最常用的 8 种组合类型,附核心特征:

| 组合类型 | 结构特征 | 典型适用场景 |
|---|---|---|
| 不带头 + 非循环 + 单向链表 | 最基础形式,头指针→有效节点→...→尾节点(NULL),仅单向遍历,边界操作需单独处理 | 入门教学、极简队列实现 |
| 带头 + 非循环 + 单向链表 | 头节点(空)→有效节点→...→尾节点(NULL),统一空 / 非空链表操作逻辑 | 实际开发中最常用的单链表形式 |
| 不带头 + 循环 + 单向链表 | 头指针→有效节点→...→尾节点→头指针(闭环),无 NULL,需控制遍历次数 | 约瑟夫环问题、简单环形调度 |
| 带头 + 循环 + 单向链表 | 头节点→有效节点→...→尾节点→头节点(闭环),边界操作更友好 | 环形缓冲区、循环任务队列 |
| 不带头 + 非循环 + 双向链表 | 头指针→有效节点(prev/next)→...→尾节点(next=NULL),双向遍历但边界复杂 | 极少用(仅教学) |
| 带头 + 非循环 + 双向链表 | 头节点→有效节点(prev/next)→...→尾节点(next=NULL),双向遍历 + 边界友好 | LRU 缓存、双向队列、高频增删场景 |
| 不带头 + 循环 + 双向链表 | 头指针→有效节点(prev/next)→尾节点→头指针(闭环),双向循环但边界复杂 | 特殊环形场景(如时钟循环遍历) |
| 带头 + 循环 + 双向链表 | 头节点→有效节点(prev/next)→尾节点→头节点(闭环),双向循环 + 边界友好 | 高性能环形缓存、复杂调度系统 |
四、单链表(不带头单向不循环)
4.1 核心定义
单链表是一种线性表的链式存储结构,它不像数组那样占用连续的内存空间,而是由一组节点(Node) 依次连接而成,每个节点包含两部分:
- 数据域:存储当前节点的实际数据(如整数、字符串等);
- 指针域(也称后继指针):存储下一个节点的内存地址,以此建立节点间的连接。

单链表的最后一个节点的指针域通常指向 NULL(空),表示链表的末尾;同时,通常会用一个头节点 / 头指针指向链表的第一个节点,作为访问整个链表的入口。
关键特性
-
非连续内存:节点可以分散存储在内存的任意位置,通过指针关联,无需提前分配连续空间;
-
单向遍历:只能从头部开始,通过每个节点的指针依次访问后续节点,无法反向访问;
-
动态性:插入 / 删除节点时只需修改指针指向,无需移动其他元素(相比数组更高效);
-
随机访问差:访问第 n 个节点时,必须从头部开始遍历 n-1 个节点,时间复杂度为 O (n)(数组是 O (1))。
4.2 代码实现
4.2.1 单个节点定义
cpp
typedef int SLTDataType;
typedef struct SListNode
{
//指针域
SLTNode* _next;
//数据域
SLTDataType _data;
}SLTNode;
单链表的一个节点中包含两部分:数据域+指针域所以我们使用一个结构体来整体封装,并且为方便后续使用节点定义我们将其重定义为SLTNode。
4.2.2 获取一个节点
cpp
SLTNode* STLBuyNode(SLTDataType data)
{
SLTNode* temp = (SLTNode*)malloc(sizeof(SLTNode));
if (temp == NULL)
{
printf("STLBuyNode fail!!\n");
return NULL;
}
temp->_data = data;
temp->_next = NULL;
return temp;
}
4.2.3 头插与尾插
cpp
//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
//二级指针不能为空
assert(pphead);
SLTNode* newnode = STLBuyNode(x);
if (newnode == NULL)
{
return;
}
if (*pphead == NULL)
{
//说明单链表中没有节点
*pphead = newnode;
return;
}
newnode->_next = *pphead;
*pphead = newnode;
}
当进行插入操作而链表中一个节点也没有时除了创建一个节点还需要一个头指针指针phead来存储节点的地址作为整个链表的入口地址。
而在头插时需要在第一个节点之前插入一个节点,此时链表的入口地址phead会转而存储新加入节点的地址。所以一个SLTNode*指针内容的改变需要二级指针,所以需要传二级指针。
cpp
//尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = STLBuyNode(x);
if (newnode == NULL)
{
return;
}
if (*pphead == NULL)
{
//说明单链表中没有节点
*pphead = newnode;
return;
}
//找尾:
SLTNode* cur = *pphead;
while (cur->_next != NULL)
{
cur = cur->_next;
}
cur->_next = newnode;
}
4.2.4 头删与尾删
cpp
//尾删:
void SLTPopBack(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
//只有一个节点
if ((*pphead)->_next == NULL)
{
free(*pphead);
*pphead = NULL;
return;
}
//找尾:
SLTNode* prev = NULL;
SLTNode* cur = *pphead;
while (cur->_next != NULL)
{
prev = cur;
cur = cur->_next;
}
prev->_next = NULL;
free(cur);
cur = NULL;
return;
}
//头删
void SLTPopFront(SLTNode** pphead)
{
assert(pphead);
assert(*pphead);
SLTNode* temp = (*pphead)->_next;
free(*pphead);
*pphead = temp;
}
头删与尾删接口也需要传递二级指针的原因是,当只有一个节点时删除节点后链表的入口地址需要更改为NULL避免野指针。
删除节点的相关接口中除了判断传递的二级指针为NULL还需要判断一级指针phead,这是为了确保链表中至少有一个节点可以删除。
4.2.5 指定位置插入删除
cpp
//在指定位置之前插⼊数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead);
assert(pos);
if (pos == *pphead)
{
//头插
SLTPushFront(pphead,x);
return;
}
SLTNode* newnode = STLBuyNode(x);
if (newnode == NULL)
{
return;
}
SLTNode* cur = *pphead;
while (cur->_next != NULL && cur->_next != pos)
{
cur = cur->_next;
}
if (cur->_next == NULL)
{
printf("节点不存在!!/n");
free(newnode);
return;
}
newnode->_next = pos;
cur->_next = newnode;
}
参数合法性校验 :通过断言确保传入的链表头指针地址(pphead)和目标插入位置节点(pos)均不为空,避免非法指针操作。
头部插入特殊处理 :若目标插入位置pos是链表头节点(pos == *pphead),直接调用链表头插函数(SLTPushFront)完成插入,执行后直接返回。
常规插入前置准备 :先创建存储数据x的新节点(通过SLTBuyNode分配内存);遍历链表找pos的前驱节点cur(循环判定条件:cur->next不为空且不等于pos);校验pos是否存在于链表中:若遍历结束后cur->next为空,说明pos无效,打印 "节点不存在" 提示,释放新节点内存后返回。
指针调整完成插入 :将新节点的后继指针指向pos(newnode->next = pos),再将pos前驱节点cur的后继指针指向新节点(cur->next = newnode),完成在pos前插入新节点的核心操作。
cpp
//在指定位置之后插⼊数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = STLBuyNode(x);
if (newnode == NULL)
{
return;
}
newnode->_next =pos->_next;
pos->_next = newnode;
}
在指定位置pos之后插入数据不需要链表入口地址phead,因为pos->_next就已经记载了插入位置的信息,不需要遍历链表。
cpp
//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead);
assert(*pphead);
assert(pos);
if (pos == *pphead)
{
*pphead = (*pphead)->_next;
free(pos);
return;
}
SLTNode* cur = *pphead;
while (cur->_next != NULL && cur->_next != pos)
{
cur = cur->_next;
}
if (cur->_next == NULL)
{
printf("节点不存在!!/n");
return;
}
cur->_next = pos->_next;
free(pos);
}
参数合法性校验 :通过断言确保链表头指针地址(pphead)、链表本身(*pphead)、目标删除节点(pos)均不为空,避免非法操作。
头节点删除特殊处理 :若pos是链表头节点(pos == *pphead),直接将头指针指向原头节点的下一个节点,释放pos节点内存后返回。
常规删除的前驱节点查找 :遍历链表找到pos的前驱节点cur(循环条件:cur->next不为空且不等于pos)。
节点存在性校验 :若遍历后cur->next为空,说明pos不在链表中,打印 "节点不存在" 提示并返回。
指针调整与节点释放 :将pos的前驱节点cur的后继指针,指向pos的下一个节点(跳过pos),最后释放pos节点的内存,完成删除操作。
4.2.6 销毁/查找/判空
cpp
//销毁链表
void SListDesTroy(SLTNode** pphead)
{
SLTNode* cur = *pphead;
while (cur != NULL)
{
SLTNode* prev = cur;
cur = cur->_next;
free(prev);
}
*pphead= NULL;
}
//查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
assert(phead);
SLTNode* cur = phead;
while (cur)
{
if (cur->_data == x)
{
return cur;
}
else
{
cur = cur->_next;
}
}
return NULL;
}
//判空
bool SListEmpty(SLTNode* phead)
{
return phead == NULL;
}
销毁链表接口的核心是释放链表所有节点内存并清空链表,具体通过指针cur从头节点开始遍历,每次暂存当前节点、移动指针后释放该节点内存,遍历完成后将链表头指针置为NULL;查找节点接口用于在链表中查找存储数据为x的节点并返回其指针,先断言链表头节点非空,再通过指针cur从头遍历链表匹配目标数据,找到则返回节点指针,未找到返回NULL;判空接口的逻辑极为简洁,直接通过判断链表头节点是否为NULL,返回链表是否为空的布尔结果。
五、双链表(带头双向循环链表)
5.1 核心定义
双向循环带头链表是一种功能更完善的链表结构,结合了 "双向""循环""带头" 三个核心特性,具体可理解为:
它包含一个哨兵位头节点 (不存储实际数据,仅作为链表的固定入口),链表中每个节点除了数据域,还包含两个指针域 ------ 一个前驱指针 (指向当前节点的前一个节点)、一个后继指针(指向当前节点的后一个节点);同时链表的首尾节点会形成循环:头节点的前驱指针指向链表的最后一个节点,最后一个节点的后继指针指向头节点。
这种结构的优势在于:无需单独处理头 / 尾节点的边界情况(比如插入 / 删除首尾节点时,不用额外判断是否为空),且支持从任意节点向前后两个方向遍历,操作效率更高

5.2 代码实现
5.2.1 单个节点的实现
cpp
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* _prev;
struct ListNode* _next;
LTDataType _data;
}LTNode;
5.2.2 初始化与销毁
单链表中一个指针变量充当整个链表的入口,与单链表不同的是在双链表中哨兵节点就是整个链表固定的操作入口,无论链表结构如何变化(插入、删除节点),都可以通过哨兵节点快速访问链表的首尾。
所以创建一个双链表的时候需要初始化一个哨兵节点,哨兵节点的前驱与后继指针都指向自己。这样做的目的是
- 契合 "双向循环" 的核心结构定义
双向循环带头链表的核心规则是:
-
链表首尾形成闭环,即 "最后一个节点的
next指向哨兵节点,哨兵节点的prev指向最后一个节点"。 -
当链表为空(无任何有效节点)时,哨兵节点自身就是 "链表的全部" ,此时要满足 "闭环" 规则,只能让它的_
prev和_next都指向自己 ------ 既保证了 "循环" 特性,也符合 "双向" 指针的指向逻辑(没有其他节点时,前驱和后继只能是自己)。
- 统一空链表与非空链表的操作逻辑
如果没有这个初始化规则,空链表的哨兵节点指针会是NULL,后续插入第一个有效节点时,需要额外判断 "链表是否为空",再单独处理指针指向;而初始化时让哨兵节点自环(指针指向自己),插入第一个节点的逻辑和插入任意节点完全一致。
cpp
//获取一个节点
LTNode* BuyNode(LTDataType x)
{
LTNode* temp = (LTNode*)malloc(sizeof(LTNode));
if (temp == NULL)
{
printf("新节点创建失败!/n");
return NULL;
}
//因为是双向带头循环链表所以新节点保持循环特征
temp->_data = x;
temp->_next = temp;
temp->_prev = temp;
//因为是在堆上开辟的空间没有手动释放所以没有野指针问题
return temp;
}
//初始化链表
LTNode* LTInit()
{
return BuyNode(-1);
}
cpp
//销毁链表(遍历释放各个节点)
void LTDestroy(LTNode** phead)
{
assert(phead);
assert(*phead);
LTNode* cur = (*phead)->_next;
while (cur != *phead)
{
LTNode* temp = cur;
cur = cur->_next;
free(temp);
}
//释放头节点
free(*phead);
(*phead) = NULL;
}
核心逻辑为先通过断言校验传入的头指针地址和哨兵节点非空,避免非法操作;接着用指针cur从哨兵节点的后继(第一个有效节点)开始遍历,以cur != *pphead作为循环终止条件(cur回到哨兵节点即遍历完所有有效节点),遍历过程中每次暂存当前节点、移动cur后释放该节点内存;最后释放哨兵节点本身的内存,并将头指针置为NULL,完成整个链表的销毁。确保所有节点(包括有效节点和哨兵节点)都被完整释放,彻底清空链表。
5.2.3 头插与尾插
cpp
//头插
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode = BuyNode(x);
newnode->_next = phead->_next;
newnode->_prev = phead;
phead->_next->_prev = newnode;
phead->_next = newnode;
}
//尾插
void LTPushBack(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* newnode= BuyNode(x);
newnode->_next = phead;
newnode->_prev = phead->_prev;
phead->_prev = newnode;
newnode->_prev->_next = newnode;
}
两个接口均先断言哨兵节点phead非空以确保链表结构合法,再创建存储数据x的新节点newnode;头插接口通过调整新节点与哨兵节点、哨兵节点原后继节点的前驱 / 后继指针,将新节点插入到哨兵节点之后(第一个有效节点前),尾插接口则借助哨兵节点的prev指针直接定位原尾节点,调整新节点与哨兵节点、原尾节点的前驱 / 后继指针,将新节点插入到哨兵节点之前(最后一个有效节点后)。整体设计充分适配双向循环带头链表的特性,无需遍历链表,插入操作时间复杂度均为 O (1),且无需区分空链表与非空链表,统一的指针调整逻辑让代码简洁高效。
5.2.4 头删与尾删
cpp
//头删
void LTPopFront(LTNode* phead)
{
assert(phead);
if (!LTEmpty(phead))
{
LTNode* head = phead->_next;
phead->_next = head->_next;
head->_next->_prev = phead;
free(head);
}
}
//尾删
void LTPopBack(LTNode* phead)
{
assert(phead);
if (!LTEmpty(phead))
{
LTNode* tail = phead->_prev;
phead->_prev = tail->_prev;
tail->_prev->_next = phead;
free(tail);
}
}
两个接口均先断言哨兵节点phead非空以保证链表结构合法,再通过LTEmpty函数判断链表非空,避免空链表删除的非法操作;头删接口直接定位哨兵节点后继的第一个有效节点作为待删节点,调整哨兵节点与该节点后继节点的前驱 / 后继指针完成解链,释放待删节点内存;尾删接口则借助哨兵节点的prev指针直接定位最后一个有效节点作为待删节点,调整哨兵节点与该节点前驱节点的前驱 / 后继指针完成解链,释放待删节点内存。整体设计充分利用双向循环带头链表的特性,无需遍历链表即可完成删除,操作时间复杂度均为 O (1),且指针调整逻辑简洁统一,规避了空指针操作风险。
5.2.5 指定位置插入删除
cpp
//在pos位置之后插⼊数据
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos);
LTNode* newnode = BuyNode(x);
newnode->_next = pos->_next;
newnode->_prev = pos;
pos->_next = newnode;
newnode->_next->_prev = newnode;
}
//删除pos节点
void LTErase(LTNode* pos)
{
LTNode* prev = pos->_prev;
LTNode* next = pos->_next;
prev->_next = next;
next->_prev = prev;
free(pos);
}
LTInsert接口用于在指定节点pos之后插入新节点,先断言pos非空以确保操作合法,再创建存储数据x的新节点,通过调整新节点与pos、pos后继节点的前驱 / 后继指针,完成新节点的 "链入",整个过程无需遍历链表,操作时间复杂度为 O (1)。
LTErase接口用于删除指定节点pos,先获取pos的前驱与后继节点,通过调整这两个节点的前驱 / 后继指针跳过pos完成解链,最后释放pos的内存,同样借助双向指针直接完成操作,无需额外查找节点,时间复杂度为 O (1)。
5.2.6 查找/打印/判空
cpp
//查找x值对应的节点并返回指针
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead);
LTNode* cur = phead->_next;
while (cur != phead)
{
if (cur->_data == x)
{
return cur;
}
cur = cur->_next;
}
printf("链表中没有%d节点",x);
return NULL;
}
//遍历打印带头双向循环链表
void LTPrint(LTNode* phead)
{
assert(phead);
LTNode* cur = phead->_next;
while (cur != phead)
{
printf("%d->",cur->_data);
cur = cur->_next;
}
}
//判空
bool LTEmpty(LTNode* phead)
{
return phead->_next == phead;
}
LTFind接口用于查找存储数据x的节点,先断言哨兵节点phead非空,再从哨兵节点的后继(第一个有效节点)开始遍历,以 "cur回到哨兵节点" 为循环终止条件(契合链表循环特性),遍历中若匹配到数据x则返回对应节点指针,未找到则打印提示并返回NULL,其遍历逻辑适配双向循环结构,确保覆盖所有有效节点。
LTPrint接口用于遍历打印链表所有有效数据,同样先断言哨兵节点非空,从第一个有效节点开始遍历,循环终止条件与LTFind一致,遍历中依次打印节点数据,实现链表内容的可视化输出,操作逻辑与链表结构高度适配。
LTEmpty接口用于判断链表是否为空,通过判断 "哨兵节点的后继是否指向自身" 实现 ------ 双向循环带头链表空链表时,哨兵节点的next指向自身,因此该接口仅需一个条件判断即可完成判空,逻辑简洁且符合链表结构定义。
六、单链表与双链表的比较
单链表仅包含 "数据域 + 后继指针",节点仅能指向后继节点,且无固定哨兵节点,空链表时头指针直接为NULL;双链表(双向循环带头)则包含 "数据域 + 前驱指针 + 后继指针",节点可双向指向前后节点,且存在固定哨兵位头节点,空链表时哨兵节点自环,始终维持循环结构,从根本上消除了头 / 尾节点的边界差异。
操作效率与逻辑复杂度
-
遍历 :单链表仅支持单向遍历,需从头部开始逐个访问,访问尾节点需遍历全表;双链表可双向遍历,借助哨兵节点的
prev指针能直接定位尾节点,遍历灵活性和效率更高。 -
插入 / 删除:单链表插入 / 删除非头节点时,需先遍历找到目标节点的前驱节点(时间复杂度 O (n)),且需单独处理头 / 尾节点的边界逻辑;双链表借助前驱指针可直接定位目标节点的前后节点,插入 / 删除仅需调整指针(时间复杂度 O (1)),所有位置操作逻辑统一,无需区分头 / 尾。
-
销毁 / 判空 :单链表销毁需遍历释放所有节点,判空需检查头指针是否为
NULL;双链表销毁依托循环特性,以哨兵节点为终止条件遍历释放,判空仅需检查哨兵节点是否自环,逻辑更简洁且不易出现空指针错误。
空间与适用场景
单链表每个节点仅需一个指针域,空间开销更小,适合内存资源紧张、仅需单向遍历、插入删除操作较少的场景(如简单队列 / 栈的底层实现);双链表虽因额外的前驱指针增加了空间开销,但操作效率和逻辑简洁性大幅提升,适合需要频繁双向遍历、高频插入删除(尤其是首尾或任意位置)的场景(如 LRU 缓存、双向队列等)。
| 对比维度 | 单链表 | 双链表(双向循环带头) |
|---|---|---|
| 节点结构 | 数据域 + 单个后继指针 | 数据域 + 前驱指针 + 后继指针 |
| 哨兵节点 | 无,空链表头指针为 NULL | 有固定哨兵位头节点,空链表时自环 |
| 遍历特性 | 仅支持单向遍历,访问尾节点需遍历全表 | 支持双向遍历,哨兵节点 prev 直接定位尾节点 |
| 插入操作 | 非头节点插入需遍历找前驱(O (n)),需单独处理头 / 尾边界 | 任意位置插入仅调整指针(O (1)),所有位置逻辑统一 |
| 删除操作 | 非头节点删除需遍历找前驱(O (n)),需单独处理头 / 尾边界 | 任意节点删除仅调整指针(O (1)),无需查找前驱 |
| 判空逻辑 | 检查头指针是否为 NULL | 检查哨兵节点 next 是否指向自身 |
| 销毁逻辑 | 遍历释放所有节点,需判断空指针 | 以哨兵节点为终止条件遍历释放,无空指针风险 |
| 空间开销 | 每个节点仅 1 个指针域,开销小 | 每个节点 2 个指针域,开销略大 |
| 适用场景 | 内存紧张、单向遍历、插入删除少(如简单队列 / 栈) | 频繁双向遍历、高频插入删除(如 LRU 缓存、双向队列) |
| 代码维护性 | 边界逻辑多,易出错 | 操作逻辑统一,维护成本低 |