数据结构之单向链表

一、为什么需要单向链表?------ 解决顺序存储的痛点

在学习单向链表前,我们首先要明白:链式存储是为了解决顺序存储的固有缺陷而设计的

顺序存储(如数组)虽然实现简单,但存在三个致命问题:

  1. 插入 / 删除效率低:若在数组中间插入或删除元素,需要移动后续所有元素,时间复杂度为 O (n);
  2. 动态扩容难题:数组初始化时需指定固定大小,扩容时需重新申请更大内存并拷贝数据,不仅麻烦还会浪费资源;
  3. 内存利用率低:顺序存储要求内存连续,即使内存总空闲空间足够,若没有连续块也无法存储数据。

而单向链表通过 "离散存储 + 指针关联" 的方式,完美解决了这些问题 ------ 它不需要连续内存,插入 / 删除只需调整指针,且能动态适应数据量变化。

二、单向链表的核心原理:节点与逻辑结构

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)获取长度与判断空链表

这两个接口直接操作链表头的clenhead,复杂度为 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. 应用场景

  • 数据量不确定,需频繁插入 / 删除的场景(如链表式队列、栈);
  • 无需随机访问,只需顺序遍历的场景(如日志记录、链表式哈希表)。
相关推荐
为何创造硅基生物5 小时前
C语言 结构体内存对齐规则(通俗易懂版)
c语言·开发语言
仰泳之鹅5 小时前
【C语言】自定义数据类型2——联合体与枚举
c语言·开发语言·算法
jolimark6 小时前
C语言自学攻略:小白入门三步走
c语言·编程入门·学习路线·实践项目·自学攻略
cen__y7 小时前
Linux12(Git01)
linux·运维·服务器·c语言·开发语言·git
社交怪人7 小时前
【算平均分】信息学奥赛一本通C语言解法(题号2071)
c语言·开发语言
卢锡荣8 小时前
单芯通吃,盲插标杆 —— 乐得瑞 LDR6020,Type‑C 全场景互联 “智慧芯”
c语言·开发语言·计算机外设
Mr. zhihao8 小时前
深入解析redis基本数据结构
数据结构·数据库·redis
念何架构之路9 小时前
Go语言加密算法
数据结构·算法·哈希算法
AI科技星9 小时前
《数学公理体系·第三部·数术几何》(2026 年版)
c语言·开发语言·线性代数·算法·矩阵·量子计算·agi
失去的青春---夕阳下的奔跑9 小时前
560. 和为 K 的子数组
数据结构·算法·leetcode