【C 语言数据结构】单向链表核心操作全解析 + 实战踩坑总结

文章目录

  • 前言
  • [一、数组 vs 链表:优缺点对比](#一、数组 vs 链表:优缺点对比)
  • 二、单向链表基础定义
    • [2.1 带头结点的设计](#2.1 带头结点的设计)
    • [2.2 头文件定义(linklist.h)](#2.2 头文件定义(linklist.h))
  • 三、核心操作代码实现与详解
    • [3.1 创建空链表](#3.1 创建空链表)
    • [3.2 判断链表是否为空](#3.2 判断链表是否为空)
    • [3.3 头插法插入节点](#3.3 头插法插入节点)
    • [3.4 尾插法插入节点](#3.4 尾插法插入节点)
    • [3.5 遍历打印链表](#3.5 遍历打印链表)
    • [3.6 链表反转(头插反转法)](#3.6 链表反转(头插反转法))
    • [3.7 链表冒泡排序(交换数据版)](#3.7 链表冒泡排序(交换数据版))
    • [3.8 按值查找节点](#3.8 按值查找节点)
    • [3.9 按值删除节点](#3.9 按值删除节点)
    • [3.10 销毁链表](#3.10 销毁链表)
  • 四、实战高频踩坑总结
    • [1. 空指针解引用引发段错误(SIGSEGV)](#1. 空指针解引用引发段错误(SIGSEGV))
    • [2. 指针修改顺序错误导致断链](#2. 指针修改顺序错误导致断链)
    • [3. 冒泡排序外层条件写错](#3. 冒泡排序外层条件写错)
    • [4. 销毁链表只释放不置空](#4. 销毁链表只释放不置空)
  • 五、学习总结

前言

本文是单向链表的入门学习笔记,基于 C 语言、带头结点的设计实现,覆盖创建、插入、遍历、反转、排序、删除、销毁全流程核心操作,并结合实际调试中遇到的段错误、逻辑漏洞等问题整理踩坑指南。


一、数组 vs 链表:优缺点对比

线性表有两种经典存储方式:顺序存储(数组)和链式存储(链表),二者特性互补,适用场景不同。

表格

维度 数组(顺序表) 单向链表
空间特性 内存空间连续,提前分配固定大小 内存空间不连续,节点动态申请,按需扩容
访问效率 随机访问效率高,下标访问时间复杂度 O (1) 只能顺序遍历访问,查找时间复杂度 O (n)
插入 / 删除 效率低,需要移动大量元素,平均 O (n) 找到目标位置后,修改指针即可,时间复杂度 O (1)
空间限制 元素数量有上限,扩容麻烦,易造成空间浪费 只要内存足够,理论上无元素上限
额外开销 无额外指针开销,空间利用率高 每个节点需要存储指针域,增加空间开销

总结:频繁查找、数据量固定选数组;频繁插入删除、数据量动态变化选链表。


二、单向链表基础定义

2.1 带头结点的设计

本文所有代码均采用带头结点的单向链表设计:

  • 头结点不存储有效数据,仅作为链表的入口标识;
  • 头结点的 pNext 指向第一个有效数据节点;
  • 优势:统一空链表和非空链表的操作逻辑,减少边界判断。

2.2 头文件定义(linklist.h)

复制代码
#ifndef __LINKLIST_H__
#define __LINKLIST_H__

typedef int DataType;

// 链表节点结构体
typedef struct node{
    DataType Data;          // 数据域
    struct node *pNext;     // 指针域,指向后继节点
} Node_t;

// 函数声明
extern Node_t *CreateEmptyLinkList(void);
extern int IsEmptyLinkList(Node_t *pTmpList);
extern int InsertHeadLinkList(Node_t *pTmpList, DataType TmpData);
extern int InsertTailLinkList(Node_t *pTmpList, DataType TmpData);
extern void ShowLinkList(Node_t *pTmpList);
extern Node_t *FindLinkList(Node_t *pTmpList, DataType TmpData);
extern int DeleteLinkList(Node_t *pTmpList, DataType TmpData);
extern int ReverseLinkList(Node_t *pTmpList);
extern int BubbleSortLinkList(Node_t *pTmpList);
extern void DestroyLinkList(Node_t **ppTmpList);

#endif

三、核心操作代码实现与详解

3.1 创建空链表

功能:申请头结点,初始化后继为空,返回头结点地址。

复制代码
Node_t *CreateEmptyLinkList(void)
{
    Node_t *pTmpNode = (Node_t *)malloc(sizeof(Node_t));
    if(NULL == pTmpNode)
    {
        printf("malloc failed!\n");
        return NULL;
    }
    pTmpNode->pNext = NULL;
    return pTmpNode;
}

3.2 判断链表是否为空

复制代码
int IsEmptyLinkList(Node_t *pTmpList)
{
    if(NULL == pTmpList) return 1;
    return pTmpList->pNext == NULL ? 1 : 0;
}

3.3 头插法插入节点

核心逻辑:新节点插入到头结点之后,成为第一个有效节点。

复制代码
int InsertHeadLinkList(Node_t *pTmpList, DataType TmpData)
{
    if(NULL == pTmpList) return -1;
    
    Node_t *pTmpNode = (Node_t *)malloc(sizeof(Node_t));
    if(NULL == pTmpNode)
    {
        printf("malloc failed!\n");
        return -1;
    }
    pTmpNode->Data = TmpData;

    // 头插两步法:先连后面,再连头结点
    pTmpNode->pNext = pTmpList->pNext;
    pTmpList->pNext = pTmpNode;
    return 0;
}

注意:顺序不能颠倒。如果先修改头结点的指针,会丢失原有链表,造成断链。

3.4 尾插法插入节点

核心逻辑:遍历找到最后一个节点,将新节点挂在尾部。

复制代码
int InsertTailLinkList(Node_t *pTmpList, DataType TmpData)
{
    if(NULL == pTmpList) return -1;

    Node_t *pTmpNode = (Node_t *)malloc(sizeof(Node_t));
    if(NULL == pTmpNode)
    {
        printf("malloc failed!\n");
        return -1;
    }
    pTmpNode->Data = TmpData;
    pTmpNode->pNext = NULL;

    // 遍历找到尾节点
    Node_t *pCur = pTmpList;
    while(pCur->pNext != NULL)
    {
        pCur = pCur->pNext;
    }
    pCur->pNext = pTmpNode;
    return 0;
}

3.5 遍历打印链表

复制代码
void ShowLinkList(Node_t *pTmpList)
{
    if(NULL == pTmpList)
    {
        printf("链表不存在!\n");
        return;
    }
    Node_t *pCur = pTmpList->pNext;
    while(pCur != NULL)
    {
        printf("%d ", pCur->Data);
        pCur = pCur->pNext;
    }
    printf("\n");
}

3.6 链表反转(头插反转法)

核心思想:不申请新节点,复用原有节点,依次拆出原链表节点,用头插法重新插入到头结点后,实现就地反转。

复制代码
int ReverseLinkList(Node_t *pTmpList)
{
    if(NULL == pTmpList)
    {
        printf("头结点为空,无法反转!\n");
        return -1;
    }

    Node_t *pTmpNode = NULL;    // 遍历原链表的指针
    Node_t *pInsertNode = NULL; // 待插入的节点

    pTmpNode = pTmpList->pNext; // 指向第一个有效节点
    pTmpList->pNext = NULL;     // 头结点后置空,构建空的新链表

    while(pTmpNode != NULL)
    {
        pInsertNode = pTmpNode;               // 锁定当前要移动的节点
        pTmpNode = pTmpNode->pNext;           // 遍历指针先后移,防止断链
        pInsertNode->pNext = pTmpList->pNext; // 新节点接上现有链表
        pTmpList->pNext = pInsertNode;        // 头结点指向新节点
    }
    return 0;
}

关键口诀:先存后移,先连后接。遍历指针必须在修改指针域之前后移,否则会丢失后续节点。

3.7 链表冒泡排序(交换数据版)

核心思想 :相邻节点两两比较,大值向后 "冒泡",用 pEnd 标记有序区边界,每轮收缩一次。

注意:常见错误是外层循环终止条件写错,会导致排序不完整。

复制代码
int BubbleSortLinkList(Node_t *pTmpList)
{
    if(NULL == pTmpList || NULL == pTmpList->pNext || NULL == pTmpList->pNext->pNext)
    {
        return 0; // 空链表或只有一个节点,无需排序
    }

    Node_t *pTmpNode1 = NULL;
    Node_t *pTmpNode2 = NULL;
    Node_t *pEnd = NULL; // 有序区起点,每轮向前收缩
    DataType TmpData;

    // 正确外层条件:pEnd未收缩到第一个有效节点就继续
    while(pEnd != pTmpList->pNext)
    {
        pTmpNode1 = pTmpList->pNext;
        pTmpNode2 = pTmpNode1->pNext;

        while(pTmpNode2 != pEnd)
        {
            if(pTmpNode1->Data > pTmpNode2->Data)
            {
                // 交换数据域,不移动节点
                TmpData = pTmpNode1->Data;
                pTmpNode1->Data = pTmpNode2->Data;
                pTmpNode2->Data = TmpData;
            }
            pTmpNode1 = pTmpNode1->pNext;
            pTmpNode2 = pTmpNode2->pNext;
        }
        pEnd = pTmpNode1; // 本轮最大值沉底,更新有序边界
    }
    return 0;
}

3.8 按值查找节点

复制代码
Node_t *FindLinkList(Node_t *pTmpList, DataType TmpData)
{
    if(NULL == pTmpList) return NULL;

    Node_t *pCur = pTmpList->pNext;
    while(pCur != NULL)
    {
        if(pCur->Data == TmpData)
        {
            return pCur;
        }
        pCur = pCur->pNext;
    }
    return NULL;
}

3.9 按值删除节点

复制代码
int DeleteLinkList(Node_t *pTmpList, DataType TmpData)
{
    if(NULL == pTmpList || IsEmptyLinkList(pTmpList))
    {
        return -1;
    }

    Node_t *pPre = pTmpList;
    Node_t *pCur = pTmpList->pNext;

    while(pCur != NULL)
    {
        if(pCur->Data == TmpData)
        {
            pPre->pNext = pCur->pNext; // 跨过待删节点
            free(pCur);                // 释放内存
            return 0;
        }
        pPre = pCur;
        pCur = pCur->pNext;
    }
    return -1; // 未找到目标节点
}

3.10 销毁链表

注意:需要传入二级指针,才能在函数内部把主函数的头指针置空,避免野指针。

复制代码
void DestroyLinkList(Node_t **ppTmpList)
{
    if(NULL == ppTmpList || NULL == *ppTmpList) return;

    Node_t *pCur = *ppTmpList;
    Node_t *pNext = NULL;

    while(pCur != NULL)
    {
        pNext = pCur->pNext;
        free(pCur);
        pCur = pNext;
    }
    *ppTmpList = NULL; // 头指针置空,防止野指针
}

四、实战高频踩坑总结

1. 空指针解引用引发段错误(SIGSEGV)

典型场景 :调用 DestroyLinkList 销毁链表后,头指针已被置空,后续继续调用反转、打印等函数,直接访问 NULL->pNext 触发崩溃。 解决

  • 所有链表操作函数开头增加入参判空;
  • 销毁链表后,不要再对链表进行任何操作。

2. 指针修改顺序错误导致断链

头插、反转操作中,如果先修改前驱节点的指针,再移动遍历指针,会丢失后续所有节点。 规则:永远先保存下一个节点地址 / 先移动遍历指针,再修改指针域。

3. 冒泡排序外层条件写错

错误写法 while(pEnd != pTmpList->pNext->pNext) 会导致循环提前终止,排序不完整; 正确终止边界应为第一个有效节点,保证前两个元素有多轮比较机会。

4. 销毁链表只释放不置空

只 free 节点,不把主函数的头指针置空,会留下野指针,后续访问触发不可预期错误。 规范写法 :销毁函数使用二级指针,结束时 *ppList = NULL


五、学习总结

  1. 单向链表的核心是指针操作 ,所有操作本质都是修改节点的 pNext 指向;
  2. 带头结点设计能大幅简化边界判断,是工程中最常用的实现方式;
  3. 调试链表问题优先用 GDB 查看指针地址,定位空指针、断链问题;
  4. 内存操作遵循 "谁申请谁释放" 原则,销毁链表必须完整释放所有节点并置空头指针。
相关推荐
峥无4 小时前
从0到1手撕红黑树:封装实现 my_map 与 my_set(SGI-STL 源码级深度解析)
开发语言·c++·笔记·算法·stl
wp123_14 小时前
IPX8 防水 Type‑C连接器:安费诺 124018792112A 与 TONEVEE TY48087‑24A 技术梳理
c语言·开发语言
不会代码的小猴4 小时前
3. 控件学习1
开发语言·c++·笔记·qt·算法
疯狂打码的少年6 小时前
【数据结构】图的存储结构:邻接矩阵与邻接表
数据结构·笔记
203号居民6 小时前
LeetCode hot 100 —41. 缺失的第一个正数
数据结构·算法·leetcode
蒸蒸yyyyzwd6 小时前
cpp 选手准备秋招学习笔记 day10
笔记·面试·求职招聘
haerapicom7 小时前
欠定方程里藏着哪几个非零项:OMP 稀疏恢复侦探笔记
笔记
程思扬7 小时前
Android ANR实战排查:主线程SystemClock.sleep导致页面启动偶现无响应
android·笔记
zjnlswd8 小时前
C#学习笔记4
笔记·学习·c#
minglie18 小时前
在linux环境烧录esp8266
学习