
引言
单链表是一种物理存储非连续、逻辑存储连续的数据结构,其数据元素通过节点间的指针链接维持逻辑顺序。相较于顺序表,单链表无需预先分配固定内存,插入、删除操作更灵活,是哈希桶、图的邻接表等复杂数据结构的基础组件。本文基于 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; // 保存原头节点的next
free(*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)
{
// 循环找prev
prev = prev->next;
}
newnode->next = pos; // 新节点连接pos
prev->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的原next
pos->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非头节点:找prev
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next; // prev连接pos的next
free(pos); // 释放pos
pos = 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连接待删除节点的next
free(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,避免野指针。
掌握这些操作后,可基于单链表实现通讯录、栈、队列等更复杂的功能,也为后续学习双向链表、循环链表奠定基础。