前言
今天是数据结构学习的第三天,在掌握单向链表的基础上,我们进阶学习了双向链表 与双向循环链表,核心聚焦「创建初始化、头插、尾插、销毁」四大基础操作。本文结合实操中踩过的编译、逻辑坑点,梳理完整知识点,并横向对比单向链表、双向链表、双向循环链表的异同
提示:以下是本篇文章正文内容,下面案例可供参考
一、双向链表基础结构
1.1 节点结构体定义
双向链表的核心是每个节点拥有两个指针域:
-
pNext:指向后继节点(下一个节点) -
pPre:指向前驱节点(上一个节点) -
相比单向链表,它支持双向遍历,插入删除时无需额外遍历查找前驱节点,操作灵活性更高。
// 数据类型重定义,方便后续统一修改数据类型 typedef int DataType; // 双向链表节点结构体 typedef struct node { DataType Data; // 数据域:存储有效数据 struct node *pPre; // 前驱指针:指向直接前一个节点 struct node *pNext; // 后继指针:指向直接后一个节点 }Node_t;本文统一采用带头结点的设计:头结点不存储有效数据,仅作为链表的起始标记,能够统一空表、非空表的操作逻辑,大幅减少边界条件判断。
二、双向链表(非循环)核心操作
2.1 链表初始化(创建头结点)
初始化即创建一个空的带头结点链表,头结点的两个指针都置为
NULL。// 初始化双向链表,返回头结点指针 Node_t *InitDouList() { Node_t *pHead = (Node_t *)malloc(sizeof(Node_t)); if(pHead == NULL) { printf("malloc failed!\n"); return NULL; } pHead->pPre = NULL; pHead->pNext = NULL; return pHead; }2.2 尾插法(尾部插入节点)
尾插核心逻辑:遍历找到链表最后一个节点,将新节点双向绑定在尾部。
实操易错坑点
-
新节点创建后,必须手动将
pNext置为NULL,否则malloc的随机脏值会导致遍历死循环、输出刷屏 -
遍历找尾时,起始指针从头结点出发,避免空链表时
NULL->pNext触发段错误
// 双向链表尾插
int InsertTailDouList(Node_t *pHead, DataType TmpData)
{
// 1. 创建并初始化新节点
Node_t *pNewNode = (Node_t *)malloc(sizeof(Node_t));
if(pNewNode == NULL)
{
printf("malloc failed!\n");
return -1;
}
pNewNode->Data = TmpData;
pNewNode->pPre = NULL;
pNewNode->pNext = NULL; // 尾节点后继必须置空,核心易错点
// 2. 遍历找尾节点(从头结点出发,天然兼容空表)
Node_t *pCur = pHead;
while(pCur->pNext != NULL)
{
pCur = pCur->pNext;
}
// 3. 双向指针绑定
pCur->pNext = pNewNode;
pNewNode->pPre = pCur;
return 0;
}
2.3 头插法(头部插入节点)
头插核心逻辑:将新节点插入到头结点与第一个有效节点之间,注意双向指针的修改顺序。
// 双向链表头插
int InsertHeadDouList(Node_t *pHead, DataType TmpData)
{
Node_t *pNewNode = (Node_t *)malloc(sizeof(Node_t));
if(pNewNode == NULL)
{
printf("malloc failed!\n");
return -1;
}
pNewNode->Data = TmpData;
// 先绑定新节点的前后指针
pNewNode->pNext = pHead->pNext;
pNewNode->pPre = pHead;
// 如果链表非空,修改原第一个节点的前驱指针
if(pHead->pNext != NULL)
{
pHead->pNext->pPre = pNewNode;
}
// 最后修改头结点的后继
pHead->pNext = pNewNode;
return 0;
}
2.4 链表销毁(内存释放)
销毁需要逐个释放所有节点,最后将外部头指针置为NULL,避免产生野指针。
常见疑问解答
函数内的临时遍历指针(如pCur、pFree)不需要手动置空。它们是栈上的局部变量,函数结束后栈帧销毁,变量会自动回收;只要函数内不再解引用,就不会产生风险。真正必须置空的是外部传入的头指针。
// 销毁双向链表,二级指针用于修改外部的头指针
void DestroyDouList(Node_t **ppHead)
{
// 入参合法性校验
if(ppHead == NULL || *ppHead == NULL)
{
return;
}
Node_t *pCur = *ppHead;
Node_t *pFree = NULL;
while(pCur != NULL)
{
pFree = pCur; // 保存当前待释放节点
pCur = pCur->pNext; // 先移动到下一个节点,再释放当前节点
free(pFree);
}
*ppHead = NULL; // 关键:外部头指针置空,防止野指针
}
三、双向循环链表核心操作
3.1 结构特点
双向循环链表在双向链表的基础上,让尾节点的后继指向头结点,头结点的前驱指向尾节点,整个链表形成一个闭环。
- 空表状态:头结点的
pNext和pPre都指向自己 - 遍历终止标志:指针回到头结点,不再是
NULL(这是最容易踩的致命坑点)
3.2 初始化
// 初始化双向循环链表
Node_t *InitCirList()
{
Node_t *pHead = (Node_t *)malloc(sizeof(Node_t));
if(pHead == NULL)
{
printf("malloc failed!\n");
return NULL;
}
// 空循环表:自己指向自己,形成闭环
pHead->pNext = pHead;
pHead->pPre = pHead;
return pHead;
}
3.3 尾插法
致命踩坑点(实操验证)
❌ 错误写法:while(pCur->pNext != NULL) → 循环链表永远碰不到NULL,程序直接死循环、终端刷屏 ✅ 正确写法:while(pCur->pNext != pHead) → 下一个节点是头结点,说明当前就是尾节点
// 双向循环链表尾插
int InsertTailCirList(Node_t *pHead, DataType TmpData)
{
Node_t *pNewNode = (Node_t *)malloc(sizeof(Node_t));
if(pNewNode == NULL)
{
printf("malloc failed!\n");
return -1;
}
pNewNode->Data = TmpData;
// 找尾节点:下一个节点是头结点,即为尾部
Node_t *pCur = pHead;
while(pCur->pNext != pHead)
{
pCur = pCur->pNext;
}
// 双向指针 + 循环闭环绑定
pCur->pNext = pNewNode;
pNewNode->pPre = pCur;
pNewNode->pNext = pHead; // 新尾节点后继指向头结点,维持循环
pHead->pPre = pNewNode; // 头结点前驱指向新尾节点,维持循环
return 0;
}
3.4 头插法
// 双向循环链表头插
int InsertHeadCirList(Node_t *pHead, DataType TmpData)
{
Node_t *pNewNode = (Node_t *)malloc(sizeof(Node_t));
if(pNewNode == NULL)
{
printf("malloc failed!\n");
return -1;
}
pNewNode->Data = TmpData;
// 新节点前后指针绑定
pNewNode->pNext = pHead->pNext;
pNewNode->pPre = pHead;
// 原第一个节点的前驱指向新节点
pHead->pNext->pPre = pNewNode;
// 头结点后继指向新节点
pHead->pNext = pNewNode;
return 0;
}
3.5 销毁操作
循环链表销毁的终止条件:遍历指针回到头结点时停止,最后单独释放头结点。
void DestroyCirList(Node_t **ppHead)
{
if(ppHead == NULL || *ppHead == NULL)
{
return;
}
Node_t *pCur = (*ppHead)->pNext; // 从第一个有效节点开始遍历
Node_t *pFree = NULL;
// 遍历回到头结点则停止
while(pCur != *ppHead)
{
pFree = pCur;
pCur = pCur->pNext;
free(pFree);
}
// 最后释放头结点,并置空外部指针
free(*ppHead);
*ppHead = NULL;
}
四、三类链表横向对比
4.1 核心差异对照表
| 对比维度 | 单向链表 | 双向链表 | 双向循环链表 |
|---|---|---|---|
| 指针域 | 仅后继指针pNext |
前驱pPre+ 后继pNext |
前驱pPre+ 后继pNext |
| 遍历方向 | 只能从前往后单向遍历 | 支持双向遍历 | 支持双向遍历,首尾循环贯通 |
| 尾节点标记 | pNext == NULL |
pNext == NULL |
pNext == 头结点 |
| 尾插时间复杂度 | O (n),必须遍历找尾 | O (n),必须遍历找尾 | 遍历实现为 O (n);若记录尾指针可优化为 O (1) |
| 删除已知节点效率 | O (n),需额外遍历找前驱 | O (1),直接通过自身pPre获取前驱 |
O (1),直接通过自身pPre获取前驱 |
| 空表状态 | 头结点pNext == NULL |
头结点pNext == NULL |
头结点pNext/pPre均指向自身 |
| 空间开销 | 最小(1 个指针 / 节点) | 中等(2 个指针 / 节点) | 中等(2 个指针 / 节点) |
| 典型易错点 | 尾插遍历越界、空表操作 | 新节点指针未初始化、断链 | 误用NULL作为循环终止条件 |
4.2 相同点总结
- 都属于链式存储结构,节点内存不连续,通过指针串联,支持动态扩容
- 都可采用带头结点设计,统一空表与非空表的操作逻辑,简化边界判断
- 插入、删除节点都仅需修改指针,无需移动大量数据,操作效率高于顺序表
- 都需要手动管理内存(
malloc/free),存在内存泄漏、野指针的风险
4.3 适用场景
- 单向链表:仅需正向遍历、对空间开销敏感的场景,如哈希表拉链法、链式队列实现
- 双向链表:需要频繁双向遍历、频繁插入删除中间节点的场景,如 LRU 缓存、浏览器前进后退功能
- 双向循环链表:需要循环访问、首尾操作频繁的场景,如操作系统进程调度队列、循环缓冲区
五、实操高频踩坑汇总
结合本次编码调试遇到的实际问题,整理核心避坑清单:
-
循环条件写错 :双向循环链表终止条件是头结点,不是
NULL,否则会触发死循环、终端无限刷屏 -
语法细节笔误 :
if/while后多打分号,会导致大括号内代码无条件执行,逻辑完全失效 -
新节点指针未初始化 :
malloc申请的内存是随机脏值,尾节点不手动置NULL会导致遍历异常 -
空链表解引用 :直接访问
pHead->pNext->pNext,空表时会触发空指针访问、程序段错误崩溃 -
销毁漏置空头指针:只释放节点不置空头指针,会导致外部指针变成野指针,后续访问触发崩溃
-
编译链接报错 :Makefile 中
-o输出文件名与已有目录重名,会导致链接器ld报错无法生成可执行文件
六、学习总结
双向链表通过增加前驱指针,解决了单向链表无法反向遍历、删除节点需额外查找前驱的痛点;而循环链表则消除了头尾的边界限制,让首尾操作的逻辑更连贯。三类链表没有绝对的优劣,核心是根据业务场景的遍历方向、操作频率、空间限制来选择。
掌握基础的增删操作后,后续可以继续练习指定位置插入、按值查找、按位置删除等进阶操作,进一步夯实链式存储的核心逻辑与指针操作能力。