【数据结构】:链表的核心实现与操作解析
引言
单链表是一种物理存储非连续、逻辑存储连续的数据结构,其数据元素通过节点间的指针链接维持逻辑顺序。相较于顺序表,单链表无需预先分配固定内存,插入、删除操作更灵活,是哈希桶、图的邻接表等复杂数据结构的基础组件。本文基于 C 语言,系统解析单链表的初始化、节点申请、插入、删除、查找及销毁等核心操作,重点阐述每个函数的设计思路与实现逻辑。
文章目录
- 引言
- 一、单链表的基础定义与核心概念
- 二、单链表的核心操作实现
- 2.1 链表初始化(SLTInit)
- 2.2 节点申请(SLTBuyNode)
- 2.3 尾部插入(SLTPushBack)
- 2.4 头部插入(SLTPushFront)
- 2.5 尾部删除(SLTPopBack)
- 2.6 头部删除(SLTPopFront)
- 2.7 指定位置前插入(SLTInsert)
- 2.8 指定位置后插入(SLTInsertAfter)
- 2.9 删除指定节点(SLTErase)
- 2.10 删除指定节点后的节点(SLTEraseAfter)
- 2.11 链表打印(SLTPrint)
- 2.12 链表销毁(SListDesTroy)
- 三、总结
一、单链表的基础定义与核心概念
单链表的基本构成单元是 “节点”,每个节点包含 “数据域”(存储数据)与 “指针域”(存储下一个节点的地址)。为适配不同数据类型,先通过typedef
定义通用数据类型SLTDataType
,再定义节点结构体:
typedef int SLTDataType; // 可根据需求修改为char、float等
typedef struct SListNode
{SLTDataType data; // 节点数据域struct SListNode* next; // 指针域:指向 next 节点
} SLTNode;
后续所有操作均围绕该节点结构展开,核心设计原则是 “避免野指针”“不破坏链表逻辑连续性”,且根据是否修改头指针,决定使用一级指针或二级指针传参(修改头指针需传二级指针,否则传一级指针)。
二、单链表的核心操作实现
2.1 链表初始化(SLTInit)
初始化的目标是将链表的头指针置为NULL
,表示空链表。
注意:头指针是指向结构体的指针,它并非首个结构体本身,而是指向第一个结构体节点的指针。
- 链表未初始化时,头指针可能是随机值,直接使用会导致野指针问题。由于需修改头指针本身(从随机值改为
NULL
),需通过二级指针pphead
间接操作头指针(一级指针传参会导致形参副本修改,不影响实参)。 - 断言
pphead
不为空(防止传入空指针),将*pphead
(即头指针)赋值为NULL
,完成空链表初始化。
void SLTInit(SLTNode** pphead)
{assert(pphead); // 确保传入的指针有效*pphead = NULL; // 头指针置空,链表为空
}
2.2 节点申请(SLTBuyNode)
单链表插入操作需先申请新节点,该函数统一封装节点申请逻辑,避免重复代码。
- 节点需从堆区申请(
malloc
),申请可能失败(如内存不足),需检查返回值;申请成功后,需初始化数据域(赋值x
)与指针域(置NULL
,避免随机指向),最后返回新节点地址。 - 调用
malloc
申请节点大小的内存,perror
处理申请失败场景并退出程序;成功则赋值data
与next
,返回新节点指针。
SLTNode* SLTBuyNode(SLTDataType x)
{SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));if (newnode == NULL) { // 检查内存申请结果perror("malloc fail:");exit(1); // 异常退出,避免后续野指针操作}newnode->data = x; // 初始化数据域newnode->next = NULL; // 初始化指针域,新节点默认无后续节点return newnode;
}
2.3 尾部插入(SLTPushBack)
尾部插入是在链表末尾添加新节点,需区分 “空链表” 与 “非空链表” 两种场景。
- 空链表时,头指针为
NULL
,直接让头指针指向新节点即可;非空链表时,需先遍历找到最后一个节点(next
为NULL
的节点),再将其next
指向新节点。由于空链表场景需修改头指针,需传二级指针pphead
。 - 先申请新节点,断言
pphead
有效;若链表为空(*pphead == NULL
),头指针指向新节点;否则用临时变量ptail
遍历找到尾节点,将ptail->next
指向新节点。
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{assert(pphead);SLTNode* newnode = SLTBuyNode(x); // 申请新节点if (*pphead == NULL) { // 空链表:头指针直接指向新节点*pphead = newnode;} else { // 非空链表:找尾节点SLTNode* ptail = *pphead; // 临时指针遍历,不修改头指针while (ptail->next) { // 尾节点的next为NULL,循环终止时ptail是尾节点ptail = ptail->next;}ptail->next = newnode; // 尾节点连接新节点}
}
2.4 头部插入(SLTPushFront)
头部插入是在链表开头添加新节点,新节点成为新的头节点。
- 头部插入需让新节点的
next
指向原头节点,再让头指针指向新节点 —— 若先修改头指针,会丢失原链表地址。由于需修改头指针,需传二级指针pphead
。 - 申请新节点,新节点的
next
指向原头节点(*pphead
),再将头指针更新为新节点,完成头部插入。
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{assert(pphead);SLTNode* newnode = SLTBuyNode(x);newnode->next = *pphead; // 新节点连接原头节点*pphead = newnode; // 新节点成为新头节点
}
2.5 尾部删除(SLTPopBack)
尾部删除是移除链表最后一个节点,需区分 “仅一个节点” 与 “多个节点” 两种场景。
- 删除前需确保链表非空(断言
*pphead != NULL
);仅一个节点时,删除后需将头指针置NULL
(否则头指针指向已释放的节点,成为野指针);多个节点时,需找到倒数第二个节点,将其next
置NULL
,再释放最后一个节点。因需修改头指针(仅一个节点场景),传二级指针pphead
。 - 断言链表非空,若仅一个节点,直接释放头节点并置
NULL
;否则用prev
和ptail
两个指针遍历,prev
跟随ptail
,找到倒数第二个节点后,释放ptail
(尾节点),prev->next
置NULL
。
void SLTPopBack(SLTNode** pphead)
{assert(pphead && *pphead); // 断言链表非空if ((*pphead)->next == NULL) { // 仅一个节点:删除后链表为空free(*pphead);*pphead = NULL;} else { // 多个节点:找倒数第二个节点SLTNode* ptail = *pphead;SLTNode* prev = ptail;while (ptail->next) { // 循环终止时ptail是尾节点,prev是倒数第二个prev = ptail;ptail = ptail->next;}free(ptail); // 释放尾节点ptail = NULL; // 避免野指针prev->next = NULL; // 倒数第二个节点成为新尾节点}
}
2.6 头部删除(SLTPopFront)
头部删除是移除链表第一个节点,让头指针指向原第二个节点。
- 删除前需确保链表非空,先保存原头节点的
next
(避免释放后丢失后续链表),再释放原头节点,最后让头指针指向保存的next
。因需修改头指针,传二级指针pphead
。 - 断言链表非空,保存原头节点的
next
,释放原头节点,头指针更新为保存的next
。
void SLTPopFront(SLTNode** pphead)
{assert(pphead && *pphead); // 断言链表非空SLTNode* next = (*pphead)->next; // 保存原头节点的nextfree(*pphead); // 释放原头节点*pphead = next; // 新头节点为原第二个节点
}
2.7 指定位置前插入(SLTInsert)
在给定节点pos
前插入新节点,需区分pos
是否为头节点。
- 若
pos
是头节点,插入逻辑与头部插入一致,可直接调用SLTPushFront
(头插);若pos
非头节点,需找到pos
的前一个节点prev
,让prev->next
指向新节点,新节点的next
指向pos
。因可能修改头指针(pos
为头节点场景),传二级指针pphead
。 - 断言
pos
有效,判断pos
是否为头节点,是则头插;否则遍历找prev
(prev->next == pos
),连接新节点与pos
、prev
。
在插入之前,我们需要先定位目标位置指针pos。由于pos存储的是地址,而我们无法直接输入地址进行查找,因此在实际插入操作前,还需要一个专门的查找步骤来获取目标位置的地址。
//查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x) //仅查找不改第一节点
{assert(phead);SLTNode* pur = phead;while (pur){if (pur->data == x) return pur;pur = pur->next;}return NULL;
}
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{assert(pphead && *pphead && pos); // 断言参数有效if (pos == *pphead) { // pos是头节点:调用头插SLTPushFront(pphead, x);} else { // pos非头节点:找pos的前一个节点SLTNode* newnode = SLTBuyNode(x);SLTNode* prev = *pphead;while (prev->next != pos) { // 循环找prevprev = prev->next;}newnode->next = pos; // 新节点连接posprev->next = newnode; // prev连接新节点}
}
2.8 指定位置后插入(SLTInsertAfter)
在给定节点pos
后插入新节点,无需修改头指针,仅需操作pos
的next
。
pos
后插入无需头节点参与,也不修改头指针,故传一级指针pos即可。需先让新节点的next
指向pos
的next
(避免丢失pos
后的节点),再让pos
的next
指向新节点,确保顺序正确。- 断言
pos
有效,申请新节点,新节点next
指向pos->next
,pos->next
指向新节点。
这个与上面的在指定位置之前插入一样,要在插入之前找到节点位置
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{assert(pos); // 断言pos有效SLTNode* newnode = SLTBuyNode(x);newnode->next = pos->next; // 新节点连接pos的原nextpos->next = newnode; // pos连接新节点
}
2.9 删除指定节点(SLTErase)
删除给定节点pos
,需区分pos
是否为头节点。
- 若
pos
是头节点,删除逻辑与头部删除一致,调用SLTPopFront
;若pos
非头节点,需找到pos
的前一个节点prev
,让prev->next
指向pos->next
,再释放pos
。因可能修改头指针,传二级指针pphead
。 - 断言参数有效,判断
pos
是否为头节点,是则头删;否则找prev
,修改prev->next
,释放pos
并置NULL
。
void SLTErase(SLTNode** pphead, SLTNode* pos)
{assert(pphead && *pphead && pos); // 断言参数有效if (pos == *pphead) { // pos是头节点:调用头删SLTPopFront(pphead);} else { // pos非头节点:找prevSLTNode* prev = *pphead;while (prev->next != pos) {prev = prev->next;}prev->next = pos->next; // prev连接pos的nextfree(pos); // 释放pospos = NULL; // 避免野指针}
}
2.10 删除指定节点后的节点(SLTEraseAfter)
删除pos
后的节点,无需修改头指针,仅需操作pos
的next
。
- 需先确保
pos
后有节点(断言pos->next != NULL
),保存pos->next
(待删除节点),让pos->next
指向pos->next->next
,再释放保存的节点,避免丢失后续链表。 - 断言
pos
与pos->next
有效,保存待删除节点,修改pos->next
,释放待删除节点并置NULL
。
void SLTEraseAfter(SLTNode* pos) {assert(pos && pos->next); // 断言pos有效且后有节点SLTNode* next = pos->next; // 保存待删除节点pos->next = pos->next->next; // pos连接待删除节点的nextfree(next); // 释放待删除节点next = NULL; // 避免野指针
}
2.11 链表打印(SLTPrint)
遍历链表并打印所有节点数据,验证链表逻辑正确性,不修改链表内容。
- 打印无需修改头指针,故传一级指针
phead
;用临时变量pcur
遍历(避免修改头指针),循环打印pcur->data
,直至pcur
为NULL
(尾节点后),最后打印NULL
标识链表结束。 pcur
从phead
开始遍历,循环打印数据,pcur
更新为pcur->next
,结束时打印NULL
。
void SLTPrint(SLTNode* phead)
{SLTNode* pcur = phead; // 临时指针遍历while (pcur) {printf("%d -> ", pcur->data);pcur = pcur->next;}printf("NULL\n"); // 标识链表结束
}
2.12 链表销毁(SListDesTroy)
释放链表所有节点的内存,避免内存泄漏,最后将头指针置NULL
。
- 销毁需释放每个节点,可复用头部删除逻辑(每次删头节点,直至链表为空),因需修改头指针,传二级指针
pphead
。 - 循环调用
SLTPopFront
删除头节点,直至*pphead
为NULL
,所有节点释放完毕。
void SListDesTroy(SLTNode** pphead)
{assert(pphead && *pphead); // 断言链表非空while (*pphead) { // 循环删头节点,直至链表为空SLTPopFront(pphead);}
}
三、总结
单链表的核心操作围绕 “指针管理” 与 “边界场景处理” 展开,关键结论如下:
- 传参原则:需修改头指针(初始化、头插、头删、尾删、指定位置前插入、销毁)传二级指针,否则传一级指针;
- 边界场景:空链表、仅一个节点、指定位置为头 / 尾节点,需单独处理,避免野指针与链表断裂;
- 内存管理:节点从堆区申请后,必须在删除、销毁时free,且
free
后需置NULL
,避免野指针。
掌握这些操作后,可基于单链表实现通讯录、栈、队列等更复杂的功能,也为后续学习双向链表、循环链表奠定基础。