【数据结构】C语言实现单链表(附完整代码)
📌 前言
在前面的文章中,我们学习了顺序表,它虽然支持随机访问,但在头部和中间插入删除时需要移动大量元素,效率较低。那么有没有一种数据结构可以解决这个问题呢?
链表(Linked List) 应运而生!它通过“指针”将零散的内存块串联起来,插入删除不需要移动元素,只需修改指针指向即可。
本文将带你从零实现一个单链表(Singly Linked List),包含:
-
✅ 结点的定义与创建
-
✅ 头插、头删、尾插、尾删
-
✅ 任意位置插入/删除(前插/后插)
-
✅ 查找、打印、销毁
-
✅ 完整测试代码与运行结果
学完你将掌握:
链表的底层存储结构
二级指针的使用场景
链表的增删改查操作
顺序表与链表的差异与适用场景
一、单链表的基本概念
1.1 什么是链表
链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素之间的逻辑关系通过指针链接来实现。
通俗理解:
顺序表像一排连在一起的火车车厢(连续内存);
链表像用绳子串起来的珠子(分散的内存,通过“绳子”即指针连接)。
1.2 单链表的结构
每个结点(Node)包含两部分:
-
数据域:存储实际数据
-
指针域:存储下一个结点的地址
typedef struct SListNode {
SLTDataType data; // 数据域
struct SListNode* next; // 指针域,指向下一个结点
} SLTNode;
结构示意图:
[data|next] -> [data|next] -> [data|next] -> NULL 头结点 尾结点
-
头指针:指向第一个结点(链表入口)
-
尾结点:指针域为 NULL
二、代码实现(逐模块讲解)
2.1 头文件 SList.h
#pragma once
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
// 定义数据类型(方便切换)
typedef int SLTDataType;
// 定义结点结构
typedef struct SListNode {
SLTDataType data;
struct SListNode* next;
} SLTNode;
// 接口函数声明
void SLTPrint(SLTNode* phead); // 打印链表
void SLTPushBack(SLTNode** pphead, SLTDataType x); // 尾插
void SLTPushFront(SLTNode** pphead, SLTDataType x); // 头插
void SLTPopBack(SLTNode** pphead); // 尾删
void SLTPopFront(SLTNode** pphead); // 头删
SLTNode* SLTFind(SLTNode* phead, SLTDataType x); // 查找
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x); // 在pos之前插入
void SLTInsertAfter(SLTNode* pos, SLTDataType x); // 在pos之后插入
void SLTErase(SLTNode** pphead, SLTNode* pos); // 删除pos结点
void SLTEraseAfter(SLTNode* pos); // 删除pos之后的结点
void SListDestroy(SLTNode** pphead); // 销毁链表
⚠️ 为什么很多函数需要二级指针?
因为头插、尾删等操作可能修改头指针本身(比如链表为空时插入第一个结点,或者删除头结点)。
传递二级指针(SLTNode**)才能在函数内部修改外部的头指针。本人也曾在此踩过坑,希望大家以此为戒。
三、核心函数实现
3.1 创建新结点(辅助函数)
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;
}
📌
malloc失败时要检查返回值,并给出错误提示。
3.2 打印链表
void SLTPrint(SLTNode* phead) {
SLTNode* pcur = phead;
while (pcur) {
printf("%d -> ", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
输出示例:1 -> 2 -> 3 -> 4 -> NULL
3.3 尾插(在链表末尾插入)
void SLTPushBack(SLTNode** pphead, SLTDataType x) {
assert(pphead); // 确保二级指针不为空
SLTNode* newnode = SLTBuyNode(x);
// 情况1:链表为空,新结点直接成为头结点
if (*pphead == NULL) {
*pphead = newnode;
}
// 情况2:链表非空,找到尾结点
else {
SLTNode* pcur = *pphead;
while (pcur->next) { // 找到最后一个结点
pcur = pcur->next;
}
pcur->next = newnode;
}
}
时间复杂度:O(n)(需要遍历找到尾结点)
3.4 头插(在链表头部插入)
void SLTPushFront(SLTNode** pphead, SLTDataType x) {
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = *pphead; // 新结点指向原头结点
*pphead = newnode; // 头指针指向新结点
}
图解:
原链表:head -> [1] -> [2] -> NULL 头插0后:head -> [0] -> [1] -> [2] -> NULL
时间复杂度:O(1) ✅
3.5 尾删(删除最后一个结点)
void SLTPopBack(SLTNode** pphead) {
assert(pphead && *pphead); // 链表不能为空
// 情况1:只有一个结点
if ((*pphead)->next == NULL) {
free(*pphead);
*pphead = NULL;
}
// 情况2:有多个结点
else {
SLTNode* prev = NULL;
SLTNode* ptail = *pphead;
while (ptail->next) {
prev = ptail;
ptail = ptail->next;
}
prev->next = NULL;
free(ptail);
}
}
⚠️ 注意:需要记录尾结点的前一个结点,将其
next置为 NULL。
3.6 头删(删除第一个结点)
void SLTPopFront(SLTNode** pphead) {
assert(pphead && *pphead);
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
时间复杂度:O(1) ✅
3.7 查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x) {
SLTNode* pcur = phead;
while (pcur) {
if (pcur->data == x) {
return pcur; // 返回结点指针
}
pcur = pcur->next;
}
return NULL; // 未找到返回NULL
}
3.8 在指定结点之前插入
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) {
assert(pphead && pos);
// 情况1:pos就是头结点 → 调用头插
if (*pphead == pos) {
SLTPushFront(pphead, x);
}
// 情况2:pos不是头结点
else {
SLTNode* newnode = SLTBuyNode(x);
SLTNode* prev = *pphead;
while (prev->next != pos) { // 找到pos的前驱
prev = prev->next;
}
prev->next = newnode;
newnode->next = pos;
}
}
时间复杂度:O(n)(需要找到前驱结点)
3.9 在指定结点之后插入
void SLTInsertAfter(SLTNode* pos, SLTDataType x) {
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
时间复杂度:O(1) ✅
优势:不需要遍历,效率高!
3.10 删除指定结点
void SLTErase(SLTNode** pphead, SLTNode* pos) {
assert(pphead && pos);
// 情况1:pos是头结点 → 调用头删
if (*pphead == pos) {
SLTPopFront(pphead);
}
// 情况2:pos不是头结点
else {
SLTNode* prev = *pphead;
while (prev->next != pos) { // 找到pos的前驱
prev = prev->next;
}
prev->next = pos->next;
free(pos);
}
}
3.11 删除指定结点之后的结点
c
void SLTEraseAfter(SLTNode* pos) {
assert(pos && pos->next); // 确保pos和pos->next存在
SLTNode* del = pos->next;
pos->next = pos->next->next;
free(del);
}
时间复杂度:O(1) ✅
3.12 销毁链表
c
void SListDestroy(SLTNode** pphead) {
assert(pphead);
SLTNode* pcur = *pphead;
while (pcur) {
SLTNode* next = pcur->next; // 先保存下一个结点
free(pcur); // 释放当前结点
pcur = next; // 移动到下一个
}
*pphead = NULL; // 最后头指针置空
}
⚠️ 注意:一定要按顺序释放,避免丢失后续结点的地址。
四、完整测试代码及运行结果
4.1 测试程序(test.c)
#include "SList.h"
void test_tail_insert() {
printf("====== 测试尾插 ======\n");
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTPrint(plist); // 1 -> 2 -> 3 -> 4 -> NULL
}
void test_front_insert() {
printf("====== 测试头插 ======\n");
SLTNode* plist = NULL;
SLTPushFront(&plist, 1);
SLTPushFront(&plist, 2);
SLTPushFront(&plist, 3);
SLTPushFront(&plist, 4);
SLTPrint(plist); // 4 -> 3 -> 2 -> 1 -> NULL
}
void test_tail_delete() {
printf("====== 测试尾删 ======\n");
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTPrint(plist);
SLTPopBack(&plist); SLTPrint(plist); // 1 -> 2 -> 3 -> NULL
SLTPopBack(&plist); SLTPrint(plist); // 1 -> 2 -> NULL
SLTPopBack(&plist); SLTPrint(plist); // 1 -> NULL
SLTPopBack(&plist); SLTPrint(plist); // NULL
}
void test_insert_before() {
printf("====== 测试在结点之前插入 ======\n");
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTPrint(plist);
SLTNode* find = SLTFind(plist, 2);
SLTInsert(&plist, find, 100);
SLTPrint(plist); // 1 -> 100 -> 2 -> 3 -> 4 -> NULL
}
void test_insert_after() {
printf("====== 测试在结点之后插入 ======\n");
SLTNode* plist = NULL;
SLTPushBack(&plist, 1);
SLTPushBack(&plist, 2);
SLTPushBack(&plist, 3);
SLTPushBack(&plist, 4);
SLTPrint(plist);
SLTNode* find = SLTFind(plist, 2);
SLTInsertAfter(find, 100);
SLTPrint(plist); // 1 -> 2 -> 100 -> 3 -> 4 -> NULL
}
int main() {
test_tail_insert();
test_front_insert();
test_tail_delete();
test_insert_before();
test_insert_after();
return 0;
}
4.2 运行结果
====== 测试尾插 ====== 1 -> 2 -> 3 -> 4 -> NULL ====== 测试头插 ====== 4 -> 3 -> 2 -> 1 -> NULL ====== 测试尾删 ====== 1 -> 2 -> 3 -> 4 -> NULL 1 -> 2 -> 3 -> NULL 1 -> 2 -> NULL 1 -> NULL NULL ====== 测试在结点之前插入 ====== 1 -> 2 -> 3 -> 4 -> NULL 1 -> 100 -> 2 -> 3 -> 4 -> NULL ====== 测试在结点之后插入 ====== 1 -> 2 -> 3 -> 4 -> NULL 1 -> 2 -> 100 -> 3 -> 4 -> NULL
五、复杂度分析汇总
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 头插 | O(1) ✅ | 直接修改头指针 |
| 头删 | O(1) ✅ | 直接修改头指针 |
| 尾插 | O(n) | 需要遍历找到尾结点(可优化为尾指针) |
| 尾删 | O(n) | 需要遍历找到尾结点及其前驱 |
| 在pos之后插入 | O(1) ✅ | 直接修改指针 |
| 在pos之前插入 | O(n) | 需要遍历找到pos的前驱 |
| 删除pos之后的结点 | O(1) ✅ | 直接修改指针 |
| 删除pos结点 | O(n) | 需要遍历找到pos的前驱 |
| 查找 | O(n) | 可能需要遍历整个链表 |
| 销毁 | O(n) | 需要遍历释放所有结点 |
六、顺序表 vs 单链表
| 对比维度 | 顺序表 | 单链表 |
|---|---|---|
| 存储空间 | 连续内存 | 分散内存(不连续) |
| 随机访问 | O(1) ✅ | O(n) |
| 头插/头删 | O(n) | O(1) ✅ |
| 中间插入/删除 | O(n) | O(1)(如果已定位到位置) |
| 空间利用率 | 可能有空间浪费(扩容策略) | 每个结点有指针开销 |
| 缓存友好性 | 好(连续内存) | 差(指针跳跃) |
| 适用场景 | 频繁随机访问、增删少 | 频繁增删、随机访问少 |
选择建议:
-
需要频繁随机访问 → 选顺序表
-
需要频繁头插/头删/中间插入 → 选链表
七、常见问题与优化建议
7.1 为什么尾插效率低?
每次尾插都要遍历找到尾结点,时间复杂度 O(n)。
优化方案:增加一个尾指针,但需要维护多个指针,实现更复杂。
7.2 二级指针的困惑
很多初学者不理解为什么要有 SLTNode**:
// 错误写法(一级指针)
void wrong_push_front(SLTNode* phead, int x) {
SLTNode* newnode = buy_node(x);
newnode->next = phead;
phead = newnode; // ❌ 只修改了形参,不影响外部
}
// 正确写法(二级指针)
void right_push_front(SLTNode** pphead, int x) {
SLTNode* newnode = buy_node(x);
newnode->next = *pphead;
*pphead = newnode; // ✅ 修改了外部的头指针
}
函数传值:
传值:形参是实参的值的拷贝。
传地址:形参的改变影响实参。
记忆口诀:要修改谁,就传谁的地址。
7.3 内存泄漏问题
每次 malloc 后,最终都要 free,否则会导致内存泄漏。
我们的 SListDestroy 函数已经做了彻底清理。
八、总结
通过本文,我们学习了:
-
✅ 单链表的结点结构(数据域 + 指针域)
-
✅ 完整的增删改查操作(头尾插删、任意位置插删)
-
✅ 二级指针的使用场景(需要修改头指针时)
-
✅ 时间复杂度分析,理解链表的性能特点
-
✅ 与顺序表的对比,知道各自适用场景
链表是后续学习双向链表、循环链表、栈、队列的基础,掌握好单链表,后面的数据结构会越学越轻松!如果你有任何疑问或改进想法,欢迎在评论区留言讨论!
最后!如果这篇博客对你有帮助,欢迎 点赞、收藏、评论 三连!后续会持续更新 链表、栈、队列、树 等数据结构教程,敬请关注~
更多推荐

所有评论(0)