目录
[(一)链表的 8 种分类逻辑](#(一)链表的 8 种分类逻辑)
[(二)List.h 完整代码](#(二)List.h 完整代码)
[(一)为什么必须校验 pos 的合法性?](#(一)为什么必须校验 pos 的合法性?)
[(二)为什么插入 / 删除操作传一级指针即可?](#(二)为什么插入 / 删除操作传一级指针即可?)
[(四)为什么哨兵位数据域赋值为 - 1?](#(四)为什么哨兵位数据域赋值为 - 1?)
一、链表基础认知与分类
(一)链表的 8 种分类逻辑
1、核心分类逻辑
链表通过3 组二元属性组合形成 8 种类型,每种类型对应不同的应用场景,核心分类维度如下:
(1)带头 / 不带头
① 带头:含哨兵位结点(不存有效数据,仅标识链表起始)
② 不带头:无哨兵位
(2)双向 / 单向
① 双向:结点含next(后继)+prev(前驱)指针
② 单向:仅含next指针
(3)循环 / 非循环
① 循环:尾结点next指向头节点,形成闭环;
② 非循环:尾结点next指向NULL
其中带头双向循环链表是所有类型中操作最便捷的,是工程中最常用的链表结构,也是本文讲解的内容。
(二)双向链表与单链表、顺序表的本质差异
1、带头双向循环链表
(1)物理存储特性: 物理离散,逻辑连续
(2)优点:任意位置插入 / 删除效率高(O (1))
(3)缺点: 额外存储prev指针,空间开销大
2、不带头单向链表
**(1)物理存储特性:**物理离散,逻辑连续
(2)优点: 结构简单,空间开销小
(3)缺点: 尾操作效率低(O (n)),仅支持单向遍历
3、顺序表(数组)
**(1)物理存储特性:**物理离散,逻辑连续
(2)优点:随机访问效率高(O (1))
(3)缺点: 头部 / 中间插入 / 删除效率低(O (n))
(三)双向链表的核心概念
**1、哨兵位 (头结点) :**不存储有效数据,仅用于标识链表起始,空链表时next和prev均指向自身。
**2、首元结点:**哨兵位next指向的第一个存储有效数据的结点。
3、尾结点:哨兵位prev指向的最后一个存储有效数据的结点。
**4、闭环特性:**尾结点next指向哨兵位,哨兵位prev指向尾结点,形成循环结构。
二、环境搭建与结构体定义
(一)工程文件结构
需创建 3 个文件,分工明确,便于维护:
**1、List.h:**结构体定义、函数声明、头文件引入(对外提供接口)。
**2、List.c:**所有函数的具体实现(内部逻辑)。
**3、test.c:**测试用例编写,验证功能正确性(调用接口)。
(二)List.h 完整代码
cpp
#pragma once
// 引入依赖头文件
#include<stdio.h> // 输入输出
#include<stdlib.h> // malloc/free
#include<assert.h> // 断言(调试用)
#include<stdbool.h> // 布尔类型(判空用)
// 1. 数据类型重定义(便于后续修改数据类型,如改为char/float)
typedef int LTDataType;
// 2. 双向链表结点结构体定义
typedef struct ListNode {
LTDataType data; // 数据域:存储结点值
struct ListNode* next; // 后继指针:指向后一个结点
struct ListNode* prev; // 前驱指针:指向前一个结点
} LTNode;
// 3. 函数声明(接口)
//(1)初始化与销毁
// 初始化:返回哨兵位指针(推荐方式)
LTNode* LTInit();
// 销毁:释放所有结点(需手动置空实参)
//(2)辅助操作
void LTDestroy(LTNode* phead);
// 打印:输出所有有效结点
void LTPrint(LTNode* phead);
// 判空:判断链表是否为空(仅含哨兵位)
bool LTEmpty(LTNode* phead);
//(3)增
// 插入操作
// 尾插:在链表尾部插入结点
void LTPushBack(LTNode* phead, LTDataType x);
// 头插:在链表头部(哨兵位后)插入结点
void LTPushFront(LTNode* phead, LTDataType x);
// 指定位置后插入:在pos节点后插入新结点
void LTInsert(LTNode* pos, LTDataType x);
//(4)删
// 删除操作
// 尾删:删除链表尾部结点
void LTPopBack(LTNode* phead);
// 头删:删除链表头部(哨兵位后)结点
void LTPopFront(LTNode* phead);
// 指定位置删除:删除pos结点
void LTErase(LTNode* pos);
//(5)改
void LTModify(LTNode* pos, LTNode* phead, LTDataType newVal);
//(6)查
// 查找:根据值查找结点,返回结点指针(未找到返回NULL)
LTNode* LTFind(LTNode* phead, LTDataType x);
三、核心函数实现
(一)辅助函数
1、创建单个结点(BuyNode)
所有插入操作都需要创建新节点,单独封装避免代码冗余:
cpp
// 辅助函数:创建一个新结点,初始化数据和指针
LTNode* BuyNode(LTDataType x)
{
// 1. 向操作系统申请节点大小的内存
LTNode* node = (LTNode*)malloc(sizeof(LTNode));
// 2. 检查内存申请是否成功(避免malloc失败导致野指针)
if (node == NULL) {
perror("malloc fail!"); // 打印错误信息(如内存不足)
exit(1); // 终止程序(非0表示异常退出)
}
// 3. 初始化节点的三个成员
node->data = x; // 数据域赋值
node->next = node; // 初始自指向(便于后续插入)
node->prev = node; // 初始自指向(形成闭环)
// 4. 返回新节点指针
return node;
}
2、判空操作(LTEmpty)
判断链表是否为空(仅含哨兵位),用于删除操作前的合法性校验;
cpp
bool LTEmpty(LTNode* phead)
{
assert(phead); // 断言:确保哨兵位不为空(避免传入NULL)
// 空链表标识:哨兵位的next指向自身(无有效节点)
return phead->next == phead;
}
**Tip:**如果链表为空就返回 1,非空就返回 0;用于删除操作的断言是 assert(!LTEmpty(phead));
3、打印操作(LTPrint)
遍历链表并打印所有有效结点,需跳过哨兵位,终止条件为 "回归哨兵位":
cpp
void LTPrint(LTNode* phead)
{
assert(phead); // 确保哨兵位有效
printf("哨兵位 -> ");
// 1. 从首元结点开始遍历(跳过哨兵位)
LTNode* pcur = phead->next;
// 2. 遍历终止条件:pcur回到哨兵位(所有有效结点已遍历)
while (pcur != phead) {
printf("%d -> ", pcur->data); // 打印当前结点值
pcur = pcur->next; // 指针后移
}
printf("哨兵位\n"); // 标识链表闭环
}
(二)初始化操作(LTInit)
初始化的核心是创建哨兵位结点,确保链表初始状态为 "空链表"(仅含哨兵位,自指向闭环):
cpp
// 初始化方式:返回哨兵位指针(推荐,无需传二级指针)
LTNode* LTInit() {
// 创建哨兵位结点,数据域赋值为-1(无效值,仅标识)
LTNode* phead = BuyNode(-1);
return phead; // 返回哨兵位指针,供外部使用
}
//在Test函数中调用这个方法
//初始化链表,获得哨兵位结点,然后就可以通过插入操作增加数据
LTNode* plist = LTInit();
(三)增
1、尾插(LTPushBack)
**(1)****核心逻辑:**在尾节点(哨兵位prev)后插入新节点,需修改 4 个指针:
①新结点的 prev 指向原尾结点;
②新结点的 next 指向哨兵位;
③ 原尾结点的 next 指向新结点;
④哨兵位的 prev 指向新结点。
Tip:一般就是先操作新结点 、再操作原尾结点 、最后操作哨兵位。【后面基本都是这个顺序】
(2)代码实现
cpp
void LTPushBack(LTNode* phead, LTDataType x) {
assert(phead); // 确保哨兵位有效(不能向空链表插入)
// 1. 创建新结点
LTNode* newnode = BuyNode(x);
// 2. 定位原尾结点(哨兵位的prev)
LTNode* tail = phead->prev;
// 3. 修改指针(顺序不影响,先处理新节点更安全)
newnode->prev = tail; // 新结点前驱 -> 原尾结点
newnode->next = phead; // 新结点后继 -> 哨兵位
tail->next = newnode; // 原尾结点后继 -> 新结点
phead->prev = newnode; // 哨兵位前驱 -> 新结点(新尾结点)
}
2、 头插(LTPushFront)
**(1)核心逻辑:**在哨兵位后插入新节点(成为首元节点),需修改 4 个指针:
①新结点的prev指向哨兵位;
②新结点的next指向原首元结点;
③ 原首元节结点的prev指向新结点;
④哨兵位的next指向新结点。
(2)代码实现
cpp
void LTPushFront(LTNode* phead, LTDataType x)
{
assert(phead); // 确保哨兵位有效
// 1. 创建新结点
LTNode* newnode = BuyNode(x);
// 2. 定位原首元结点(哨兵位的next)
LTNode* first = phead->next;
// 3. 修改指针
newnode->prev = phead; // 新结点前驱 -> 哨兵位
newnode->next = first; // 新结点后继 -> 原首元结点
first->prev = newnode; // 原首元结点前驱 -> 新结点
phead->next = newnode; // 哨兵位后继 -> 新结点(新首元结点)
}
3、指定位置后插入(LTInsert)
**(1)核心逻辑:**基于目标结点pos,在其后插入新结点,兼容尾插场景(pos为尾结点时自动衔接哨兵位)
(2)代码实现
cpp
void LTInsert(LTNode* pos, LTDataType x)
{
assert(pos); // 确保目标位置有效(不能为NULL)
// 1. 创建新结点
LTNode* newnode = BuyNode(x);
// 2. 定位pos的后继结点
LTNode* posNext = pos->next;
// 3. 修改指针
newnode->prev = pos; // 新结点前驱 -> pos
newnode->next = posNext; // 新结点后继 -> pos的原后继
posNext->prev = newnode; // pos原后继的前驱 -> 新结点
pos->next = newnode; // pos的后继 -> 新结点
}
Tip:一般先处理新结点 、再从右到左,处理 pos 原后续结点的前驱 与 pos的后继。
(3)应用场景: 结合LTFind函数**(查找)**,实现 "在值为 x 的节点后插入 y":
cpp
LTNode* find = LTFind(plist, 2); // 查找值为2的节点
if (find != NULL) {
LTInsert(find, 100); // 在2后插入100
}
(四)删
1、尾删(LTPopBack)
**(1)核心逻辑:**删除尾结点(哨兵位prev),需先判空,再修改 2 个指针,最后释放结点
(2)代码实现
cpp
void LTPopBack(LTNode* phead)
{
// 1. 合法性校验:哨兵位有效 + 链表非空
assert(phead);
assert(!LTEmpty(phead)); // 链表为空时不能删
// 2. 定位尾结点和尾结点的前驱
LTNode* tail = phead->prev; // 尾结点
LTNode* tailPrev = tail->prev; // 尾结点的前驱(新尾结点)
// 3. 修改指针:断开尾结点与链表的连接
tailPrev->next = phead; // 新尾结点的后继 -> 哨兵位
phead->prev = tailPrev; // 哨兵位的前驱 -> 新尾结点
// 4. 释放尾结点内存,避免内存泄漏
free(tail);
tail = NULL; // 置空,避免野指针
}
2、头删(LTPopFront)
(1)核心逻辑:删除首元结点(哨兵位 next),需先判空,再修改 2 个指针,最后释放结点。
(2)代码实现
cpp
void LTPopFront(LTNode* phead)
{
// 1. 合法性校验
assert(phead);
assert(!LTEmpty(phead));
// 2. 定位首元结点和首元结点的后继
LTNode* first = phead->next; // 首元结点(待删除)
LTNode* firstNext = first->next;// 首元结点的后继(新首元结点)
// 3. 修改指针:断开首元结点与链表的连接
firstNext->prev = phead; // 新首元结点的前驱 -> 哨兵位
phead->next = firstNext; // 哨兵位的后继 -> 新首元结点
// 4. 释放结点
free(first);
first = NULL;
}
3、指定位置删除(LTErase)
**(1)核心逻辑:**删除目标结点pos,需先判空,再修改pos前驱和后继的指针,最后释放pos。
(2)代码实现
cpp
void LTErase(LTNode* phead, LTNode* pos)
{
assert(phead && pos); // 哨兵位和pos都必须有效
assert(!LTEmpty(phead)); // 链表非空
assert(pos != phead); // pos不能是哨兵位
// 直接通过phead和pos操作
LTNode* posPrev = pos->prev;
LTNode* posNext = pos->next;
posPrev->next = posNext;
posNext->prev = posPrev;
free(pos);
}
(五)改
**1、核心逻辑:**从头结点开始遍历,找到要修改的结点,然后进行修改。
2、代码实现
cpp
void LTModify(LTNode* pos, LTNode* phead, LTDataType newVal)
{
//1、合法性验证
assert(pos != NULL && phead != NULL); // 禁止野指针,避免野指针访问
assert(pos != phead); // 禁止修改哨兵位,哨兵位不存储有效数据
assert(!LTEmpty(phead)); // 禁止空链表修改,空链表无有效节点可修改
//2、寻找要修改的结点
LTNode* pcur = phead->next;
while (pcur != phead) {
if (pcur == pos) {
break;
}
pcur = pcur->next;
}
//3、判断是否找到
if(pcur != pos)
{
printf("修改失败:pos节点不属于当前链表,无法修改!\n");
return;
}
//4、核心操作:修改pos节点的data域
pos->data = newVal;
printf("修改成功:节点值已更新为%d\n", newVal);
}
(六)查
**1、核心逻辑:**从首元结点开始线性遍历,匹配到值为 x 的结点则返回指针,未找到返回NULL。
2、代码实现
cpp
LTNode* LTFind(LTNode* phead, LTDataType x)
{
assert(phead); // 确保哨兵位有效
// 1. 从首元结点开始遍历(跳过哨兵位)
LTNode* pcur = phead->next;
// 2. 遍历终止条件:pcur回到哨兵位(所有结点已检查)
while (pcur != phead) {
if (pcur->data == x) {
return pcur; // 找到,返回节点指针
}
pcur = pcur->next; // 指针后移
}
return NULL; // 未找到,返回NULL
}
(七)销毁操作(LTDestroy)
1、核心逻辑:遍历释放所有有效结点,最后释放哨兵位,需注意一级指针传参需手动置空实参 ;虽然使用二级指针就可以避免这种情况,但是为了保证接口的一致性,我们还是使用一级指针。
2、代码实现
cpp
void LTDestroy(LTNode* phead)
{
assert(phead); // 确保哨兵位有效
// 1. 从首元结点开始遍历
LTNode* pcur = phead->next;
while (pcur != phead) {
// 先保存下一个结点地址(避免释放后找不到)
LTNode* next = pcur->next;
free(pcur); // 释放当前节点
pcur = next; // 指针后移
}
// 2. 释放哨兵位(最后释放,避免提前释放导致遍历错误)
free(phead);
phead = NULL; // 仅修改形参,实参需外部手动置空
}
这里需要澄清一个常见误区:
在传值调用中,函数的形参确实是实参的 "副本",因此对形参本身的修改(如重新赋值)不会影响外部实参。
但这并不意味着函数无法改变外部数据 ------ 通过形参(例如指针类型的形参)访问和操作其指向的内存空间时,这些修改会直接作用于原始数据,是真实且有效的。
3、调用示例
cpp
LTDestroy(plist); // 释放所有节点
plist = NULL; // 手动置空实参,避免野指针
四、完整测试用例(test.c)
cpp
#include"List.h"
// 测试初始化、插入、打印、删除、查找、修改、销毁
void test01()
{
// 1. 初始化链表(获取哨兵位指针)
LTNode* plist = LTInit();
printf("初始化后(空链表):");
LTPrint(plist); // 输出:哨兵位 -> 哨兵位
// 2. 尾插测试
LTPushBack(plist, 1);
LTPushBack(plist, 2);
LTPushBack(plist, 3);
LTPushBack(plist, 4);
printf("尾插1、2、3、4后:");
LTPrint(plist); // 输出:哨兵位 -> 1 -> 2 -> 3 -> 4 -> 哨兵位
// 3. 头插测试
LTPushFront(plist, 0);
printf("头插0后:");
LTPrint(plist); // 输出:哨兵位 -> 0 -> 1 -> 2 -> 3 -> 4 -> 哨兵位
// 4. 查找测试 + 指定位置后插入
LTNode* find = LTFind(plist, 2);
if (find != NULL) {
printf("找到值为2的节点,在其后插入100:");
LTInsert(find, 100); // 在2后插入100
LTPrint(plist); // 输出:哨兵位 -> 0 -> 1 -> 2 -> 100 -> 3 -> 4 -> 哨兵位
} else {
printf("未找到值为2的节点\n");
}
// 5. 修改测试:找到值为100的节点并修改为200
LTFind(plist, 100);
printf("修改后链表:");
LTPrint(plist); // 输出:哨兵位 -> 0 -> 1 -> 2 -> 200 -> 3 -> 4 -> 哨兵位
// 6. 尾删测试
LTPopBack(plist);
printf("尾删1次后:");
LTPrint(plist); // 输出:哨兵位 -> 0 -> 1 -> 2 -> 200 -> 3 -> 哨兵位
// 7. 头删测试
LTPopFront(plist);
printf("头删1次后:");
LTPrint(plist); // 输出:哨兵位 -> 1 -> 2 -> 200 -> 3 -> 哨兵位
// 8. 指定位置删除测试
LTNode* delNode = LTFind(plist, 2);
if (delNode != NULL) {
printf("删除值为2的节点后:");
LTErase(delNode); // 删除2
LTPrint(plist); // 输出:哨兵位 -> 1 -> 200 -> 3 -> 哨兵位
}
// 9. 销毁测试
LTDestroy(plist);
plist = NULL; // 手动置空
printf("销毁后,plist = %p\n", plist); // 输出:plist = 00000000
}
int main() {
test01();
return 0;
}
五、关键问题与解决方案
(一)为什么必须校验 pos 的合法性?
1、避免 NULL 指针
若 pos 为 NULL(如 LTFind 未找到目标节点),直接修改 pos->data 会导致程序崩溃。
2、禁止修改哨兵位
哨兵位是链表的 "标志结点",修改其 data 域无意义,还可能导致后续逻辑混乱(如误将哨兵位当作有效结点)。
(二)为什么插入 / 删除操作传一级指针即可?
1、因为不需要修改双向链表的哨兵位地址,插入 / 删除仅修改节点内部的next/prev指针,不会改变哨兵位的地址。
**2、**一级指针足以传递哨兵位地址,无需二级指针,二级指针仅用于修改实参的地址。
(三)如何避免野指针?
**1、malloc 后检查:**创建节点时必须检查malloc是否成功,避免返回NULL导致野指针。
**2、free 后置空:**释放节点后,必须将指针置空(如tail = NULL),避免后续误用。
**3、销毁后手动置空:**调用LTDestroy后,需手动将实参(如plist)置空,因为一级指针传参无法修改实参地址。
(四)为什么哨兵位数据域赋值为 - 1?
**1、**哨兵位不存储有效数据,赋值为 - 1 仅为了标识,也可赋值为其他无效值(如INT_MIN)。
**2、**核心是避免与有效数据冲突,若链表存储的值可能包含 -1,可改为其他值,如 0xCCCCCCCC
(五)双向链表的时间复杂度分析
|-----------------|---------------|--------------------|
| 操作 | 时间复杂度 | 原因 |
| 头插 / 头删、尾插 / 尾删 | O(1) | 直接通过哨兵位定位首尾节点,无需遍历 |
| 指定位置插入 / 删除 | O(1) | 已知节点位置,仅需修改指针 |
| 查找 | O(n) | 线性遍历所有节点 |
| 销毁 | O(n) | 需遍历释放所有节点 |
(六)工程应用场景
**1、编辑器撤销 / 重做功能:**用双向链表存储操作历史,支持向前(撤销)和向后(重做)遍历。
**2、浏览器历史记录:**记录用户访问的网页,支持 "前进""后退" 操作。
**3、双向队列实现:**基于带头双向循环链表,可高效实现队列的首尾操作。
**4、复杂数据结构底层:**如红黑树、哈希表的桶结构,可使用双向链表处理哈希冲突。
(七)总结
带头双向循环链表是功能最完善、操作最高效的链表结构,其核心优势在于:
**1、操作高效:**首尾操作和指定位置操作均为 O (1),远超单链表和顺序表。
**2、结构稳定:**哨兵位机制避免空指针异常,边界处理更简单。
**3、遍历灵活:**支持双向遍历,适应更多场景。
以上即为 一篇文章掌握"双向链表" 的全部内容,创作不易,麻烦三连支持一下呗~
