单链表是数据结构中最基础也是最重要的线性表结构之一,相比顺序表(数组),它的优势在于动态内存分配,无需预先开辟连续的内存空间,插入和删除操作也更灵活。本文将从单链表的基本概念出发,一步步实现单链表的增删改查等核心功能,帮助初学者彻底掌握单链表的底层逻辑。
在项目中创建了以下三个文件:
(1) SList.h(头文件)
(2) SList.c(实现文件)
(3) test.c(测试文件,可取消注释测试不同功能)
一、单链表的核心概念
1.1 单链表的结构
单链表由一个个节点(Node) 串联而成,每个节点包含两部分:
- 数据域:存储节点的实际数据(如 int、char 等);
- 指针域:存储下一个节点的地址,通过指针将节点连接起来。
最后一个节点的指针域指向NULL,表示链表结束。此外,我们通常用一个头指针指向链表的第一个节点,以此访问整个链表。
用 C 语言定义单链表节点的结构体:
cpp
// 定义链表节点存储的数据类型(可灵活修改)
typedef int SLDataType;
// 单链表节点结构体
typedef struct SListNode {
SLDataType data; // 数据域
struct SListNode* next;// 指针域,指向后一个节点
} SLTNode;
1.2 单链表的特点
- 物理存储上非连续、非顺序,逻辑上连续;
- 只能从头指针开始,顺着指针域遍历链表,无法反向访问;
- 插入 / 删除操作无需移动大量数据,只需修改指针指向,效率高;
- 随机访问效率低(无法像数组一样通过下标直接访问)。
二、单链表的基础功能实现
我们先搭建单链表的核心函数框架(头文件SList.h),再逐一实现功能(源文件SList.c),最后通过测试文件test.c验证功能。
2.1 头文件声明(SList.h)
头文件主要负责结构体、函数声明,以及引入必要的库(如stdio.h、stdlib.h、assert.h):
cpp
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
typedef int SLDataType;
typedef struct SListNode {
SLDataType data;
struct SListNode* next;
} SLTNode;
// 链表打印
void SLTPrint(SLTNode* head);
// 新建节点
SLTNode* SLTBuyNode(SLDataType x);
// 尾插(链表尾部添加节点)
void SLTPushBack(SLTNode** pphead, SLDataType x);
// 头插(链表头部添加节点)
void SLTPushFront(SLTNode** pphead, SLDataType x);
// 尾删(链表尾部删除节点)
void SLTPopBack(SLTNode** pphead);
// 头删(链表头部删除节点)
void SLTPopFront(SLTNode** pphead);
// 查找指定值的节点
SLTNode* SLTFind(SLTNode* head, SLDataType x);
// 在指定位置前插入节点
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLDataType x);
// 在指定位置后插入节点
void SLTInsertAfter(SLTNode* pos, SLDataType x);
// 删除指定位置的节点
void SLTErase(SLTNode** pphead, SLTNode* pos);
// 删除指定位置后的节点
void SLTEraseAfter(SLTNode* pos);
// 销毁链表
void SListDestroy(SLTNode** pphead);
2.2 核心工具函数:新建节点
所有插入操作都需要先创建新节点,因此封装一个SLTBuyNode函数,负责申请内存、初始化节点:
cpp
#include"SList.h"
// 新建节点
SLTNode* SLTBuyNode(SLDataType x)
{
// 申请节点内存
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
// 检查内存申请是否成功
if (newnode == NULL)
{
perror("malloc error"); // 打印错误信息
exit(1); // 终止程序
}
// 初始化节点
newnode->data = x;
newnode->next = NULL;
return newnode;
}
2.3 基础功能 1:打印链表
遍历链表,逐个打印节点数据,直到遇到NULL:
cpp
// 打印链表
void SLTPrint(SLTNode* head)
{
SLTNode* pcur = head; // 遍历指针,从头节点开始
while (pcur != NULL)
{
printf("%d->", pcur->data);
pcur = pcur->next; // 移动到下一个节点
}
printf("NULL\n"); // 链表结束标识
}
三、单链表的增删操作
增删是单链表的核心操作,需重点关注空链表 、只有一个节点等边界情况,以及指针的修改逻辑。
3.1 尾部插入(尾插)
思路:
- 若链表为空(头指针
*pphead为NULL),直接让头指针指向新节点; - 若链表非空,先遍历到最后一个节点(指针域为
NULL的节点),再让最后一个节点的指针域指向新节点。
cpp
// 尾插
void SLTPushBack(SLTNode** pphead, SLDataType x)
{
assert(pphead); // 确保头指针的地址非空
SLTNode* newnode = SLTBuyNode(x);
// 空链表
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
// 遍历到尾节点
SLTNode* ptail = *pphead;
while (ptail->next != NULL)
{
ptail = ptail->next;
}
ptail->next = newnode; // 尾节点指向新节点
}
}
3.2为什么函数参数要用 SLTNode**(二级指针)?
1 因为要修改链表的头节点(比如头插、头删、尾删最后一个节点时),如果只传 SLTNode*(一级指针),函数内修改的只是副本,无法改变外部的头节点。
2 简单说:只要需要修改头节点,就传二级指针;只读取链表,传一级指针即可 (比如 SLTPrint、SLTFind)
3.3头部插入(头插)
思路:无需遍历,直接让新节点的指针域指向原头节点,再将头指针指向新节点(无论链表是否为空)。
cpp
// 头插
void SLTPushFront(SLTNode** pphead, SLDataType x)
{
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = *pphead; // 新节点指向原头节点
*pphead = newnode; // 头指针指向新节点
}
3.4 尾部删除(尾删)
思路:
- 先校验:链表不能为空(
*pphead != NULL); - 若链表只有一个节点:释放该节点,头指针置
NULL; - 若链表有多个节点:遍历到倒数第二个节点,释放最后一个节点,将倒数第二个节点的指针域置
NULL。
cpp
// 尾删
void SLTPopBack(SLTNode** pphead)
{
assert(pphead && *pphead); // 链表不能为空
// 只有一个节点
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* prev = *pphead;
SLTNode* ptail = *pphead;
// 遍历到尾节点,prev记录倒数第二个节点
while (ptail->next != NULL)
{
prev = ptail;
ptail = ptail->next;
}
free(ptail); // 释放尾节点
ptail = NULL;
prev->next = NULL; // 倒数第二个节点变为尾节点
}
}
3.5 头部删除(头删)
思路:
- 校验链表非空;
- 记录原头节点的下一个节点地址;
- 释放原头节点,头指针指向记录的节点。
cpp
// 头删
void SLTPopFront(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* next = (*pphead)->next; // 记录第二个节点
free(*pphead); // 释放头节点
*pphead = next; // 头指针指向第二个节点
}
四、单链表的进阶操作
4.1 查找指定节点
遍历链表,找到数据域等于目标值的节点,返回该节点地址;若未找到,返回NULL。
cpp
// 查找指定值的节点
SLTNode* SLTFind(SLTNode* head, SLDataType x)
{
SLTNode* pcur = head;
while (pcur != NULL)
{
if (pcur->data == x)
{
return pcur; // 找到,返回节点地址
}
pcur = pcur->next;
}
return NULL; // 未找到
}
4.2 指定位置前插入节点
思路:
- 若插入位置是头节点,直接复用头插函数;
- 若插入位置是中间节点,先遍历到插入位置的前一个节点,再修改指针指向。
cpp
// 在pos节点前插入x
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLDataType x)
{
assert(pphead && pos && *pphead);
// 插入位置是头节点,复用头插
if (pos == *pphead)
{
SLTPushFront(pphead, x);
return;
}
// 找到pos的前一个节点prev
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
// 新建节点,修改指针
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos;
prev->next = newnode;
}
4.3 指定位置后插入节点
相比 "前插入","后插入" 更简单:无需找前一个节点,直接让新节点指向pos的下一个节点,再让pos指向新节点。
cpp
// 在pos节点后插入x
void SLTInsertAfter(SLTNode* pos, SLDataType x)
{
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
4.4 删除指定位置节点
思路:
- 若删除的是头节点,复用头删函数;
- 若删除的是中间节点,先找到前一个节点,让前一个节点指向
pos的下一个节点,再释放pos。
cpp
// 删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead && pos && *pphead);
// 删除头节点,复用头删
if (pos == *pphead)
{
SLTPopFront(pphead);
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
4.5 删除指定位置后的节点
无需找前一个节点,直接释放pos的下一个节点,再让pos指向该节点的下下个节点。
cpp
// 删除pos后的节点
void SLTEraseAfter(SLTNode* pos)
{
assert(pos && pos->next); // pos不能是尾节点
SLTNode* del = pos->next;
pos->next = pos->next->next;
free(del);
del = NULL;
}
4.6 销毁链表
遍历链表,逐个释放节点,最后将头指针置NULL(避免野指针)。
cpp
// 销毁链表
void SListDestroy(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* pcur = *pphead;
while (pcur != NULL)
{
SLTNode* pnext = pcur->next; // 先记录下一个节点
free(pcur); // 释放当前节点
pcur = pnext; // 移动到下一个节点
}
*pphead = NULL; // 头指针置空
}
五、测试代码(test.c)
编写测试函数,验证所有功能的正确性:
cpp
#include "SList.h"
void SListTest02()
{
// 初始化空链表
SLTNode* plist = NULL;
// 测试尾插
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
printf("尾插后:");
SLTPrint(plist); // 输出:1->2->3->4->NULL
// 测试查找
SLTNode* find = SLTFind(plist, 3);
SLTNode* find2 = SLTFind(plist, 4);
// 测试指定位置前插入
SLTInsert(&plist, find, 33);
printf("在3前插入33后:");
SLTPrint(plist); // 输出:1->2->33->3->4->NULL
// 测试指定位置后插入
SLTInsertAfter(find, 44);
printf("在3后插入44后:");
SLTPrint(plist); // 输出:1->2->33->3->44->4->NULL
// 测试删除指定节点
SLTErase(&plist, find);
printf("删除节点3后:");
SLTPrint(plist); // 输出:1->2->33->44->4->NULL
// 测试删除指定位置后节点
SLTEraseAfter(find2);
printf("删除4后节点后:");
SLTPrint(plist); // 输出:1->2->33->44->NULL
// 测试销毁链表
SListDestroy(&plist);
printf("销毁后:");
SLTPrint(plist); // 输出:NULL
}
int main()
{
SListTest02();
return 0;
}
这个测试结果:单链表功能成功实现但存在指针失效 和断言检查失败, SLTEraseAfter(find2); 中,find2 最初指向值为 4 的节点,但链表修改后,4 的节点已经是尾节点(pos->next == NULL),删除尾节点的 "后节点" 会触发断言。
六、总结与注意事项
6.1 核心要点
- 单链表的节点由数据域 + 指针域组成,头指针是访问链表的入口;
- 增删操作的核心是修改指针指向,需重点处理空链表、单节点链表等边界情况;
- 涉及修改头指针的操作(如头插、尾插、头删、尾删),参数必须传
SLTNode**(头指针的地址); - 内存管理:新建节点用
malloc,删除 / 销毁节点必须用free,避免内存泄漏; - 断言(
assert)的使用:校验指针非空,提前暴露错误,提升代码健壮性。
6.2 初学者常见错误
- 忘记处理空链表,导致访问
NULL指针; - 传参时用
SLTNode*而非SLTNode**,修改头指针失败; - 释放节点后未将指针置
NULL,导致野指针; - 遍历链表时,直接修改头指针,导致链表丢失。