数据结构之单向链表

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

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

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

  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. 应用场景

  • 数据量不确定,需频繁插入 / 删除的场景(如链表式队列、栈);
  • 无需随机访问,只需顺序遍历的场景(如日志记录、链表式哈希表)。
相关推荐
亦是远方3 小时前
南京邮电大学使用计算机求解问题实验一(C语言简单编程练习)
c语言·开发语言·实验报告·南京邮电大学
im_AMBER3 小时前
算法笔记 18 二分查找
数据结构·笔记·学习·算法
C雨后彩虹3 小时前
机器人活动区域
java·数据结构·算法·华为·面试
Gomiko4 小时前
C/C++基础(四):运算符
c语言·c++
苏小瀚4 小时前
[算法]---路径问题
数据结构·算法·leetcode
wadesir5 小时前
C语言模块化设计入门指南(从零开始构建清晰可维护的C程序)
c语言·开发语言·算法
赖small强5 小时前
【Linux C/C++开发】 GCC -g 调试参数深度解析与最佳实践
linux·c语言·c++·gdb·-g
前端之虎陈随易5 小时前
MoonBit内置数据结构详解
数据结构·数据库·redis
猫猫的小茶馆6 小时前
【ARM】ARM的介绍
c语言·开发语言·arm开发·stm32·单片机·嵌入式硬件·物联网