STM32 C语言链表

一、链表核心概念

1.1 链表概念

链表是一种线性数据结构 ,其存储单元在物理内存中非连续、非顺序 分布。数据元素之间的逻辑顺序通过节点间的指针链接实现。每个链表由一系列节点组成,节点可以动态创建和释放。

链表的核心特点:

**节点结构:**每个节点包含两部分

复制代码
数据域:存储实际的数据元素
指针域:存储指向下一个节点的内存地址

**动态性:**节点可在程序运行时动态生成和删除

复制代码
非连续性:节点在内存中的物理位置不一定相邻
操作复杂度:相比数组的顺序结构,链表在插入和删除操作上更高效,但随机访问效率较低

1.2 链表的分类

链表可以根据三个主要维度进行分类,这些维度的不同组合形成了八种不同的链表结构:

1.2.1 方向性

单向链表

每个节点仅包含一个指针,指向它的后继节点。这种结构简单,内存开销较小,但只能单向遍历。

双向链表

每个节点包含两个指针,分别指向前驱节点和后继节点。这种结构支持双向遍历,操作更灵活,但每个节点需要额外的内存空间存储前驱指针。

1.2.2 头节点设计

带头节点

链表包含一个特殊的头节点(哨兵节点),该节点不存储有效数据,仅用于简化操作逻辑。头节点的存在使得空链表和非空链表的操作可以统一处理,避免了许多边界条件的判断。

不带头节点

链表的第一个节点直接存储有效数据。这种设计更节省空间,但在处理空链表和插入删除头节点时需要特殊处理。

1.2.3 循环性

循环链表

尾节点的指针指向头节点(在带头节点的情况下)或第一个数据节点(在不带头节点的情况下),形成一个闭环结构。这种设计使得从任意节点出发都能遍历整个链表。

非循环链表

尾节点的指针为空(NULL),表示链表结束。这是最常见的链表形式,结构直观,操作简单。

1.3 链表与顺序表的对比

1.3.1 数组(顺序表)的局限性

复制代码
int a[5] = {1, 2, 3, 4, 5};
// 需要添加元素6,7时,必须创建新数组
int b[7] = {1, 2, 3, 4, 5, 6, 7};

缺点:

大小固定,扩展需要重新分配内存

插入/删除元素需要移动大量数据

可能造成内存浪费(预留空间或频繁重分配)

1.3.2 链表的优势

动态内存管理: 只在需要时申请节点空间
高效增删: 插入和删除只需修改指针,时间复杂度O(1)(已知位置)
**空间利用率高:**按需分配,无预留空间

1.3.3 重要注意事项

链表在逻辑上是连续的,但在物理存储上不一定连续

节点通常从堆(heap)上动态申请

多次申请的内存空间可能连续,也可能不连续

二、无头单向非循环链表

2.1 数据结构定义

复制代码
// 定义数据类型别名:将int类型重命名为SLTDataType
// 优点:便于统一修改链表存储的数据类型(例如改为float、char*等),提高代码可维护性
typedef int SLTDataType;

// 定义单链表节点结构体
typedef struct SListNode {
    SLTDataType data;          // 数据域:存储节点的实际数据
    struct SListNode* next;    // 指针域:指向下一个节点的地址(后继指针)
} SLTNode;  // 结构体类型别名,简化类型声明(可直接使用SLTNode声明变量)

主要逻辑: 通过 typedef 创建了两个类型别名:SLTDataType(当前为 int 类型)用于表示节点存储的数据类型,以及 SLTNode 作为单链表节点的结构体别名。结构体内部包含两个成员:data 存储节点数据,next 是一个指向相同结构体类型的指针,用于链接下一个节点。这种设计形成了单向链式结构,每个节点只知道其后继节点,是单链表实现的基础。

2.2 基本操作实现

2.2.1 节点创建

复制代码
// 函数:创建单链表新节点
// 参数:x - 要存储在新节点中的数据
// 返回值:指向新创建节点的指针
SLTNode* BuySListNode(SLTDataType x) {
    // 动态分配内存,创建一个新的链表节点
    // malloc函数分配sizeof(SLTNode)字节的内存空间
    // 并将返回的void*指针强制转换为SLTNode*类型
    SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
    
    // 检查内存分配是否成功
    // 如果malloc返回NULL,表示内存分配失败
    if (newnode == NULL) {
        // 使用perror输出错误信息,"malloc failed"是自定义的错误提示
        perror("malloc failed");
        // 退出程序,-1表示异常退出
        exit(-1);
    }
    
    // 内存分配成功,初始化新节点的数据域
    // 将参数x的值赋给新节点的data成员
    newnode->data = x;
    
    // 初始化新节点的指针域
    // 将next指针设为NULL,表示这是链表的最后一个节点(或当前唯一的节点)
    newnode->next = NULL;
    
    // 返回新创建节点的指针
    return newnode;
}

**功能说明:**动态分配节点内存,初始化数据域和指针域。

主要逻辑: 函数首先通过malloc申请节点所需的内存空间,并检查分配是否成功------如果失败则报错并终止程序。分配成功后,将传入的数据值存储到节点的data域,同时将节点的next指针初始化为NULL(表示该节点暂时作为链表的末尾)。最后函数返回这个已初始化好的新节点指针,供后续链表操作使用。

2.2.2 尾插操作

复制代码
// 函数:在单链表尾部插入新节点
// 参数:
//   pphead - 指向链表头节点指针的指针(二级指针)
//           使用二级指针是因为可能需要修改头指针(当链表为空时)
//   x - 要插入到新节点中的数据
// 返回值:无
void SLTPushBack(SLTNode** pphead, SLTDataType x) {
    // 断言:确保传入的头指针地址不为NULL
    // 这是因为我们需要通过二级指针修改一级指针的值
    assert(pphead);
    
    // 创建新节点:调用BuySListNode函数动态分配内存并初始化新节点
    SLTNode* newnode = BuySListNode(x);
    
    // 情况1:空链表(链表没有节点)
    // 判断头指针指向的内容是否为NULL
    if (*pphead == NULL) {
        // 直接将新节点赋值给头指针
        // 这里使用*pphead来修改调用者传递的头指针
        *pphead = newnode;
    } 
    // 情况2:非空链表(链表已有节点)
    else {
        // 定义一个遍历指针tail,从头节点开始
        SLTNode* tail = *pphead;
        
        // 遍历链表,找到最后一个节点(尾节点)
        // 尾节点的特征是:next指针为NULL
        while (tail->next != NULL) {
            // 移动到下一个节点
            tail = tail->next;
        }
        
        // 将新节点连接到原尾节点的next指针
        // 此时新节点成为新的尾节点
        tail->next = newnode;
    }
}

主要逻辑: 首先通过断言确保传入的链表头指针地址有效,然后创建新节点。接着分两种情况处理:若链表为空(头指针为NULL),则直接将新节点设为头节点;若链表非空,则通过遍历找到当前尾节点(next为NULL的节点),最后将尾节点的next指针指向新节点,从而将新节点链接到链表末端。

2.2.3 头插操作

复制代码
// 函数功能:在单链表头部插入新节点
// 参数pphead:指向头节点指针的指针(二级指针),用于修改链表头指针
// 参数x:要插入的新节点的数据值
void SLTPushFront(SLTNode** pphead, SLTDataType x) {
    // 1. 断言检查:确保传入的二级指针pphead不为NULL
    //    因为我们需要通过*pphead访问链表头指针
    assert(pphead);
    
    // 2. 创建新节点:调用BuySListNode函数动态分配内存并初始化新节点
    SLTNode* newnode = BuySListNode(x);
    
    // 3. 连接新节点:将新节点的next指针指向原来的头节点
    //    这样新节点就成为了链表逻辑上的第一个节点
    newnode->next = *pphead;
    
    // 4. 更新头指针:将链表头指针修改为新创建的节点
    //    通过二级指针pphead间接修改外部传入的头指针
    *pphead = newnode;
}

主要逻辑: 函数首先对传入的二级指针进行有效性检查,然后调用辅助函数BuySListNode动态创建并初始化一个新节点。核心操作分为两步:先将新节点的next指针指向原链表的头节点,使新节点链接到原有链表之前;然后通过二级指针*pphead更新链表头指针,使其指向新节点,从而完成头插操作。

2.2.4 查找操作

复制代码
// 函数功能:在单链表中查找包含指定数据值的节点
// 参数phead:链表头指针,指向链表的第一个节点
// 参数x:要查找的数据值
// 返回值:如果找到包含x的节点,则返回该节点的指针;如果未找到,则返回NULL
SLTNode* SLTFind(SLTNode* phead, SLTDataType x) {
    // 1. 初始化遍历指针:使用临时指针cur从链表头部开始遍历
    SLTNode* cur = phead;
    
    // 2. 遍历链表:循环直到cur为NULL(到达链表末尾)
    while (cur != NULL) {
        // 3. 检查当前节点:比较当前节点的data值与目标值x
        if (cur->data == x) {
            // 4. 找到匹配:返回当前节点的指针
            return cur;
        }
        // 5. 移动到下一个节点:更新cur指针指向下一个节点
        cur = cur->next;
    }
    
    // 6. 未找到匹配:遍历完整个链表后返回NULL
    return NULL;
}

**主要逻辑:**函数从头节点开始,使用临时指针依次访问每个节点,将每个节点的数据值与目标值进行比较。如果找到匹配的节点,则立即返回该节点的指针;如果遍历完整个链表(遇到NULL指针)仍未找到匹配项,则返回NULL表示查找失败。

2.2.5 指定位置插入

复制代码
// 函数功能:在单链表的指定节点pos之前插入一个新节点
// 参数pphead:指向头指针的二级指针,用于修改链表头(当在头部插入时)
// 参数pos:要在其前面插入新节点的目标节点指针(不能为NULL)
// 参数x:要插入的新节点的数据值
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) {
    // 1. 断言检查:确保目标节点pos不为NULL
    //    因为不能在一个空节点前插入新节点
    assert(pos);
    
    // 2. 特殊情况处理:如果pos是链表的头节点
    //    此时插入操作相当于头插,直接调用头插函数
    if (pos == *pphead) {
        SLTPushFront(pphead, x);
    } 
    // 3. 一般情况处理:pos不是头节点
    else {
        // 3.1 查找前驱节点:从头节点开始遍历,找到pos节点的前一个节点
        SLTNode* prev = *pphead;
        while (prev->next != pos) { // 循环直到prev的下一个节点是pos
            prev = prev->next;      // 移动到下一个节点
        }
        
        // 3.2 创建新节点:调用BuySListNode函数创建并初始化新节点
        SLTNode* newnode = BuySListNode(x);
        
        // 3.3 插入新节点:
        //     a. 将前驱节点prev的next指向新节点
        prev->next = newnode;
        //     b. 将新节点的next指向原pos节点
        newnode->next = pos;
    }
}

主要逻辑: 首先通过断言确保目标节点pos的有效性。如果是头节点插入的特殊情况(pos等于头指针),则直接调用现有的头插函数SLTPushFront。对于一般情况,函数需要通过遍历链表找到目标节点pos的前驱节点prev,然后执行三步操作:创建新节点、将前驱节点prev的next指向新节点、将新节点的next指向pos节点。

2.2.6 链表销毁

复制代码
// 函数功能:销毁整个单链表,释放所有节点内存
// 参数pphead:指向头指针的二级指针,用于修改链表头指针(置为NULL)
void SListDestroy(SLTNode** pphead) {
    // 1. 断言检查:确保传入的二级指针pphead不为NULL
    //    因为我们需要通过*pphead访问链表头指针
    assert(pphead);
    
    // 2. 初始化遍历指针:从链表头节点开始
    SLTNode* cur = *pphead;
    
    // 3. 遍历并释放所有节点
    while (cur != NULL) {
        // 3.1 保存下一个节点:在释放当前节点前,先保存其next指针
        //     否则释放后无法访问下一个节点
        SLTNode* next = cur->next;
        
        // 3.2 释放当前节点:释放cur指向的节点内存
        free(cur);
        
        // 3.3 移动到下一个节点:继续处理已保存的下一个节点
        cur = next;
    }
    
    // 4. 头指针置空:将外部传入的头指针设置为NULL
    //    防止成为野指针,表示链表已空
    *pphead = NULL;
}

主要逻辑: 函数首先检查二级指针参数的有效性,然后通过一个临时指针cur遍历链表。在遍历过程中,关键步骤是先保存当前节点的下一个节点地址,再释放当前节点,最后移动到下一个节点继续处理,这样可以避免释放节点后无法访问后续链表。遍历完成后,通过二级指针将外部头指针置为NULL,防止出现野指针,并明确表示链表已被完全销毁。

三、带头双向循环链表

3.1 数据结构定义

复制代码
// 定义数据类型别名:将int重命名为DataType
// 优点:便于统一修改链表存储的数据类型(如改为float、char*等)
typedef int DataType;

// 定义双向链表节点结构体
typedef struct ListNode {
    struct ListNode* next;    // 后继指针:指向下一个节点的地址
    struct ListNode* prev;    // 前驱指针:指向前一个节点的地址
    DataType data;            // 数据域:存储节点实际数据
} LTNode;  // 结构体类型别名,简化类型声明(可直接使用LTNode声明变量)

主要逻辑: 通过typedef创建了DataType类型别名(当前为int类型)和LTNode结构体别名。结构体内包含三个成员:两个自引用指针(next指向后继节点,prev指向前驱节点)和一个数据域data。这种设计使得每个节点既能向前访问也能向后访问,形成了双向链接关系,为双向链表的实现提供了基础结构。

3.2 核心操作实现

3.2.1 初始化创建头节点

复制代码
// 函数功能:初始化一个带头节点的双向循环链表
// 返回值:返回创建的头节点指针(链表唯一固定节点)
LTNode* LTInit() {
    // 1. 创建头节点:调用BuyListNode函数分配内存并创建节点
    //    参数0作为头节点的数据(头节点数据域通常不存储有效业务数据,仅作占位)
    LTNode* phead = BuyListNode(0);
    
    // 2. 初始化循环结构:将头节点的前驱和后继指针都指向自己
    //    a. 头节点的next指针指向自己,表示链表为空时"下一个节点"是自己
    //    b. 头节点的prev指针也指向自己,表示链表为空时"前一个节点"是自己
    phead->next = phead;
    phead->prev = phead;
    
    // 3. 返回头节点指针
    return phead;
}

主要逻辑: 首先创建一个特殊的头节点(数据域通常存储无意义的值,这里用0占位),然后将该节点的nextprev指针都指向自身,形成一个自循环结构。这种设计使得空链表表现为一个头节点自己指向自己的闭环,后续节点可以统一插入到头节点之后,简化了双向链表的操作逻辑。头节点的存在使得链表操作时不需要特殊处理空链表情况,同时提供了固定的访问入口,而循环结构则使得从任意节点出发都能遍历整个链表。

3.2.2 任意位置插入

复制代码
// 函数功能:在双向循环链表的pos位置之前插入新节点
// 参数pos:要在其之前插入新节点的目标节点指针(不能为NULL)
// 参数x:要插入的新节点的数据值
void LTInsert(LTNode* pos, DataType x) {
    // 1. 断言检查:确保目标节点pos不为NULL
    //    因为不能在一个空节点前插入新节点
    assert(pos);
    
    // 2. 创建新节点:调用BuyListNode函数动态分配内存并初始化新节点
    LTNode* newnode = BuyListNode(x);
    
    // 3. 获取pos节点的前驱节点:pos->prev指向pos的前一个节点
    LTNode* prev = pos->prev;
    
    // 4. 四步连接操作,将新节点插入到prev和pos之间:
    //    a. 前驱节点prev的后继指针指向新节点
    prev->next = newnode;
    //    b. 新节点的前驱指针指向前驱节点prev
    newnode->prev = prev;
    //    c. 新节点的后继指针指向pos节点
    newnode->next = pos;
    //    d. pos节点的前驱指针指向新节点
    pos->prev = newnode;
}

**主要逻辑:**函数首先检查目标位置pos的有效性,然后创建新节点并获取pos的前驱节点prev。通过四个指针操作步骤将新节点插入到prev和pos之间:先将prev的next指向新节点,再将新节点的prev指向prev;然后将新节点的next指向pos,最后将pos的prev指向新节点。

3.2.3 删除操作

复制代码
// 函数功能:删除双向循环链表中指定位置pos的节点
// 参数pos:要删除的目标节点指针(不能为NULL)
void LTErase(LTNode* pos) {
    // 1. 断言检查:确保要删除的节点pos不为NULL
    assert(pos);
    
    // 2. 连接前后节点:将pos的前驱节点和后继节点直接连接
    //    a. pos前驱节点的next指针指向pos的后继节点
    //       使前驱节点跳过pos,直接连接到后继节点
    pos->prev->next = pos->next;
    
    //    b. pos后继节点的prev指针指向pos的前驱节点
    //       使后继节点跳过pos,直接连接到前驱节点
    pos->next->prev = pos->prev;
    
    // 3. 释放节点内存:删除pos节点,释放其占用的内存空间
    free(pos);
}

**主要逻辑:**函数首先验证目标节点pos的有效性,然后执行两步关键指针操作:将pos前驱节点的next指针指向pos的后继节点,同时将pos后继节点的prev指针指向pos的前驱节点。这样就在链表中"跳过"了pos节点,使前后节点直接相连。最后释放pos节点的内存完成删除。由于双向循环链表的特性,这种删除操作不需要特殊处理边界条件(如头尾节点),因为所有节点都有前驱和后继(即使是头节点在循环链表中也有前驱)。但需要注意:实际使用中通常不应删除头节点(哨兵节点),否则会破坏链表结构。

3.2.4 链表遍历

复制代码
// 函数功能:打印带头节点的双向循环链表的内容
// 参数phead:链表的头节点指针(不能为NULL,且链表是带头节点的双向循环链表)
void LTPrint(LTNode* phead) {
    // 1. 断言检查:确保头节点指针phead不为NULL
    //    因为需要从头节点开始遍历链表
    assert(phead);
    
    // 2. 初始化遍历指针:从第一个实际存储数据的节点开始(即头节点的下一个节点)
    //    注意:头节点本身不存储有效数据(或存储的数据通常不打印)
    LTNode* cur = phead->next;
    
    // 3. 打印提示信息
    printf("链表内容: ");
    
    // 4. 遍历链表:当cur指针回到头节点phead时,说明已经遍历完一圈
    //    注意:双向循环链表的遍历终止条件是回到头节点(即所有节点遍历一次)
    while (cur != phead) {
        // 5. 打印当前节点的数据
        printf("%d ", cur->data);
        
        // 6. 移动到下一个节点
        cur = cur->next;
    }
    
    // 7. 打印换行,使输出更清晰
    printf("\n");
}

**主要逻辑:**从第一个实际数据节点(头节点的后一个节点)开始遍历,直到回到头节点为止。在遍历过程中,打印每个节点的数据值。由于使用了循环链表结构,遍历的终止条件是当前节点等于头节点,这意味着已经遍历完所有有效数据节点(不包括头节点本身)。

相关推荐
卷毛迷你猪2 小时前
小肥柴慢慢手写数据结构(C篇)(2.1.1 动态数组(ArrayList))
c语言·数据结构
StandbyTime2 小时前
C语言学习-菜鸟教程C经典100例-练习28
c语言
Yupureki2 小时前
《算法竞赛从入门到国奖》算法基础:入门篇-离散化
c语言·数据结构·c++·算法·visual studio
散峰而望2 小时前
OJ 题目的做题模式和相关报错情况
java·c语言·数据结构·c++·vscode·算法·visual studio code
疋瓞2 小时前
C/C++查缺补漏《5》_智能指针、C和C++中的数组、指针、函数对比、C和C++中内存分配概览
java·c语言·c++
程序员-King.2 小时前
day140—前后指针—删除排序链表中的重复元素Ⅱ(LeetCode-82)
数据结构·算法·leetcode·链表
黎雁·泠崖2 小时前
Java数组进阶:内存图解+二维数组全解析(底层原理+Java&C差异对比)
java·c语言·开发语言
StandbyTime2 小时前
C语言学习-菜鸟教程C经典100例-练习30
c语言
广药门徒3 小时前
为什么访问一地址存16bits的存储芯片需要字节对齐?为什么访问外部Flash需要字节对齐?——深入理解STM32 FMC的地址映射机制
stm32·单片机·嵌入式硬件