c语言单链表

单链表是数据结构中最基础也是最重要的线性表结构之一,相比顺序表(数组),它的优势在于动态内存分配,无需预先开辟连续的内存空间,插入和删除操作也更灵活。本文将从单链表的基本概念出发,一步步实现单链表的增删改查等核心功能,帮助初学者彻底掌握单链表的底层逻辑。

在项目中创建了以下三个文件:

(1) SList.h(头文件)

(2) SList.c(实现文件)

(3) test.c(测试文件,可取消注释测试不同功能)

一、单链表的核心概念

1.1 单链表的结构

单链表由一个个节点(Node) 串联而成,每个节点包含两部分:

  • 数据域:存储节点的实际数据(如 int、char 等);
  • 指针域:存储下一个节点的地址,通过指针将节点连接起来。

最后一个节点的指针域指向NULL,表示链表结束。此外,我们通常用一个头指针指向链表的第一个节点,以此访问整个链表。

用 C 语言定义单链表节点的结构体:

cpp 复制代码
// 定义链表节点存储的数据类型(可灵活修改)
typedef int SLDataType;
// 单链表节点结构体
typedef struct SListNode {
    SLDataType data;       // 数据域
    struct SListNode* next;// 指针域,指向后一个节点
} SLTNode;

1.2 单链表的特点

  • 物理存储上非连续、非顺序,逻辑上连续;
  • 只能从头指针开始,顺着指针域遍历链表,无法反向访问;
  • 插入 / 删除操作无需移动大量数据,只需修改指针指向,效率高;
  • 随机访问效率低(无法像数组一样通过下标直接访问)。

二、单链表的基础功能实现

我们先搭建单链表的核心函数框架(头文件SList.h),再逐一实现功能(源文件SList.c),最后通过测试文件test.c验证功能。

2.1 头文件声明(SList.h)

头文件主要负责结构体、函数声明,以及引入必要的库(如stdio.hstdlib.hassert.h):

cpp 复制代码
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>

typedef int SLDataType;
typedef struct SListNode {
    SLDataType data;
    struct SListNode* next;
} SLTNode;

// 链表打印
void SLTPrint(SLTNode* head);
// 新建节点
SLTNode* SLTBuyNode(SLDataType x);
// 尾插(链表尾部添加节点)
void SLTPushBack(SLTNode** pphead, SLDataType x);
// 头插(链表头部添加节点)
void SLTPushFront(SLTNode** pphead, SLDataType x);
// 尾删(链表尾部删除节点)
void SLTPopBack(SLTNode** pphead);
// 头删(链表头部删除节点)
void SLTPopFront(SLTNode** pphead);
// 查找指定值的节点
SLTNode* SLTFind(SLTNode* head, SLDataType x);
// 在指定位置前插入节点
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLDataType x);
// 在指定位置后插入节点
void SLTInsertAfter(SLTNode* pos, SLDataType x);
// 删除指定位置的节点
void SLTErase(SLTNode** pphead, SLTNode* pos);
// 删除指定位置后的节点
void SLTEraseAfter(SLTNode* pos);
// 销毁链表
void SListDestroy(SLTNode** pphead);

2.2 核心工具函数:新建节点

所有插入操作都需要先创建新节点,因此封装一个SLTBuyNode函数,负责申请内存、初始化节点:

cpp 复制代码
#include"SList.h"

// 新建节点
SLTNode* SLTBuyNode(SLDataType x)
{
    // 申请节点内存
    SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    // 检查内存申请是否成功
    if (newnode == NULL)
    {
        perror("malloc error"); // 打印错误信息
        exit(1); // 终止程序
    }
    // 初始化节点
    newnode->data = x;
    newnode->next = NULL;
    return newnode;
}

2.3 基础功能 1:打印链表

遍历链表,逐个打印节点数据,直到遇到NULL

cpp 复制代码
// 打印链表
void SLTPrint(SLTNode* head)
{
    SLTNode* pcur = head; // 遍历指针,从头节点开始
    while (pcur != NULL)
    {
        printf("%d->", pcur->data);
        pcur = pcur->next; // 移动到下一个节点
    }
    printf("NULL\n"); // 链表结束标识
}

三、单链表的增删操作

增删是单链表的核心操作,需重点关注空链表只有一个节点等边界情况,以及指针的修改逻辑。

3.1 尾部插入(尾插)

思路:

  1. 若链表为空(头指针*ppheadNULL),直接让头指针指向新节点;
  2. 若链表非空,先遍历到最后一个节点(指针域为NULL的节点),再让最后一个节点的指针域指向新节点。
cpp 复制代码
// 尾插
void SLTPushBack(SLTNode** pphead, SLDataType x)
{
    assert(pphead); // 确保头指针的地址非空
    SLTNode* newnode = SLTBuyNode(x);
    // 空链表
    if (*pphead == NULL)
    {
        *pphead = newnode;
    }
    else
    {
        // 遍历到尾节点
        SLTNode* ptail = *pphead;
        while (ptail->next != NULL)
        {
            ptail = ptail->next;
        }
        ptail->next = newnode; // 尾节点指向新节点
    }
}

3.2为什么函数参数要用 SLTNode**(二级指针)?

1 因为要修改链表的头节点(比如头插、头删、尾删最后一个节点时),如果只传 SLTNode*(一级指针),函数内修改的只是副本,无法改变外部的头节点。

2 简单说:只要需要修改头节点,就传二级指针;只读取链表,传一级指针即可 (比如 SLTPrintSLTFind

3.3头部插入(头插)

思路:无需遍历,直接让新节点的指针域指向原头节点,再将头指针指向新节点(无论链表是否为空)。

cpp 复制代码
// 头插
void SLTPushFront(SLTNode** pphead, SLDataType x)
{
    assert(pphead);
    SLTNode* newnode = SLTBuyNode(x);
    newnode->next = *pphead; // 新节点指向原头节点
    *pphead = newnode;       // 头指针指向新节点
}

3.4 尾部删除(尾删)

思路:

  1. 先校验:链表不能为空(*pphead != NULL);
  2. 若链表只有一个节点:释放该节点,头指针置NULL
  3. 若链表有多个节点:遍历到倒数第二个节点,释放最后一个节点,将倒数第二个节点的指针域置NULL
cpp 复制代码
// 尾删
void SLTPopBack(SLTNode** pphead)
{
    assert(pphead && *pphead); // 链表不能为空
    // 只有一个节点
    if ((*pphead)->next == NULL)
    {
        free(*pphead);
        *pphead = NULL;
    }
    else
    {
        SLTNode* prev = *pphead;
        SLTNode* ptail = *pphead;
        // 遍历到尾节点,prev记录倒数第二个节点
        while (ptail->next != NULL)
        {
            prev = ptail;
            ptail = ptail->next;
        }
        free(ptail); // 释放尾节点
        ptail = NULL;
        prev->next = NULL; // 倒数第二个节点变为尾节点
    }
}

3.5 头部删除(头删)

思路:

  1. 校验链表非空;
  2. 记录原头节点的下一个节点地址;
  3. 释放原头节点,头指针指向记录的节点。
cpp 复制代码
// 头删
void SLTPopFront(SLTNode** pphead)
{
    assert(pphead && *pphead);
    SLTNode* next = (*pphead)->next; // 记录第二个节点
    free(*pphead);                   // 释放头节点
    *pphead = next;                  // 头指针指向第二个节点
}

四、单链表的进阶操作

4.1 查找指定节点

遍历链表,找到数据域等于目标值的节点,返回该节点地址;若未找到,返回NULL

cpp 复制代码
// 查找指定值的节点
SLTNode* SLTFind(SLTNode* head, SLDataType x)
{
    SLTNode* pcur = head;
    while (pcur != NULL)
    {
        if (pcur->data == x)
        {
            return pcur; // 找到,返回节点地址
        }
        pcur = pcur->next;
    }
    return NULL; // 未找到
}

4.2 指定位置前插入节点

思路:

  1. 若插入位置是头节点,直接复用头插函数;
  2. 若插入位置是中间节点,先遍历到插入位置的前一个节点,再修改指针指向。
cpp 复制代码
// 在pos节点前插入x
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLDataType x)
{
    assert(pphead && pos && *pphead);
    // 插入位置是头节点,复用头插
    if (pos == *pphead)
    {
        SLTPushFront(pphead, x);
        return;
    }
    // 找到pos的前一个节点prev
    SLTNode* prev = *pphead;
    while (prev->next != pos)
    {
        prev = prev->next;
    }
    // 新建节点,修改指针
    SLTNode* newnode = SLTBuyNode(x);
    newnode->next = pos;
    prev->next = newnode;
}

4.3 指定位置后插入节点

相比 "前插入","后插入" 更简单:无需找前一个节点,直接让新节点指向pos的下一个节点,再让pos指向新节点。

cpp 复制代码
// 在pos节点后插入x
void SLTInsertAfter(SLTNode* pos, SLDataType x)
{
    assert(pos);
    SLTNode* newnode = SLTBuyNode(x);
    newnode->next = pos->next;
    pos->next = newnode;
}

4.4 删除指定位置节点

思路:

  1. 若删除的是头节点,复用头删函数;
  2. 若删除的是中间节点,先找到前一个节点,让前一个节点指向pos的下一个节点,再释放pos
cpp 复制代码
// 删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
    assert(pphead && pos && *pphead);
    // 删除头节点,复用头删
    if (pos == *pphead)
    {
        SLTPopFront(pphead);
    }
    else
    {
        SLTNode* prev = *pphead;
        while (prev->next != pos)
        {
            prev = prev->next;
        }
        prev->next = pos->next;
        free(pos);
        pos = NULL;
    }
}

4.5 删除指定位置后的节点

无需找前一个节点,直接释放pos的下一个节点,再让pos指向该节点的下下个节点。

cpp 复制代码
// 删除pos后的节点
void SLTEraseAfter(SLTNode* pos)
{
    assert(pos && pos->next); // pos不能是尾节点
    SLTNode* del = pos->next;
    pos->next = pos->next->next;
    free(del);
    del = NULL;
}

4.6 销毁链表

遍历链表,逐个释放节点,最后将头指针置NULL(避免野指针)。

cpp 复制代码
// 销毁链表
void SListDestroy(SLTNode** pphead)
{
    assert(pphead && *pphead);
    SLTNode* pcur = *pphead;
    while (pcur != NULL)
    {
        SLTNode* pnext = pcur->next; // 先记录下一个节点
        free(pcur);                 // 释放当前节点
        pcur = pnext;               // 移动到下一个节点
    }
    *pphead = NULL; // 头指针置空
}

五、测试代码(test.c)

编写测试函数,验证所有功能的正确性:

cpp 复制代码
#include "SList.h"

void SListTest02()
{
    // 初始化空链表
    SLTNode* plist = NULL;
    
    // 测试尾插
    SLTPushBack(&plist, 1);
    SLTPushBack(&plist, 2);
    SLTPushBack(&plist, 3);
    SLTPushBack(&plist, 4);
    printf("尾插后:");
    SLTPrint(plist); // 输出:1->2->3->4->NULL

    // 测试查找
    SLTNode* find = SLTFind(plist, 3);
    SLTNode* find2 = SLTFind(plist, 4);

    // 测试指定位置前插入
    SLTInsert(&plist, find, 33);
    printf("在3前插入33后:");
    SLTPrint(plist); // 输出:1->2->33->3->4->NULL

    // 测试指定位置后插入
    SLTInsertAfter(find, 44);
    printf("在3后插入44后:");
    SLTPrint(plist); // 输出:1->2->33->3->44->4->NULL

    // 测试删除指定节点
    SLTErase(&plist, find);
    printf("删除节点3后:");
    SLTPrint(plist); // 输出:1->2->33->44->4->NULL

    // 测试删除指定位置后节点
    SLTEraseAfter(find2);
    printf("删除4后节点后:");
    SLTPrint(plist); // 输出:1->2->33->44->NULL

    // 测试销毁链表
    SListDestroy(&plist);
    printf("销毁后:");
    SLTPrint(plist); // 输出:NULL
}

int main() 
{
    SListTest02();
    return 0;
}

这个测试结果:单链表功能成功实现但存在指针失效断言检查失败, SLTEraseAfter(find2); 中,find2 最初指向值为 4 的节点,但链表修改后,4 的节点已经是尾节点(pos->next == NULL),删除尾节点的 "后节点" 会触发断言。

六、总结与注意事项

6.1 核心要点

  1. 单链表的节点由数据域 + 指针域组成,头指针是访问链表的入口;
  2. 增删操作的核心是修改指针指向,需重点处理空链表、单节点链表等边界情况;
  3. 涉及修改头指针的操作(如头插、尾插、头删、尾删),参数必须传SLTNode**(头指针的地址);
  4. 内存管理:新建节点用malloc,删除 / 销毁节点必须用free,避免内存泄漏;
  5. 断言(assert)的使用:校验指针非空,提前暴露错误,提升代码健壮性。

6.2 初学者常见错误

  • 忘记处理空链表,导致访问NULL指针;
  • 传参时用SLTNode*而非SLTNode**,修改头指针失败;
  • 释放节点后未将指针置NULL,导致野指针;
  • 遍历链表时,直接修改头指针,导致链表丢失。
相关推荐
独自破碎E2 小时前
【中心扩展法】LCR_020_回文子串
java·开发语言
请注意这个女生叫小美2 小时前
C语言实例22 乒乓球比赛
c语言
XLYcmy2 小时前
一个用于统计文本文件行数的Python实用工具脚本
开发语言·数据结构·windows·python·开发工具·数据处理·源代码
方便面不加香菜2 小时前
数据结构--链式结构二叉树
c语言·数据结构
4311媒体网2 小时前
自动收藏功能的实现方法
java·开发语言
xyq20242 小时前
SQLite 创建表
开发语言
Tansmjs2 小时前
C++中的工厂模式变体
开发语言·c++·算法
naruto_lnq2 小时前
多平台UI框架C++开发
开发语言·c++·算法