一、链表核心概念
1.1 链表概念
链表是一种线性数据结构 ,其存储单元在物理内存中非连续、非顺序 分布。数据元素之间的逻辑顺序通过节点间的指针链接实现。每个链表由一系列节点组成,节点可以动态创建和释放。
链表的核心特点:
**节点结构:**每个节点包含两部分
数据域:存储实际的数据元素
指针域:存储指向下一个节点的内存地址
**动态性:**节点可在程序运行时动态生成和删除
非连续性:节点在内存中的物理位置不一定相邻
操作复杂度:相比数组的顺序结构,链表在插入和删除操作上更高效,但随机访问效率较低
1.2 链表的分类
链表可以根据三个主要维度进行分类,这些维度的不同组合形成了八种不同的链表结构:
1.2.1 方向性
单向链表
每个节点仅包含一个指针,指向它的后继节点。这种结构简单,内存开销较小,但只能单向遍历。
双向链表
每个节点包含两个指针,分别指向前驱节点和后继节点。这种结构支持双向遍历,操作更灵活,但每个节点需要额外的内存空间存储前驱指针。
1.2.2 头节点设计
带头节点
链表包含一个特殊的头节点(哨兵节点),该节点不存储有效数据,仅用于简化操作逻辑。头节点的存在使得空链表和非空链表的操作可以统一处理,避免了许多边界条件的判断。
不带头节点
链表的第一个节点直接存储有效数据。这种设计更节省空间,但在处理空链表和插入删除头节点时需要特殊处理。
1.2.3 循环性
循环链表
尾节点的指针指向头节点(在带头节点的情况下)或第一个数据节点(在不带头节点的情况下),形成一个闭环结构。这种设计使得从任意节点出发都能遍历整个链表。
非循环链表
尾节点的指针为空(NULL),表示链表结束。这是最常见的链表形式,结构直观,操作简单。
1.3 链表与顺序表的对比
1.3.1 数组(顺序表)的局限性
int a[5] = {1, 2, 3, 4, 5};
// 需要添加元素6,7时,必须创建新数组
int b[7] = {1, 2, 3, 4, 5, 6, 7};
缺点:
大小固定,扩展需要重新分配内存
插入/删除元素需要移动大量数据
可能造成内存浪费(预留空间或频繁重分配)
1.3.2 链表的优势
动态内存管理: 只在需要时申请节点空间
高效增删: 插入和删除只需修改指针,时间复杂度O(1)(已知位置)
**空间利用率高:**按需分配,无预留空间
1.3.3 重要注意事项
链表在逻辑上是连续的,但在物理存储上不一定连续
节点通常从堆(heap)上动态申请
多次申请的内存空间可能连续,也可能不连续
二、无头单向非循环链表
2.1 数据结构定义
// 定义数据类型别名:将int类型重命名为SLTDataType
// 优点:便于统一修改链表存储的数据类型(例如改为float、char*等),提高代码可维护性
typedef int SLTDataType;
// 定义单链表节点结构体
typedef struct SListNode {
SLTDataType data; // 数据域:存储节点的实际数据
struct SListNode* next; // 指针域:指向下一个节点的地址(后继指针)
} SLTNode; // 结构体类型别名,简化类型声明(可直接使用SLTNode声明变量)
主要逻辑: 通过 typedef 创建了两个类型别名:SLTDataType(当前为 int 类型)用于表示节点存储的数据类型,以及 SLTNode 作为单链表节点的结构体别名。结构体内部包含两个成员:data 存储节点数据,next 是一个指向相同结构体类型的指针,用于链接下一个节点。这种设计形成了单向链式结构,每个节点只知道其后继节点,是单链表实现的基础。
2.2 基本操作实现
2.2.1 节点创建
// 函数:创建单链表新节点
// 参数:x - 要存储在新节点中的数据
// 返回值:指向新创建节点的指针
SLTNode* BuySListNode(SLTDataType x) {
// 动态分配内存,创建一个新的链表节点
// malloc函数分配sizeof(SLTNode)字节的内存空间
// 并将返回的void*指针强制转换为SLTNode*类型
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
// 检查内存分配是否成功
// 如果malloc返回NULL,表示内存分配失败
if (newnode == NULL) {
// 使用perror输出错误信息,"malloc failed"是自定义的错误提示
perror("malloc failed");
// 退出程序,-1表示异常退出
exit(-1);
}
// 内存分配成功,初始化新节点的数据域
// 将参数x的值赋给新节点的data成员
newnode->data = x;
// 初始化新节点的指针域
// 将next指针设为NULL,表示这是链表的最后一个节点(或当前唯一的节点)
newnode->next = NULL;
// 返回新创建节点的指针
return newnode;
}
**功能说明:**动态分配节点内存,初始化数据域和指针域。
主要逻辑: 函数首先通过malloc申请节点所需的内存空间,并检查分配是否成功------如果失败则报错并终止程序。分配成功后,将传入的数据值存储到节点的data域,同时将节点的next指针初始化为NULL(表示该节点暂时作为链表的末尾)。最后函数返回这个已初始化好的新节点指针,供后续链表操作使用。
2.2.2 尾插操作
// 函数:在单链表尾部插入新节点
// 参数:
// pphead - 指向链表头节点指针的指针(二级指针)
// 使用二级指针是因为可能需要修改头指针(当链表为空时)
// x - 要插入到新节点中的数据
// 返回值:无
void SLTPushBack(SLTNode** pphead, SLTDataType x) {
// 断言:确保传入的头指针地址不为NULL
// 这是因为我们需要通过二级指针修改一级指针的值
assert(pphead);
// 创建新节点:调用BuySListNode函数动态分配内存并初始化新节点
SLTNode* newnode = BuySListNode(x);
// 情况1:空链表(链表没有节点)
// 判断头指针指向的内容是否为NULL
if (*pphead == NULL) {
// 直接将新节点赋值给头指针
// 这里使用*pphead来修改调用者传递的头指针
*pphead = newnode;
}
// 情况2:非空链表(链表已有节点)
else {
// 定义一个遍历指针tail,从头节点开始
SLTNode* tail = *pphead;
// 遍历链表,找到最后一个节点(尾节点)
// 尾节点的特征是:next指针为NULL
while (tail->next != NULL) {
// 移动到下一个节点
tail = tail->next;
}
// 将新节点连接到原尾节点的next指针
// 此时新节点成为新的尾节点
tail->next = newnode;
}
}
主要逻辑: 首先通过断言确保传入的链表头指针地址有效,然后创建新节点。接着分两种情况处理:若链表为空(头指针为NULL),则直接将新节点设为头节点;若链表非空,则通过遍历找到当前尾节点(next为NULL的节点),最后将尾节点的next指针指向新节点,从而将新节点链接到链表末端。
2.2.3 头插操作
// 函数功能:在单链表头部插入新节点
// 参数pphead:指向头节点指针的指针(二级指针),用于修改链表头指针
// 参数x:要插入的新节点的数据值
void SLTPushFront(SLTNode** pphead, SLTDataType x) {
// 1. 断言检查:确保传入的二级指针pphead不为NULL
// 因为我们需要通过*pphead访问链表头指针
assert(pphead);
// 2. 创建新节点:调用BuySListNode函数动态分配内存并初始化新节点
SLTNode* newnode = BuySListNode(x);
// 3. 连接新节点:将新节点的next指针指向原来的头节点
// 这样新节点就成为了链表逻辑上的第一个节点
newnode->next = *pphead;
// 4. 更新头指针:将链表头指针修改为新创建的节点
// 通过二级指针pphead间接修改外部传入的头指针
*pphead = newnode;
}
主要逻辑: 函数首先对传入的二级指针进行有效性检查,然后调用辅助函数BuySListNode动态创建并初始化一个新节点。核心操作分为两步:先将新节点的next指针指向原链表的头节点,使新节点链接到原有链表之前;然后通过二级指针*pphead更新链表头指针,使其指向新节点,从而完成头插操作。
2.2.4 查找操作
// 函数功能:在单链表中查找包含指定数据值的节点
// 参数phead:链表头指针,指向链表的第一个节点
// 参数x:要查找的数据值
// 返回值:如果找到包含x的节点,则返回该节点的指针;如果未找到,则返回NULL
SLTNode* SLTFind(SLTNode* phead, SLTDataType x) {
// 1. 初始化遍历指针:使用临时指针cur从链表头部开始遍历
SLTNode* cur = phead;
// 2. 遍历链表:循环直到cur为NULL(到达链表末尾)
while (cur != NULL) {
// 3. 检查当前节点:比较当前节点的data值与目标值x
if (cur->data == x) {
// 4. 找到匹配:返回当前节点的指针
return cur;
}
// 5. 移动到下一个节点:更新cur指针指向下一个节点
cur = cur->next;
}
// 6. 未找到匹配:遍历完整个链表后返回NULL
return NULL;
}
**主要逻辑:**函数从头节点开始,使用临时指针依次访问每个节点,将每个节点的数据值与目标值进行比较。如果找到匹配的节点,则立即返回该节点的指针;如果遍历完整个链表(遇到NULL指针)仍未找到匹配项,则返回NULL表示查找失败。
2.2.5 指定位置插入
// 函数功能:在单链表的指定节点pos之前插入一个新节点
// 参数pphead:指向头指针的二级指针,用于修改链表头(当在头部插入时)
// 参数pos:要在其前面插入新节点的目标节点指针(不能为NULL)
// 参数x:要插入的新节点的数据值
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) {
// 1. 断言检查:确保目标节点pos不为NULL
// 因为不能在一个空节点前插入新节点
assert(pos);
// 2. 特殊情况处理:如果pos是链表的头节点
// 此时插入操作相当于头插,直接调用头插函数
if (pos == *pphead) {
SLTPushFront(pphead, x);
}
// 3. 一般情况处理:pos不是头节点
else {
// 3.1 查找前驱节点:从头节点开始遍历,找到pos节点的前一个节点
SLTNode* prev = *pphead;
while (prev->next != pos) { // 循环直到prev的下一个节点是pos
prev = prev->next; // 移动到下一个节点
}
// 3.2 创建新节点:调用BuySListNode函数创建并初始化新节点
SLTNode* newnode = BuySListNode(x);
// 3.3 插入新节点:
// a. 将前驱节点prev的next指向新节点
prev->next = newnode;
// b. 将新节点的next指向原pos节点
newnode->next = pos;
}
}
主要逻辑: 首先通过断言确保目标节点pos的有效性。如果是头节点插入的特殊情况(pos等于头指针),则直接调用现有的头插函数SLTPushFront。对于一般情况,函数需要通过遍历链表找到目标节点pos的前驱节点prev,然后执行三步操作:创建新节点、将前驱节点prev的next指向新节点、将新节点的next指向pos节点。
2.2.6 链表销毁
// 函数功能:销毁整个单链表,释放所有节点内存
// 参数pphead:指向头指针的二级指针,用于修改链表头指针(置为NULL)
void SListDestroy(SLTNode** pphead) {
// 1. 断言检查:确保传入的二级指针pphead不为NULL
// 因为我们需要通过*pphead访问链表头指针
assert(pphead);
// 2. 初始化遍历指针:从链表头节点开始
SLTNode* cur = *pphead;
// 3. 遍历并释放所有节点
while (cur != NULL) {
// 3.1 保存下一个节点:在释放当前节点前,先保存其next指针
// 否则释放后无法访问下一个节点
SLTNode* next = cur->next;
// 3.2 释放当前节点:释放cur指向的节点内存
free(cur);
// 3.3 移动到下一个节点:继续处理已保存的下一个节点
cur = next;
}
// 4. 头指针置空:将外部传入的头指针设置为NULL
// 防止成为野指针,表示链表已空
*pphead = NULL;
}
主要逻辑: 函数首先检查二级指针参数的有效性,然后通过一个临时指针cur遍历链表。在遍历过程中,关键步骤是先保存当前节点的下一个节点地址,再释放当前节点,最后移动到下一个节点继续处理,这样可以避免释放节点后无法访问后续链表。遍历完成后,通过二级指针将外部头指针置为NULL,防止出现野指针,并明确表示链表已被完全销毁。
三、带头双向循环链表
3.1 数据结构定义
// 定义数据类型别名:将int重命名为DataType
// 优点:便于统一修改链表存储的数据类型(如改为float、char*等)
typedef int DataType;
// 定义双向链表节点结构体
typedef struct ListNode {
struct ListNode* next; // 后继指针:指向下一个节点的地址
struct ListNode* prev; // 前驱指针:指向前一个节点的地址
DataType data; // 数据域:存储节点实际数据
} LTNode; // 结构体类型别名,简化类型声明(可直接使用LTNode声明变量)
主要逻辑: 通过typedef创建了DataType类型别名(当前为int类型)和LTNode结构体别名。结构体内包含三个成员:两个自引用指针(next指向后继节点,prev指向前驱节点)和一个数据域data。这种设计使得每个节点既能向前访问也能向后访问,形成了双向链接关系,为双向链表的实现提供了基础结构。
3.2 核心操作实现
3.2.1 初始化创建头节点
// 函数功能:初始化一个带头节点的双向循环链表
// 返回值:返回创建的头节点指针(链表唯一固定节点)
LTNode* LTInit() {
// 1. 创建头节点:调用BuyListNode函数分配内存并创建节点
// 参数0作为头节点的数据(头节点数据域通常不存储有效业务数据,仅作占位)
LTNode* phead = BuyListNode(0);
// 2. 初始化循环结构:将头节点的前驱和后继指针都指向自己
// a. 头节点的next指针指向自己,表示链表为空时"下一个节点"是自己
// b. 头节点的prev指针也指向自己,表示链表为空时"前一个节点"是自己
phead->next = phead;
phead->prev = phead;
// 3. 返回头节点指针
return phead;
}
主要逻辑: 首先创建一个特殊的头节点(数据域通常存储无意义的值,这里用0占位),然后将该节点的next和prev指针都指向自身,形成一个自循环结构。这种设计使得空链表表现为一个头节点自己指向自己的闭环,后续节点可以统一插入到头节点之后,简化了双向链表的操作逻辑。头节点的存在使得链表操作时不需要特殊处理空链表情况,同时提供了固定的访问入口,而循环结构则使得从任意节点出发都能遍历整个链表。
3.2.2 任意位置插入
// 函数功能:在双向循环链表的pos位置之前插入新节点
// 参数pos:要在其之前插入新节点的目标节点指针(不能为NULL)
// 参数x:要插入的新节点的数据值
void LTInsert(LTNode* pos, DataType x) {
// 1. 断言检查:确保目标节点pos不为NULL
// 因为不能在一个空节点前插入新节点
assert(pos);
// 2. 创建新节点:调用BuyListNode函数动态分配内存并初始化新节点
LTNode* newnode = BuyListNode(x);
// 3. 获取pos节点的前驱节点:pos->prev指向pos的前一个节点
LTNode* prev = pos->prev;
// 4. 四步连接操作,将新节点插入到prev和pos之间:
// a. 前驱节点prev的后继指针指向新节点
prev->next = newnode;
// b. 新节点的前驱指针指向前驱节点prev
newnode->prev = prev;
// c. 新节点的后继指针指向pos节点
newnode->next = pos;
// d. pos节点的前驱指针指向新节点
pos->prev = newnode;
}
**主要逻辑:**函数首先检查目标位置pos的有效性,然后创建新节点并获取pos的前驱节点prev。通过四个指针操作步骤将新节点插入到prev和pos之间:先将prev的next指向新节点,再将新节点的prev指向prev;然后将新节点的next指向pos,最后将pos的prev指向新节点。
3.2.3 删除操作
// 函数功能:删除双向循环链表中指定位置pos的节点
// 参数pos:要删除的目标节点指针(不能为NULL)
void LTErase(LTNode* pos) {
// 1. 断言检查:确保要删除的节点pos不为NULL
assert(pos);
// 2. 连接前后节点:将pos的前驱节点和后继节点直接连接
// a. pos前驱节点的next指针指向pos的后继节点
// 使前驱节点跳过pos,直接连接到后继节点
pos->prev->next = pos->next;
// b. pos后继节点的prev指针指向pos的前驱节点
// 使后继节点跳过pos,直接连接到前驱节点
pos->next->prev = pos->prev;
// 3. 释放节点内存:删除pos节点,释放其占用的内存空间
free(pos);
}
**主要逻辑:**函数首先验证目标节点pos的有效性,然后执行两步关键指针操作:将pos前驱节点的next指针指向pos的后继节点,同时将pos后继节点的prev指针指向pos的前驱节点。这样就在链表中"跳过"了pos节点,使前后节点直接相连。最后释放pos节点的内存完成删除。由于双向循环链表的特性,这种删除操作不需要特殊处理边界条件(如头尾节点),因为所有节点都有前驱和后继(即使是头节点在循环链表中也有前驱)。但需要注意:实际使用中通常不应删除头节点(哨兵节点),否则会破坏链表结构。
3.2.4 链表遍历
// 函数功能:打印带头节点的双向循环链表的内容
// 参数phead:链表的头节点指针(不能为NULL,且链表是带头节点的双向循环链表)
void LTPrint(LTNode* phead) {
// 1. 断言检查:确保头节点指针phead不为NULL
// 因为需要从头节点开始遍历链表
assert(phead);
// 2. 初始化遍历指针:从第一个实际存储数据的节点开始(即头节点的下一个节点)
// 注意:头节点本身不存储有效数据(或存储的数据通常不打印)
LTNode* cur = phead->next;
// 3. 打印提示信息
printf("链表内容: ");
// 4. 遍历链表:当cur指针回到头节点phead时,说明已经遍历完一圈
// 注意:双向循环链表的遍历终止条件是回到头节点(即所有节点遍历一次)
while (cur != phead) {
// 5. 打印当前节点的数据
printf("%d ", cur->data);
// 6. 移动到下一个节点
cur = cur->next;
}
// 7. 打印换行,使输出更清晰
printf("\n");
}
**主要逻辑:**从第一个实际数据节点(头节点的后一个节点)开始遍历,直到回到头节点为止。在遍历过程中,打印每个节点的数据值。由于使用了循环链表结构,遍历的终止条件是当前节点等于头节点,这意味着已经遍历完所有有效数据节点(不包括头节点本身)。