数据结构 - 单链表第一篇:单链表基础操作

【C 语言数据结构】单链表基础操作详解(头插 / 尾插 / 头删 / 尾删 / 查找)

前言

单链表是 C 语言数据结构中最基础也最核心的线性表结构之一,相比顺序表,单链表的内存布局更灵活,无需连续内存空间,增删操作(非头尾)效率更高。本文将从设计思路、实现步骤、踩坑点三个维度,拆解单链表基础操作的完整思考过程,不仅告诉你代码怎么写,更讲清楚为什么这么写,以及写链表代码的通用思考方法。


一、单链表的结构体定义
cpp 复制代码
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>

// 数据类型重定义,方便后续修改(比如改成char/long等)
typedef int DaTy;
// 单链表节点结构体
typedef struct LinkList
{
    DaTy a;          // 节点存储的数据
    struct LinkList* next; // 指向下一个节点的指针
}LL; // 重命名为LL,简化后续使用
设计思路与方法
  1. 结构设计逻辑 :单链表的核心是「节点串联」,每个节点只需要两部分:数据域 存数据,指针域存下一个节点的地址。和顺序表用数组连续存储不同,链表节点在内存中是分散的,靠指针维系逻辑上的连续。
  2. 类型重命名的工程意义 :用DaTy封装数据类型,后续想存字符、结构体、指针,只改一行即可,无需全局替换,这是数据结构「通用性设计」的基础思维。
  3. 结构体自引用注意 :指针域必须写struct LinkList* next,不能直接写LL* next------ 因为LL是 typedef 的别名,结构体内部定义时别名还未生效,这是 C 语言语法的硬性规则。

二、核心辅助函数

辅助函数是链表操作的「地基」,所有增删查改都依赖它们,写好辅助函数能大幅减少重复代码和出错概率。

2.1 创建新节点(SLTBuyNode)
cpp 复制代码
LL* SLTBuyNode(DaTy x)
{
    // 申请节点内存
    LL* newnode = (LL*)malloc(sizeof(LL));
    // 内存申请失败处理(必做!)
    if (newnode == NULL)
    {
        perror("malloc fall!"); // 打印错误原因
        exit(1); // 终止程序,避免空指针操作
    }
    newnode->a = x;    // 初始化节点数据
    newnode->next = NULL; // 新节点默认指向NULL,避免野指针
    return newnode;
}
设计思路与实现步骤
  • 封装思路:把「申请内存 + 初始化 + 错误处理」封装成一个函数,后续所有插入操作都直接调用,不用每次重复写 malloc。这是代码复用的核心思想:重复逻辑抽离成函数。
  • 实现三步法
    1. malloc申请一块和节点大小相等的堆内存
    2. 判空兜底:内存申请失败直接报错退出,防止后续空指针解引用崩溃
    3. 初始化两个成员:数据赋值,指针域默认置空,杜绝野指针
  • 思考方法:凡是申请动态内存,必做判空;凡是创建指针,必初始化。这是 C 语言工程编码的基本素养。
2.2 打印链表(Print)
cpp 复制代码
void Print(LL* phead)
{
    LL* pcur = phead; // 遍历指针,从表头开始
    while (pcur) // 等价于pcur != NULL,遍历到尾节点结束
    {
        printf("%d->",pcur->a);
        pcur = pcur -> next; // 移动到下一个节点
    }
    printf("NULL\n"); // 链表尾标识,增强可读性
}
设计思路与遍历模板
  • 核心思想:打印是链表最基础的「遍历操作」,所有链表操作(查找、计数、销毁)都基于这个遍历模板。
  • 遍历通用四要素
    1. 起点:用临时指针pcur从表头开始,绝不直接修改头指针(头指针一旦改了,链表就找不到了)
    2. 终止条件:pcur == NULL(走到尾节点之后停止)
    3. 每轮操作:打印当前节点的数据
    4. 指针移动:pcur = pcur->next,往后走一个节点
  • 易错点提醒 :终止条件不能写成pcur->next != NULL,否则最后一个节点会被漏掉。判断条件怎么选,取决于你要操作「当前节点」还是「下一个节点」。
2.3 查找节点(SLFin)
cpp 复制代码
LL* SLFin(LL* phead, DaTy x)
{
    LL* pcur = phead;
    while (pcur)
    {
        if (pcur->a == x) // 找到目标值,返回节点地址
        {
            return pcur;
        }
        pcur = pcur->next;
    }
    return NULL; // 遍历完未找到,返回NULL
}
设计思路与方法
  • 功能定位:查找是插入、删除的前置操作。单链表没有下标,想在某个值前后插入 / 删除,必须先按值找到节点地址,这是链表「按值操作」的必经之路。
  • 模板复用思想:代码框架和打印函数完全一致,只是把「打印」换成了「数值比较 + 返回」。学会复用遍历模板,写链表代码速度会大幅提升。
  • 接口设计原则:查找只返回节点地址,不打印、不修改。把打印逻辑留给调用方,函数职责单一,才能灵活复用。

三、核心基础操作

头尾操作是单链表的高频操作,也是理解指针、二级指针的最佳入口。

3.1 头插法(PushFront)
cpp 复制代码
void PushFront(LL** pphead, DaTy x)
{
    assert(pphead); // 检查pphead非空(避免传入NULL)
    LL* newnode = SLTBuyNode(x);
    newnode->next = *pphead; // 新节点指向原表头
    *pphead = newnode; // 更新表头为新节点
}
设计思路与实现步骤
  • 为什么用二级指针 :这是链表第一个核心难点。C 语言函数传参是「值传递」,如果传一级指针LL* head,函数里只能修改节点的内容,不能修改head本身的指向。头插会改变头指针的指向(头节点换成新节点了),所以必须传头指针的地址(也就是二级指针LL**),才能通过解引用修改 main 函数里的head
  • 实现两步法
    1. 新节点先接上原链表:newnode->next = *pphead
    2. 更新表头:*pphead = newnode
  • 时间复杂度:O (1),不需要遍历,是单链表效率最高的插入方式。
3.2 头删法(SLDeleteBack)
cpp 复制代码
void SLDeleteBack(LL** pphead)
{
    assert(*pphead && pphead); // 检查表头非空、pphead非空
    LL* next = (*pphead)->next; // 保存下一个节点地址
    free(*pphead); // 释放表头节点
    *pphead = next; // 更新表头为下一个节点
}
设计思路与删除通用法则
  • 核心矛盾:不能直接释放头节点 ------ 直接 free 之后,后面的所有节点就都找不到了。
  • 删除通用法则先保存要保留的内容,再释放要删除的内容。这个法则适用于所有链表删除操作。
  • 实现三步法
    1. 先存下第二个节点的地址
    2. 释放原头节点
    3. 把第二个节点设为新的头节点
  • 边界校验assert(*pphead)确保链表非空,空链表执行删除属于非法操作,直接断言崩溃,方便定位 bug。
3.3 尾插法(PushBack)
cpp 复制代码
void PushBack(LL **pphead, DaTy x)
{
    assert(pphead);
    LL *newnode = SLTBuyNode(x);
    if (*pphead == NULL) // 空链表:新节点直接作为表头
    {
        *pphead = newnode;
    }
    else // 非空链表:遍历到尾节点
    {
        LL* pcurs = *pphead;
        while (pcurs->next) // 遍历到倒数第二个节点(尾节点的next为NULL)
        {
            pcurs = pcurs->next;
        }
        pcurs->next = newnode; // 尾节点指向新节点
    }
}
设计思路与边界思维
  • 为什么要分情况 :空链表没有尾节点,如果直接遍历pcurs->next,就是对 NULL 解引用,直接崩溃。所以必须单独处理空链表的情况。
  • 边界思考法 :写任何链表函数,都先想两种极端情况:空链表只有一个节点,这两种情况能跑通,多节点一般就不会错。
  • 遍历终止条件 :这里用pcurs->next != NULL,因为我们要停在「尾节点本身」,而不是尾节点后面,这样才能修改尾节点的 next 指针。
  • 时间复杂度:O (n),必须遍历到尾部,这是单链表的天然缺陷。
3.4 尾删法(SLDeleteBehind)
cpp 复制代码
void SLDeleteBehind(LL** pphead)
{
    assert(* pphead && pphead);
    if ((*pphead)->next == NULL) // 只有一个节点:释放后表头置NULL
    {
        free(*pphead);
        *pphead = NULL;
    }
    else // 多个节点:找到尾节点的前驱
    {
        LL* ptail = *pphead;
        LL* prev = *pphead;
        while (ptail->next) // 遍历到尾节点
        {
            prev = ptail; // 保存前驱节点
            ptail = ptail->next;
        }
        prev->next = NULL; // 前驱节点的next置NULL(成为新尾节点)
        free(ptail); // 释放原尾节点
        ptail = NULL;
    }
}
设计思路与前驱思维
  • 核心难点:尾删不能只找到尾节点就 free。因为尾节点的前驱节点的 next 还指向这块内存,会变成野指针。
  • 前驱思维:单链表没有前驱指针,凡是要修改「前一个节点的指针」,都必须先找到前驱节点。尾删本质是修改倒数第二个节点的 next,所以必须找到倒数第二个节点。
  • 双指针遍历法 :用prevptail两个指针一起往后走,ptail永远比prev快一步,ptail到尾部时,prev刚好停在倒数第二个节点。
  • 致命踩坑点 :单节点删除时,必须写*pphead = NULL,不能写pphead = NULL------ 后者只是修改了函数形参,不会改变外部的头指针,会留下野指针,这是新手最容易犯的错误。

四、测试代码与运行结果
cpp 复制代码
int main()
{
    LL* head = NULL;  // 表头指针初始化

    printf("========== 1. 尾插 PushBack ==========\n");
    PushBack(&head, 1);
    PushBack(&head, 2);
    PushBack(&head, 3);
    PushBack(&head, 4);
    Print(head); // 预期:1->2->3->4->NULL

    printf("========== 2. 头插 PushFront ==========\n");
    PushFront(&head, 0);
    PushFront(&head, -1);
    Print(head); // 预期:-1->0->1->2->3->4->NULL

    printf("========== 3. 查找 SLFin ==========\n");
    LL* pos1 = SLFin(head, 2);
    if (pos1)
        printf("找到元素 2,地址:%p\n", pos1);
    else
        printf("未找到元素 2\n");

    LL* pos2 = SLFin(head, 99);
    if (pos2)
        printf("找到元素 99,地址:%p\n", pos2);
    else
        printf("未找到元素 99\n");

    printf("========== 4. 头删 SLDeleteBack ==========\n");
    SLDeleteBack(&head);
    Print(head); // 删-1:0->1->2->3->4->NULL
    SLDeleteBack(&head);
    Print(head); // 删0:1->2->3->4->NULL

    SLDestroy(&head);
    return 0;
}

运行结果:

cpp 复制代码
========== 1. 尾插 PushBack ==========
1->2->3->4->NULL
========== 2. 头插 PushFront ==========
-1->0->1->2->3->4->NULL
========== 3. 查找 SLFin ==========
找到元素 2,地址:000002207859C290
未找到元素 99
========== 4. 头删 SLDeleteBack ==========
0->1->2->3->4->NULL
1->2->3->4->NULL

五、基础篇通用思考方法总结
  1. 二级指针判断法则:函数会不会修改头指针的指向?会 → 用二级指针;不会 → 用一级指针。
  2. 遍历不动头:所有遍历操作都用临时指针,绝不直接修改传入的头指针,否则链表会丢失。
  3. 删除先保留:释放节点前,一定先保存好后续节点的地址,防止断链。
  4. 边界优先法:写函数先考虑空链表、单节点两种边界,再处理多节点的一般情况。
  5. 断言防护 :所有指针解引用前,用assert判空,出问题直接定位,比程序莫名崩溃好调试百倍。
后续更新计划
  • 下一篇 :实际开发中,更多场景基于指定节点的插入 / 删除(比如有序链表插入、删除指定值节点),我将继续完成单链表的功能。
相关推荐
me8321 小时前
【AI】Langchain4j开发学习笔记
人工智能·笔记·学习
wubba lubba dub dub7501 小时前
【无标题】
学习
WL学习笔记1 小时前
通讯录(顺序表实现)
c语言·数据结构·算法
不会C语言的男孩1 小时前
Linux 系统编程 · 第 1 章:Linux 系统概述
c语言·开发语言
虎符饼干1 小时前
内容SEO落地细则,依托质量撬动搜索自然流量
笔记
2601_951645741 小时前
C语言环境搭建指南
c语言·编译器·开发环境·helloworld·集成开发环境
YM52e1 小时前
鸿蒙PC ArkTS 异常处理深度解析与最佳实践
学习·华为·harmonyos
JieE2121 小时前
树与二叉树--JS实例
javascript·数据结构
Cloud_Shy6181 小时前
解读《Effective Python 3rd Edition》:从练气到老魔(第六章 Item 44 - 47)
开发语言·人工智能·经验分享·笔记·python