从零到一:深入理解与实现C语言单链表
单链表是数据结构中最基础、最重要的线性结构之一,它不仅是理解复杂数据结构(如树、图)的基石,更是面试和实际开发中的高频考点。与顺序表不同,单链表通过指针实现动态、非连续的内存管理,在插入、删除操作上展现出独特的优势。本文将带你从核心概念出发,手把手实现一个完整的单链表,并深入探讨其底层原理与关键细节。
一、单链表的核心:概念与结构
单链表(Singly Linked List)是一种线性数据结构,其逻辑结构是连续的,但物理存储是非连续的。每个数据元素(称为“结点”)包含两部分:存储数据的数据域和指向下一个结点地址的指针域。正是这个指针,像一根无形的线,将散落在内存各处的结点串联起来,形成逻辑上的序列。
理解单链表,首先要分清几个关键概念:
- 头指针:指向链表第一个结点的指针,是访问整个链表的入口。
- 头结点:一个不存储实际数据的结点,其存在可以简化某些操作(如统一插入/删除逻辑)。我们通常实现的是无头结点的链表。
- 尾结点:链表的最后一个结点,其指针域为
NULL,标志着链表的结束。
链表的结点在C语言中通过结构体定义,其精髓在于结构体中包含一个指向自身类型的指针,而非嵌套另一个结构体。这避免了“无限递归”的定义问题,也是链表能够动态扩展的关键。

结点结构体的定义如下,它清晰地展示了数据域和指针域的构成:
// 数据类型别名,便于后续修改存储类型
typedef int SLTDataType;
// 节点结构体定义
typedef struct SListNode {
SLTDataType data; // 数据域
struct SListNode* next; // 指针域,指向下一个节点
} SLTNode;
//typedef struct ListNode LTNode;也可以
这种“串珠子”式的结构(如下图所示),使得每个结点可以独立存在于内存的任何位置,通过指针“链接”在一起。

二、项目搭建与基础框架
一个清晰的工程结构是良好代码的开始。我们通常将单链表的实现分为三个文件:
- SList.h:头文件,用于声明结构体、数据类型别名和所有操作函数的原型。
- SList.c:源文件,具体实现所有链表操作函数的核心逻辑。
- test.c:测试文件,用于验证各个函数功能的正确性。
头文件SList.h的完整代码如下,它定义了链表的数据类型和基本操作接口:
#pragma once // 防止头文件重复包含
#include // 输入输出函数(如printf)
#include // 动态内存管理(如malloc、free)
#include // 断言(用于参数合法性检查)
// 1. 数据类型别名(泛型)
typedef int SLTDataType;
// 2. 节点结构体定义
typedef struct SListNode {
SLTDataType data;
struct SListNode* next;
} SLTNode;
// 3. 函数声明
// 打印链表
void SLTPrint(SLTNode* phead);
(1)增加数据
// 申请新结点(封装,避免重复代码)
SLTNode* SLTBuyNode(SLTDataType x);
// 尾部插入
void SLTPushBack(SLTNode** pphead, SLTDataType x);
// 头部插入
void SLTPushFront(SLTNode** pphead, SLTDataType x);
// 在指定结点pos之前插入
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);
// 在指定节点pos之后插入
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
(2)删除数据
// 尾部删除
void SLTPopBack(SLTNode** pphead);
// 头部删除
void SLTPopFront(SLTNode** pphead);
// 删除指定结点pos
void SLTErase(SLTNode** pphead, SLTNode* pos);
// 删除指定结点pos之后的结点
void SLTEraseAfter(SLTNode* pos);
(3)修改数据
//void SListModify(SLTNode* pos, int newData);
(4)查找数据
// 查找结点(返回值为目标结点指针,未找到返回NULL)
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);
// 销毁链表(释放所有节点内存)
void SListDestroy(SLTNode** pphead);
这里有一个关键点:为什么链表结点要使用结构体指针来定义,而不是直接使用结构体?主要原因有二:
- 避免无限递归:如果结构体内部包含另一个同类型结构体,编译器将无法计算其大小,导致定义失败。
- 支持动态内存管理:链表的核心是动态增删,这依赖于
malloc和free。使用指针可以方便地申请和释放独立的内存块,形成灵活的链式结构。
下面的错误示例直观地展示了为何不能使用结构体嵌套:
struct Node {
int data;
struct Node next; // 错误!结构体内部包含自身类型,会无限嵌套
};
struct Wrapper { // 包装一层
struct Node node;
};
struct Node {
int data;
struct Wrapper next; // 用Wrapper间接包含下一个Node
};
一个典型的测试文件结构如下,用于验证链表的初始化与基本功能:
#define _CRT_SECURE_NO_WARNINGS
#include"SList.h"
void test01()
{
// 无结点的单链表初始化,不需要额外的方法定义
// 这样赋值就可以了
SLTNode* plist = NULL;
//具体的方法调用
.........
}
int main()
{
test01();
return 0;
}
[AFFILIATE_SLOT_1]
三、核心操作实现:增删查改
对链表的操作无非“增删查改”四字。在实现这些操作前,一个用于调试的打印函数必不可少。
1. 遍历与打印
打印链表是验证其他操作是否正确的基础。通过一个简单的循环,从头指针开始,依次访问每个结点并打印其数据,直到遇到NULL。
void SLTPrint(SLTNode* phead)
{
SLTNode* pcur = phead; // 临时指针,避免修改原头指针
while (pcur != NULL) {
printf("%d -> ", pcur->data); // 打印当前节点数据
pcur = pcur->next; // 移动到下一个节点
}
printf("NULL\n"); // 打印链表结束标识
}
2. 增加数据(插入操作)
所有插入操作都需要先动态创建一个新结点。我们将其封装为一个辅助函数:
SLTNode* SLTBuyNode(SLTDataType x)
{
// 1. 向操作系统申请节点大小的内存(malloc返回void*,需强转)
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
// 2. 检查内存申请是否成功(内存不足时malloc返回NULL)
if (newnode == NULL) {
perror("malloc fail!"); // 打印错误原因(如“malloc fail!: Not enough space”)
exit(1); // 终止程序(非0退出码表示异常)
}
// 3. 初始化节点:数据域为x,指针域为NULL(避免野指针)
newnode->data = x;
newnode->next = NULL;
return newnode; // 返回新节点指针
}
尾部插入:需要考虑链表是否为空。如果为空,新结点就是头结点;如果不为空,则需要先找到尾结点,再将其next指向新结点。⚠️ 注意,这里需要传入二级指针,因为可能修改头指针本身。
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead); // 断言:二级指针必须有效(避免传入NULL)
SLTNode* newnode = SLTBuyNode(x); // 申请新节点
// 场景1:链表为空(头指针指向NULL)
if (*pphead == NULL) {
*pphead = newnode; // 新节点直接作为首节点
}
// 场景2:链表非空,先找到尾节点
else {
SLTNode* ptail = *pphead; // 从首节点开始找尾
// 尾节点特征:next为NULL,循环条件为“ptail->next != NULL”
while (ptail->next) {
ptail = ptail->next;
}
ptail->next = newnode; // 尾节点的next指向新节点
}
}
头部插入:操作最简单,效率最高(O(1))。将新结点的next指向原头结点,然后更新头指针指向新结点即可。
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead); // 断言:二级指针有效
SLTNode* newnode = SLTBuyNode(x); // 申请新节点
// 1. 新节点的next指向原首节点
newnode->next = *pphead;
// 2. 头指针指向新节点(新节点成为新首节点)
*pphead = newnode;
}
指定位置插入:分为在指定结点前插入和后插入。SLTInsert(前插)需要找到指定位置pos的前驱结点,因此逻辑稍复杂,需要处理pos是头结点的情况。SLTInsertAfter(后插)则简单许多,直接修改pos->next和新结点的指针即可。⚠️ 这里有一个经典易错点:指针修改的顺序不能颠倒,否则会丢失对后续结点的引用。
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
// 断言:二级指针有效 + pos节点有效(避免传入NULL)
assert(pphead && pos);
SLTNode* newnode = SLTBuyNode(x); // 申请新节点
// 场景1:pos是首节点(直接复用头插逻辑)
if (pos == *pphead) {
SLTPushFront(pphead, x);
}
// 场景2:pos是中间/尾节点,找pos的前驱节点
else {
//使用临时变量prev保护头结点
SLTNode* prev = *pphead; // 从首节点开始找前驱
// 循环条件:prev的next不是pos(找到前驱时退出)
while (prev->next != pos) {
prev = prev->next;
// 若pos不在链表中,循环会一直执行,需避免死循环(可选:添加断言)
assert(prev != NULL); // 若prev为NULL,说明pos无效
}
// 建立新链接:prev → newnode → pos
newnode->next = pos;
prev->next = newnode;
}
}
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos); // 断言:pos节点有效(避免传入NULL)
SLTNode* newnode = SLTBuyNode(x); // 申请新节点
// 建立新链接:pos → newnode → pos->next
newnode->next = pos->next;
pos->next = newnode;
}
3. 删除数据
删除操作的核心逻辑是:先维护链表结构的完整性,再释放内存。即先调整指针,让被删除结点的前驱和后继正确连接起来,然后再free掉该结点。
尾部删除:需要找到尾结点的前一个结点(前驱),将其next置为NULL,然后释放尾结点。需处理链表只有一个结点的情况。
void SLTPopBack(SLTNode** pphead)
{
// 断言:二级指针有效 + 链表非空(空链表无法删除)
assert(pphead && *pphead);
// 场景1:链表仅一个结点(首节点的next为NULL)
if ((*pphead)->next == NULL)
{
free(*pphead); // 释放首节点内存
*pphead = NULL; // 头指针置空(避免野指针)
}
// 场景2:链表多个结点,找尾结点及其前驱
else
{
// 定义前驱结点指针(初始为NULL);用于记录遍历到最后时,前一个结点的指针
SLTNode* prev = NULL;
// 尾结点指针(初始为首结点);用于从头结点开始遍历
SLTNode* ptail = *pphead;
// 遍历至尾结点(ptail->next为NULL时退出)
// 先让prev = ptail,然后ptail在往前走一步探路,
// 这时下一轮循环,如果ptail->next为空,则说明ptail为尾结点,
// 此时前去前驱结点的位置就找到了。
while (ptail->next) {
prev = ptail; // 先移动前驱指针
ptail = ptail->next; // 再移动尾结点指针
}
// 一定要先断开连接,再删除尾结点,即释放ptail
// 因为先释放 ptail 会导致 prev->next 短暂成为 “无效链接”,
// 而链表操作的核心原则是 “任何时刻都要保证链表结构的有效性”,
// 即指针要么指向有效节点,要么指向 NULL。
prev->next = NULL; // 前驱节点的next置空(断开与尾结点的链接)
free(ptail); // 释放尾结点内存
ptail = NULL; // 尾结点指针置空(避免野指针)
}
}
头部删除:保存原头结点的下一个结点地址,释放原头结点,然后更新头指针。
void SLTPopFront(SLTNode** pphead)
{
// 断言:二级指针有效 + 链表非空
assert(pphead && *pphead);
// 即使只有一个结点,也是可以满足的
SLTNode* next = (*pphead)->next; // 保存原首节点的下一个结点
free(*pphead); // 释放原首节点内存
*pphead = next; // 头指针指向新首结点
}
删除指定结点:同样需要区分待删除结点是否是头结点。如果是,则等同于头删;否则需要找到其前驱结点。
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
// 断言:二级指针和pos均有效
assert(pphead && pos);
// 情况1:pos是首结点,复用头删逻辑
if (pos == *pphead)
SLTPopFront(pphead);
// 情况2:pos是中间/尾结点,找pos的前驱
else
{
// 遍历找到pos的前驱结点
SLTNode* prev = *pphead;
while (prev->next != pos)
prev = prev->next;
// 前驱直接链接到pos的下一个节点,跳过pos
prev->next = pos->next;
free(pos); // 释放pos节点
pos = NULL; // 避免野指针
}
}
删除指定结点的后一个结点:操作相对简单,但需确保指定结点之后确实存在结点。
void SLTEraseAfter(SLTNode* pos)
{
// 断言:pos有效 + pos的下一个节点非空(无节点可删时断言失败)
assert(pos && pos->next);
SLTNode* del = pos->next; // 保存要删除的节点
pos->next = del->next; // pos直接链接到del的下一个节点
free(del); // 释放del节点内存
del = NULL; // 避免野指针
}
4. 查找与修改
查找操作遍历链表,比较数据域,返回第一个匹配结点的指针。修改操作则直接通过指针访问结点并赋值。
void SListModify(SLTNode* pos, int newData)
{
// 检查pos是否为无效指针(避免空指针访问)
if (pos == NULL) {
printf("错误:要修改的结点位置无效(空指针)\n");
return;
}
// 修改结点的数据域
pos->data = newData;
}
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
SLTNode* pcur = phead; // 临时指针遍历链表
while (pcur) { // 循环条件:当前结点不为空(未遍历完链表)
if (pcur->data == x) {
return pcur; // 找到目标节点,返回指针
}
pcur = pcur->next; // 未找到,移动到下一个节点
}
return NULL; // 遍历结束未找到,返回NULL
}
5. 销毁链表
为了避免内存泄漏,链表使用完毕后必须销毁。需要遍历链表,逐个free每个结点,最后将头指针置为NULL。同样需要传入二级指针。
void SListDestroy(SLTNode** pphead)
{
assert(pphead); // 断言:二级指针有效
SLTNode* pcur = *pphead; // 从首节点开始释放
//不停地头删
while (pcur) {
SLTNode* next = pcur->next; // 先保存下一个节点(避免释放后丢失)
free(pcur); // 释放当前节点内存
pcur = next; // 移动到下一个节点
}
*pphead = NULL; // 头指针置空(避免野指针,标识链表已销毁)
}
四、单链表 vs. 顺序表:如何选择?
单链表和顺序表(数组)是线性表的两种主要实现,各有优劣,选择取决于具体场景。
比维度 | 顺序表(底层为数组) | 单链表 |
存储空间 | 物理上一定连续(数组特性) | 物理上非连续,逻辑上连续 |
随机访问 | 支持; 通过下标访问,时间复杂度O(1) | 不支持; 需从首结点遍历,时间复杂度为O(n) |
插入 / 删除 | (1)中间 / 头部插入删除:需搬移数据,时间复杂度O(n); (2)尾部插入(无增容):O(1)。 | (1)头部 / 指定位置后插入删除:仅修改指针,时间复杂度O(1); (2)尾部删除:需找尾结点前驱,O(n)。 |
内存管理 | (1)动态增容:需申请新空间、拷贝数据、释放旧空间,有性能消耗; (2)增容后可能存在空间浪费(如容量 200 仅用 5 个数据)。 | (1)无需增容:按需申请 / 释放节点,无空间浪费; (2)每次插入需malloc,删除需free(内存操作频繁)。 |
应用场景 | 适合频繁访问数据; 如:数组遍历、随机查询 | 适合频繁插入 / 删除数据; 如:链表头部操作、中间插入 |
简单来说:需要频繁在头部或中间插入/删除,且数据量变化大时,选链表;需要频繁随机访问,且数据量相对稳定时,选顺序表。 在现代编程中,许多高级语言(如Python的list、Java的ArrayList、C++的vector、Go的slice)的底层都采用了类似顺序表的动态数组,因为它对缓存更友好。而链表的思想则在内核数据结构、LRU缓存实现、以及某些特定算法中发挥着不可替代的作用。
[AFFILIATE_SLOT_2]五、关键注意事项与深度理解
1. 野指针与内存管理
动态内存是C/C++编程的难点,也是链表的核心。野指针(指向已释放或未申请内存的指针)是万恶之源。必须遵守以下规则:
malloc后检查是否成功。free(p)后立即p = NULL。- 链表为空时,确保头指针为
NULL。
2. 理解free与NULL的本质
许多人对free操作有误解。free(ptr)并不擦除内存中的数据,也不改变ptr变量本身的值。它只是告诉操作系统:“这块内存我不用了,你可以把它收回去分配给其他人。”此时,ptr仍然指向原来的地址,但该地址的内容已“不受保护”,随时可能被覆盖。这就是野指针的危险所在。因此,ptr = NULL这一步至关重要,它明确切断了指针与那块已释放内存的联系。NULL在数值上通常是0,但在语义上它是一个明确的“空指针”标记。
3. 二级指针的使用场景
这是理解链表函数参数设计的关键。核心原则是:如果需要修改调用者手中的指针变量本身的值,就必须传递这个指针的地址(即二级指针)。
- ✅ 需要传二级指针:初始化空链表、在空链表中尾插、头插、头删、销毁链表。因为这些操作会改变头指针的指向。
- ✅ 传一级指针即可:在链表中间插入、删除、查找、修改。这些操作只修改链表内部的
next指针,不改变头指针。
理解这一点,不仅能写好链表,对理解其他复杂数据结构和指针传参都大有裨益。这种“传址调用”的思想在C++的引用、TypeScript/Java的对象传递中也有异曲同工之妙。

总结
单链表是数据结构学习的经典起点,它深刻地体现了指针的威力与动态内存管理的思想。掌握单链表,不仅仅是学会一套增删查改的API,更是理解如何用非连续的内存单元构建逻辑上的连续结构。从C语言的实现中领悟到的指针操作、边界条件处理和内存管理原则,是成为高级程序员的必备素养。无论你后续是学习更复杂的双向链表、循环链表,还是栈、队列、树、图,扎实的单链表基础都将让你事半功倍。希望本文能帮助你真正“掌握”单链表,而不仅仅是“知道”。
浙公网安备 33010602011771号