一、为什么需要单向链表?------ 解决顺序存储的痛点
在学习单向链表前,我们首先要明白:链式存储是为了解决顺序存储的固有缺陷而设计的。
顺序存储(如数组)虽然实现简单,但存在三个致命问题:
- 插入 / 删除效率低:若在数组中间插入或删除元素,需要移动后续所有元素,时间复杂度为 O (n);
- 动态扩容难题:数组初始化时需指定固定大小,扩容时需重新申请更大内存并拷贝数据,不仅麻烦还会浪费资源;
- 内存利用率低:顺序存储要求内存连续,即使内存总空闲空间足够,若没有连续块也无法存储数据。
而单向链表通过 "离散存储 + 指针关联" 的方式,完美解决了这些问题 ------ 它不需要连续内存,插入 / 删除只需调整指针,且能动态适应数据量变化。
二、单向链表的核心原理:节点与逻辑结构
1. 链式存储的核心特点
线性表链式存储的本质是:用一组任意的存储单元(连续或离散均可)存储数据元素,通过 "指针" 维系元素间的逻辑顺序。
与顺序存储仅需存储 "数据本身" 不同,链式存储中每个元素需要存储两部分信息:
- 数据域(Data Field):存储元素本身的信息(如学生的姓名、年龄、成绩);
- 指针域(Pointer Field):存储直接后继元素的内存地址,确保元素间的逻辑关联。
这两部分共同构成一个 "节点(Node)",节点是单向链表的基本存储单元。
2. 单向链表的结构组成
单向链表由 "头指针" 和 "节点链" 两部分组成:
- 头指针(Head Pointer) :指向链表的第一个节点,是访问整个链表的入口;若链表为空,头指针为
NULL; - 节点链 :每个节点的指针域指向后继节点,最后一个节点的指针域为
NULL(表示链表结尾)。
为了方便管理,我们通常会设计一个 "链表头结构体",包含头指针和链表长度(避免每次统计长度都遍历链表),结构如下:
// 链表头结构体:管理整个链表
typedef struct list
{
LinkNode *head; // 头指针:指向第一个节点
int clen; // 链表长度:记录有效节点数
} LinkList;
三、C 语言实现:从结构体定义到完整接口
接下来,我们基于 "学生信息管理" 场景,实现单向链表的完整功能。首先定义数据类型和节点结构体,再逐一实现创建、插入、查找、修改、删除、销毁等核心接口。
1. 头文件定义(linklist.h)
首先在头文件中定义数据结构、函数指针(用于灵活查找)和接口声明,确保代码模块化。
#ifndef _LINKLIST_H_
#define _LINKLIST_H_
#include <string.h>
// 1. 定义数据域类型:存储学生信息(可根据需求修改)
typedef struct person
{
char name[32]; // 姓名
char sex; // 性别('M'/'F')
int age; // 年龄
int score; // 成绩
} DATATYPE;
// 2. 定义节点结构体:包含数据域和指针域
typedef struct node
{
DATATYPE data; // 数据域:存储学生信息
struct node *next; // 指针域:指向后继节点
} LinkNode;
// 3. 定义链表头结构体:管理链表
typedef struct list
{
LinkNode *head; // 头指针:链表入口
int clen; // 链表长度:有效节点数
} LinkList;
// 4. 函数指针:用于灵活查找(解耦查找规则)
// 功能:判断data是否符合arg指定的条件,符合返回1,否则返回0
typedef int (*PFUN)(DATATYPE*, void* arg);
// 5. 链表核心接口声明
LinkList *CreateLinkList(); // 创建链表(初始化)
int InsertHeadLinkList(LinkList *list, DATATYPE *data); // 头插法
int InsertTailLinkList(LinkList *list, DATATYPE *data); // 尾插法
int ShowLinkList(LinkList *list); // 遍历打印链表
DATATYPE *FindLinkList(LinkList *list, char *name); // 按姓名查找(固定规则)
DATATYPE *FindLinkList2(LinkList *list, PFUN fun, void* arg); // 灵活查找(自定义规则)
int ModifyLinkList(LinkList *list, char *name, DATATYPE* data); // 按姓名修改
int DeleteLinkList(LinkList *list, char *name); // 按姓名删除
int DestroyLinkList(LinkList *list); // 销毁链表(释放内存)
int GetSizeLinkList(LinkList* list); // 获取链表长度
int IsEmptyLinkList(LinkList* list); // 判断链表是否为空
void RevertLinkList(LinkList* list); // 反转链表
#endif
2. 源文件实现(linklist.c)
接下来在源文件中实现头文件声明的所有接口,重点关注内存管理和指针操作的细节。
(1)创建链表:初始化链表头
创建链表的本质是为 "链表头结构体" 申请内存,并初始化头指针(空链表时为NULL)和长度(0)。
#include "linklist.h"
#include <stdio.h>
#include <stdlib.h>
LinkList *CreateLinkList()
{
// 为链表头申请内存
LinkList *ll = (LinkList *)malloc(sizeof(LinkList));
if (NULL == ll)
{
printf("CreateLinkList: 内存分配失败\n");
return NULL;
}
// 初始化:空链表,头指针为NULL,长度为0
ll->head = NULL;
ll->clen = 0;
return ll;
}
(2)获取长度与判断空链表
这两个接口直接操作链表头的clen和head,复杂度为 O (1),非常高效。
// 获取链表长度
int GetSizeLinkList(LinkList *list)
{
return list->clen;
}
// 判断链表是否为空(空返回1,非空返回0)
int IsEmptyLinkList(LinkList *list)
{
return (NULL == list->head) ? 1 : 0;
}
(3)插入操作:头插法与尾插法
插入是链表的核心操作,头插法和尾插法的效率不同(头插 O (1),尾插 O (n)),需根据场景选择。
-
头插法:新节点插入到链表头部,只需调整新节点的指针和头指针,无需遍历链表。
/**
-
@brief 头插法:新节点插入到链表第一个位置
-
@param list 目标链表
-
@param data 待插入的数据
-
@return 0成功,1失败
*/
int InsertHeadLinkList(LinkList *list, DATATYPE *data)
{
// 1. 为新节点申请内存
LinkNode *newnode = (LinkNode *)malloc(sizeof(LinkNode));
if (NULL == newnode)
{
printf("InsertHeadLinkList: 新节点内存分配失败\n");
return 1;
}// 2. 初始化新节点:拷贝数据,指针域先置空
memcpy(&newnode->data, data, sizeof(DATATYPE));
newnode->next = NULL;// 3. 插入链表头部:新节点的next指向原头节点,头指针指向新节点
newnode->next = list->head;
list->head = newnode;// 4. 链表长度+1
list->clen++;
return 0;
}
-
-
尾插法 :新节点插入到链表尾部,需遍历到最后一个节点(指针域为
NULL),再调整指针。/**
-
@brief 尾插法:新节点插入到链表最后一个位置
-
@param list 目标链表
-
@param data 待插入的数据
-
@return 0成功,1失败
*/
int InsertTailLinkList(LinkList *list, DATATYPE *data)
{
// 若链表为空,直接调用头插法(逻辑一致)
if (IsEmptyLinkList(list))
{
return InsertHeadLinkList(list, data);
}// 1. 为新节点申请内存并初始化
LinkNode *newnode = malloc(sizeof(LinkNode));
if (NULL == newnode)
{
printf("InsertTailLinkList: 新节点内存分配失败\n");
return 1;
}
memcpy(&newnode->data, data, sizeof(DATATYPE));
newnode->next = NULL;// 2. 遍历到最后一个节点(next为NULL)
LinkNode *tmp = list->head;
while (tmp->next != NULL)
{
tmp = tmp->next;
}// 3. 插入尾部:最后一个节点的next指向新节点
tmp->next = newnode;// 4. 链表长度+1
list->clen++;
return 0;
}
-
(4)遍历打印:查看链表内容
遍历链表需从头部开始,通过指针域依次访问每个节点,直到指针为NULL(链表结尾)。
int ShowLinkList(LinkList *list)
{
// 若链表为空,直接返回
if (IsEmptyLinkList(list))
{
printf("ShowLinkList: 链表为空\n");
return 1;
}
// 从头部开始遍历
LinkNode *tmp = list->head;
int len = GetSizeLinkList(list);
printf("链表内容(共%d个节点):\n", len);
for (int i = 0; i < len; i++)
{
printf("姓名:%s | 性别:%c | 年龄:%d | 成绩:%d\n",
tmp->data.name, tmp->data.sex, tmp->data.age, tmp->data.score);
tmp = tmp->next; // 移动到下一个节点
}
return 0;
}
(5)查找操作:固定规则与灵活规则
查找的核心是遍历链表,判断节点数据是否符合条件。为了提高灵活性,我们实现两种查找方式:
-
固定规则查找:按姓名查找(直接在函数内写死判断逻辑)。
/**
-
@brief 按姓名查找:找到返回数据地址,未找到返回NULL
-
@param list 目标链表
-
@param name 待查找的姓名
-
@return DATATYPE* 数据地址(成功)/ NULL(失败)
*/
DATATYPE *FindLinkList(LinkList *list, char *name)
{
if (IsEmptyLinkList(list))
{
printf("FindLinkList: 链表为空\n");
return NULL;
}LinkNode *tmp = list->head;
while (tmp != NULL)
{
// 比较姓名(strcmp返回0表示相等)
if (0 == strcmp(tmp->data.name, name))
{
return &tmp->data; // 返回数据域地址
}
tmp = tmp->next;
}printf("FindLinkList: 未找到姓名为【%s】的节点\n", name);
return NULL;
}
-
-
灵活规则查找:通过函数指针传入自定义判断逻辑(解耦查找规则,支持按年龄、成绩等查找)。
/**
-
@brief 灵活查找:支持自定义查找规则(函数指针实现)
-
@param list 目标链表
-
@param fun 函数指针:自定义判断逻辑
-
@param arg 查找参数(如年龄、成绩)
-
@return DATATYPE* 数据地址(成功)/ NULL(失败)
*/
DATATYPE *FindLinkList2(LinkList *list, PFUN fun, void *arg)
{
if (IsEmptyLinkList(list) || NULL == fun)
{
printf("FindLinkList2: 链表为空或函数指针为空\n");
return NULL;
}LinkNode *tmp = list->head;
while (tmp != NULL)
{
// 调用外部传入的判断函数,符合条件则返回
if (fun(&tmp->data, arg))
{
return &tmp->data;
}
tmp = tmp->next;
}printf("FindLinkList2: 未找到符合条件的节点\n");
return NULL;
}
-
(6)修改操作:基于查找的更新
修改的逻辑很简单:先通过查找找到目标节点,再拷贝新数据覆盖原数据。
/**
* @brief 按姓名修改:找到节点后更新数据
* @param list 目标链表
* @param name 待修改节点的姓名
* @param data 新数据
* @return 0成功,1失败
*/
int ModifyLinkList(LinkList *list, char *name, DATATYPE *data)
{
// 先查找目标节点
DATATYPE *target = FindLinkList(list, name);
if (NULL == target)
{
printf("ModifyLinkList: 修改失败(节点不存在)\n");
return 1;
}
// 覆盖原数据
memcpy(target, data, sizeof(DATATYPE));
printf("ModifyLinkList: 姓名为【%s】的节点修改成功\n", name);
return 0;
}
(7)删除操作:处理头部与中间节点
删除的核心是 "找到待删节点的前驱节点",调整前驱节点的指针跳过待删节点,再释放待删节点的内存。需特别处理 "删除头部节点" 的场景(无前驱节点)。
/**
* @brief 按姓名删除:找到节点后释放内存
* @param list 目标链表
* @param name 待删除节点的姓名
* @return 0成功,1失败
*/
int DeleteLinkList(LinkList *list, char *name)
{
if (IsEmptyLinkList(list))
{
printf("DeleteLinkList: 链表为空,无法删除\n");
return 1;
}
LinkNode *prev = list->head; // 前驱节点:初始指向头节点
// 场景1:删除头部节点(无前驱节点)
if (0 == strcmp(prev->data.name, name))
{
list->head = prev->next; // 头指针指向原第二个节点
free(prev); // 释放原头部节点内存
list->clen--; // 长度-1
printf("DeleteLinkList: 头部节点【%s】删除成功\n", name);
return 0;
}
// 场景2:删除中间/尾部节点(需找到前驱节点)
while (prev->next != NULL)
{
// 前驱节点的next指向待删节点
if (0 == strcmp(prev->next->data.name, name))
{
LinkNode *tmp = prev->next; // 待删节点
prev->next = tmp->next; // 前驱节点跳过待删节点
free(tmp); // 释放待删节点内存
list->clen--; // 长度-1
printf("DeleteLinkList: 节点【%s】删除成功\n", name);
return 0;
}
prev = prev->next; // 移动前驱节点
}
printf("DeleteLinkList: 未找到姓名为【%s】的节点\n", name);
return 1;
}
(8)销毁链表:彻底释放内存
链表的销毁需遍历所有节点,逐个释放内存,最后释放链表头的内存(避免内存泄漏)。
/**
* @brief 销毁链表:释放所有节点和链表头内存
* @param list 目标链表
* @return 0成功,1失败
*/
int DestroyLinkList(LinkList *list)
{
if (NULL == list)
{
printf("DestroyLinkList: 链表为空,无需销毁\n");
return 1;
}
LinkNode *curr = list->head; // 当前节点
LinkNode *next = NULL; // 下一个节点(避免释放后找不到)
// 遍历释放所有节点
while (curr != NULL)
{
next = curr->next; // 先记录下一个节点
free(curr); // 释放当前节点
curr = next; // 移动到下一个节点
}
// 释放链表头
free(list);
list = NULL; // 避免野指针(虽然外部指针需自行置空,但此处是良好习惯)
printf("DestroyLinkList: 链表销毁成功\n");
return 0;
}
(9)反转链表:调整指针方向
反转链表是链表的经典操作,核心是通过三个指针(前驱prev、当前curr、后继next)逐步调整节点的指针方向,将 "向前指向" 改为 "向后指向"。
/**
* @brief 反转链表:将链表顺序倒置(如1→2→3变为3→2→1)
* @param list 目标链表
*/
void RevertLinkList(LinkList* list)
{
// 边界条件:链表为空、只有一个节点,无需反转
if (NULL == list || IsEmptyLinkList(list) || list->head->next == NULL)
{
printf("RevertLinkList: 无需反转(链表为空或只有一个节点)\n");
return;
}
LinkNode *prev = NULL; // 前驱节点(初始为空)
LinkNode *curr = list->head; // 当前节点(初始为头节点)
LinkNode *next = NULL; // 后继节点(临时存储)
while (curr != NULL)
{
next = curr->next; // 1. 先记录当前节点的后继
curr->next = prev; // 2. 反转当前节点的指针(指向前驱)
prev = curr; // 3. 前驱节点向后移动
curr = next; // 4. 当前节点向后移动
}
// 最后,前驱节点指向原链表的最后一个节点,设为新头节点
list->head = prev;
printf("RevertLinkList: 链表反转成功\n");
}
四、接口测试:验证链表功能
为了确保接口正确,我们编写一个测试程序(main.c),模拟学生信息的插入、查找、修改、删除和反转操作。
#include "linklist.h"
#include <stdio.h>
// 自定义查找规则1:按年龄查找(arg为目标年龄)
int FindByAge(DATATYPE *data, void *arg)
{
int target_age = *(int *)arg;
return (data->age == target_age) ? 1 : 0;
}
// 自定义查找规则2:按成绩查找(arg为目标成绩)
int FindByScore(DATATYPE *data, void *arg)
{
int target_score = *(int *)arg;
return (data->score == target_score) ? 1 : 0;
}
int main()
{
// 1. 创建链表
LinkList *ll = CreateLinkList();
if (NULL == ll)
{
return 1;
}
// 2. 插入学生信息(头插+尾插)
DATATYPE stu1 = {"张三", 'M', 20, 95};
DATATYPE stu2 = {"李四", 'F', 19, 88};
DATATYPE stu3 = {"王五", 'M', 21, 92};
InsertHeadLinkList(ll, &stu1); // 头插:张三
InsertTailLinkList(ll, &stu2); // 尾插:李四(此时链表:张三→李四)
InsertTailLinkList(ll, &stu3); // 尾插:王五(此时链表:张三→李四→王五)
ShowLinkList(ll); // 打印结果:张三、李四、王五
// 3. 查找操作
printf("\n=== 查找测试 ===\n");
DATATYPE *find1 = FindLinkList(ll, "李四"); // 按姓名查找
if (find1 != NULL)
{
printf("按姓名查找李四:%s %c %d %d\n", find1->name, find1->sex, find1->age, find1->score);
}
int target_age = 21;
DATATYPE *find2 = FindLinkList2(ll, FindByAge, &target_age); // 按年龄查找
if (find2 != NULL)
{
printf("按年龄查找21岁:%s %c %d %d\n", find2->name, find2->sex, find2->age, find2->score);
}
// 4. 修改操作
printf("\n=== 修改测试 ===\n");
DATATYPE new_stu = {"李四", 'F', 20, 90}; // 将李四的年龄改为20,成绩改为90
ModifyLinkList(ll, "李四", &new_stu);
ShowLinkList(ll); // 打印结果:李四的年龄和成绩已更新
// 5. 反转链表
printf("\n=== 反转测试 ===\n");
RevertLinkList(ll);
ShowLinkList(ll); // 打印结果:王五→李四→张三
// 6. 删除操作
printf("\n=== 删除测试 ===\n");
DeleteLinkList(ll, "李四"); // 删除李四
ShowLinkList(ll); // 打印结果:王五→张三
// 7. 销毁链表
printf("\n=== 销毁测试 ===\n");
DestroyLinkList(ll);
ll = NULL; // 外部指针置空,避免野指针
return 0;
}
五、总结:单向链表的优缺点与应用场景
1. 优点
- 动态存储:无需预先分配内存,随数据量动态扩展,内存利用率高;
- 插入 / 删除高效:头插 O (1),中间 / 尾部删除只需调整指针(O (n) 是遍历时间,非移动时间);
- 内存灵活:无需连续内存,离散存储即可。
2. 缺点
- 随机访问差:无法像数组那样通过下标直接访问元素,必须从头部遍历(O (n));
- 额外内存开销:每个节点需存储指针域,增加了内存消耗;
- 反向遍历难:单向链表只能从头部向后遍历,无法向前(如需反向遍历,需用双向链表)。
3. 应用场景
- 数据量不确定,需频繁插入 / 删除的场景(如链表式队列、栈);
- 无需随机访问,只需顺序遍历的场景(如日志记录、链表式哈希表)。