数据结构2:单链表 - 教程

news/2025/11/15 19:51:35/文章来源:https://www.cnblogs.com/gccbuaa/p/19226134

目录

前言:

一、链表的分类

二、单链表

三、单链表的功能实现

3.1单链表的初始化

3.2单链表的打印

3.3单链表的尾插

3.4单链表的头插

3.5单链表的尾删

3.6单链表的头删

3.7单链表的查找

3.8在指定位置之前插入数据

3.9在指定位置之后插入数据

3.10删除pos节点

3.11删除pos之后的节点

3.12单链表的销毁

四、单链表(通讯录)

五、双向链表

5.1双向链表的初始化

5.2双向链表的打印

5.3双向链表的尾插

5.4双向链表的头插

5.5双向链表的尾删

5.6双向链表的头删

5.7双向链表的查找

5.8在pos节点前插入数据

5.9删除pos节点

5.10销毁双向链表

六、接口一致性


前言:

最好与上一节一起学习

上一节,我们分析了顺序表的底层逻辑,并基于此开发了则增加、删除、查找等的功能但顺序表也有很多缺点:中间和头部插入效率低下,增容降低运行效率,增容造成空间浪费......

为了解决这些问题就需要我们这次探讨的内容:链表

一、链表的分类

链表的结构非常多样,以下情况组合起来就有8种

带头:指的是链表中有哨兵位的节点,该哨兵位就的头节点(在下面单链表的实现中我们提到的头节点实际上是第一个有效节点,这样错误的称呼为了方便理解)

单向只能从一个方向遍历,双向可以从两个方向遍历

循环尾节点不为空

单链表是单向不带头不循环链表,双向链表是带头双向循环链表

二、单链表

链表也是线性表的一种,它们三个在物理和逻辑结构上是否线性如下

物理结构上是否线性逻辑结构上是否线性

线性表

不一定

顺序表
链表

我们可以把链表形象的看作一个火车,它是由一节一节车厢组成的,还可以根据需要增加或减少车厢。链表是由一个一个的节点组成,我们想往链表里储存数据就只需要申请一个一个空间然后加在链表里就好了。

链表的节点是由两部分组成,存储的数据和指向下一个节点的指针

三、单链表的功能实现

在顺序表中强调的部分将不再过多赘述

3.1单链表的初始化

我们想要定义单链表就是要定义链表的节点的结构,定义好结构就可以让它们像手拉手一样连接起来。节点的定义类型是SListNode所以指向下一个节点的指针的类型是SListNode*。

//定义节点的结构
//数据+指向下一个节点的指针
typedef int SLTDataType;
typedef struct SListNode
{int data;struct SListType* next;
}SLTNode;

申请节点需要用到malloc函数,记得注意申请失败的情况,而且要定义节点的内容(我们还不知道申请的这个节点的下一个节点是什么,所以NULL)

//申请新节点
SLTNode* SLTBuyNode(SLTDataType x)
{SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));if (newnode == NULL){perror("malloc fail!");exit(1);}newnode->data = x;newnode->next = NULL;
}

3.2单链表的打印

只要pcur不为NULL,while会一直循环,在while循环里打印pcur->next的值,再通过pcur = pcur->next(下一个节点的指针)使pcur移动到下一个节点,直到pcur移动到最后一个节点就退出循环,最后我们手动加上链表最后的空指针NULL。

//打印
void SLTPrint(SLTNode* phead)
{SLTNode* pcur = phead;while (pcur)//pcur != NULL;{printf("%d->", pcur->data);pcur = pcur->next;}printf("NULL\n");
}

3.3单链表的尾插

这里我们实现尾插功能时传递数据要用到二级指针,这里是传值和传地址的区别:如果传值,函数接收的是参数的副本,修改副本不会影响原变量,如同“抄作业”,改抄本不改变原本。但传地址(传引用),函数接收的是参数的内存地址,修改时直接操作原变量,如同“给钥匙”,对方能直接改动你家的东西。所以传值适用于无需修改原变量的场景,如计算两数之和,安全但会额外占用内存。传地址适用于需要修改原变量的场景,如交换两个变量的值,高效且不占用额外内存,但需谨慎操作避免意外修改。

总结来说,如果我们的代码会改变参数就传地址,不改变就传值。

我们的尾插逻辑是先找到尾节点再将尾节点和新节点连接起来。但我们先要判断链表存在才能尾插,然后链表存在还有判断链表里有没有数据,没有就直接插入,有的话就先找到尾节点再将尾节点和新节点连接起来

//尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{assert(pphead);//SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));SLTNode* newnode = SLTBuyNode(x);//空链表和非空链表if (*pphead == NULL){*pphead = newnode;}else{//找到尾节点SLTNode* ptail = *pphead;while (ptail->next != NULL){ptail = ptail->next;}//ptail指向的是尾节点//尾节点和新节点相连ptail->next = newnode;}
}

3.4单链表的头插

首先要申请一个新节点,然后要把新节点与原来链表的头节点链接在一起。我们先要判断链表存在才能头插,头插时链表里有没有数据对插入没有影响。

//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{assert(pphead);SLTNode* newnode = SLTBuyNode(x);//newnode  *ppheadnewnode->next = *pphead;*pphead = newnode;
}

3.5单链表的尾删

尾删就是先找尾节点,把尾节点释放的同时还要把尾节点前一个节点的next指向NULL,我们先要判断链表存在并且链表不为空(为空的话删谁呢)才能尾删。如果链表只有一个节点就更特殊,直接释放那一个节点,然后置为NULL。

//尾删
void SLTPopBack(SLTNode** pphead)
{assert(pphead);assert(*pphead);//链表不能为空//链表只有一个节点if ((*pphead)->next == NULL){free(*pphead);*pphead = NULL;}//链表有多个节点else{SLTNode* prev = *pphead;SLTNode* ptail = *pphead;while (ptail->next){prev = ptail;ptail = ptail->next;}//ptail 找到尾节点free(ptail);ptail = NULL;prev->next = NULL;}
}

3.6单链表的头删

头删要先把第二个节点的地址存储起来再释放头节点最后把第二个节点定义为头节点,我们先要判断链表存在并且链表不为空(为空的话删谁呢)才能头删。

//头删
void SLTPopFront(SLTNode** pphead)
{assert(pphead);assert(*pphead);SLTNode* next = (*pphead)->next;free(*pphead);*pphead = next;
}

3.7单链表的查找

查找就是遍历链表,让节点中储存的data和x进行比较

//查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{SLTNode* pcur = phead;while (pcur){if (pcur->data == x){return pcur;}pcur = pcur->next;}//pcur == NULLreturn NULL;
}

3.8在指定位置之前插入数据

我们先要找到pos前一个节点,让前一个节点指向新节点,再让新节点指向pos节点。我们要先判断链表必须存在,并且不能为空链表,否则pos根本就不存在。但还要一个特别情况就是只有一个节点,pos就是头节点,所以这就相当于头插。

//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{assert(pphead);assert(*pphead);assert(pos);SLTNode* newnode = SLTBuyNode(x);//若pos == *pphead,说明是头插if (pos == *pphead){SLTPushFront(pphead, x);}else{SLTNode* prev = *pphead;while (prev->next != pos){prev = prev->next;}newnode->next = pos;prev->next = newnode;}
}

3.9在指定位置之后插入数据

我们直接让新节点指向pos的下一个节点,让pos指向新节点。这里与上一个相比不需要头节点是因为用不到,在指定位置之前插入数据我们需要找到pos之前的节点,但在指定位置之后插入数据我们要找的是pos之后的节点,可以直接用pos找到。

//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{assert(pos);SLTNode* newnode = SLTBuyNode(x);newnode->next = pos->next;pos->next = newnode;
}

3.10删除pos节点

我们要先找到pos前一个节点,让pos前一个节点指向pos后一个节点,最后释放pos节点。我们要先判断链表必须存在,并且不能为空链表,否则pos根本就不存在。但还要一个特别情况就是只有一个节点,pos就是头节点,所以这就相当于头删。

//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{assert(pphead);assert(*pphead);assert(pos);//pos是头节点if (pos == *pphead){//头删SLTPopFront(pphead);}else{SLTNode* prev = *pphead;while (prev->next != pos){prev = prev->next;}prev->next = pos->next;free(pos);pos = NULL;}
}

3.11删除pos之后的节点

我们需要不断释放pos之后的节点。这里与上一个相比不需要头节点是因为用不到,删除pos节点需要找到pos前一个和后一个节点,但删除pos之后的节点需要找到pos之后的节点和pos之后节点的之后节点。

//删除pos之后节点
void SLTEraseAfter(SLTNode* pos)
{assert(pos);assert(pos->next);SLTNode* del = pos->next;//pos del del->nextpos->next = del->next;free(del);del = NULL;
}

3.12单链表的销毁

从前往后释放节点,最后别忘记把头节点置为NULL。我们要先判断链表必须存在,并且不能为空链表,否则没东西释放(释放NULL报错)。

//销毁链表
void SListDesTroy(SLTNode** pphead)
{assert(pphead);assert(*pphead);SLTNode* pcur = *pphead;while (pcur){SLTNode* next = pcur->next;free(pcur);pcur = next;}*pphead = NULL;
}

四、单链表(通讯录)

上一节我们用顺序表实现了通讯录,这次我们也可以用单链表实现。具体每个功能的实现逻辑和顺序表相同,所以这次也不再过多赘述。完整代码大家也可以到我的仓库里自行查看。链接:单链表(通讯录) · 15be83d · 陈陈陈陈/数据结构 - Gitee.com

五、双向链表

5.1双向链表的初始化

我们想要定义双向链表就是要定义双向链表的节点的结构,定义好结构就可以让它们像手拉手一样连接起来。

typedef int LTDataType;
//定义双向链表的结构
typedef struct ListNode
{LTDataType data;struct ListNode* next;struct ListNode* prev;
}LTNode;

我们需要注意的是单链表在最开始通常是一个空链表(phead = NULL),但双向链表必须有一个哨兵位的存在,因此在插入数据前链表中必须初始化到只有一个头指针的情况。申请节点需要用到malloc函数,记得注意申请失败的情况,而且要定义节点的内容(我们还不知道申请的这个节点的下一个节点和上一个节点是什么,但不能指向NULL,因为一旦指向NULL链表的情况就如下,不符合我们对双链表的定义,所以为了让它保持循环状态,就需要让next和prev指向自己)后面申请的节点初始状态都是一个类似于自循环的状态。

//申请节点
LTNode* LTBuyNode(LTDataType x)
{LTNode* node = (LTNode*)malloc(sizeof(LTNode));if (node == NULL){perror("malloc fail!");exit(1);}node->data = x;node->next = node->prev = node;return node;
}
// 初始化
void LTInit(LTNode** pphead)
{//创建一个哨兵位*pphead = LTBuyNode(-1);
}

5.2双向链表的打印

打印要遍历双向链表,由于双向链表是循环的,所以我们以头节点作为跳出循环的条件。

void LTPrint(LTNode* phead)
{LTNode* pcur = phead->next;while (pcur != phead){printf("%d->", pcur->data);pcur = pcur->next;}printf("\n");
}

5.3双向链表的尾插

我们要注意哨兵位节点不能被删除,而且节点的地址不能发生改变。所以我们在进行尾插或者头插时使用一级指针就可以了。这也是因为一级指针足以直接操作尾节点的内部成员,无需二级指针来修改指针本身的指向。

如果不能理解的话我们可以举个例子帮助理解

简单说:双向链表尾插用一级指针,是因为我们只需要改“节点里的指针”,不用改“指向节点的指针本身”,一级指针就够了。

你可以把链表想象成一串手拉手的人:每个人(节点)有两只手,一只拉前面的人(next指针),一只拉后面的人(prev指针)。tail指针就像你手指着最后一个人,是“指向人的指针”。尾插新节点,就像在最后一个人后面加个新朋友:

    1.    新朋友先伸手拉住最后一个人(新节点的prev指向tail)。

    2.    你通过tail(一级指针),让最后一个人伸手拉住新朋友(tail->next指向新节点)。

    3.    最后把你手指的方向挪到新朋友身上(更新tail指向新节点)。

全程你只需要“指挥最后一个人伸手”(改节点里的指针),和“挪自己手指”(改tail的值),这两件事用一级指针都能做到,根本不用更复杂的二级指针。
尾插的过程就是先让新指针连上哨兵位(phead)和原链表的最后一个节点(phead->prev),再让原链表的最后一个节点(phead->prev)指向新节点,最后让哨兵位(phead)指向新节点。

//尾插
void LTPushBack(LTNode* phead, LTDataType x)
{assert(phead);LTNode* newnode = LTBuyNode(x);newnode->prev = phead->prev;newnode->next = phead;phead->prev->next = newnode;phead->prev = newnode;
}

5.4双向链表的头插

头插是往链表里第一个有效的节点前插入,头插的过程先让新指针连上哨兵位(phead)和原链表的第一个有效节点(phead->next),再让原链表的第一个有效节点(phead->next)指向新节点,最后让哨兵位(phead)指向新节点。

//头插
void LTPushFront(LTNode* phead, LTDataType x)
{assert(phead);LTNode* newnode = LTBuyNode(x);newnode->prev = phead;newnode->next = phead->next;phead->next->prev = newnode;phead->next = newnode;
}

5.5双向链表的尾删

尾删链表必须有效而且不能为空,尾删的过程要让原链表的最后一个节点的前一个节点(del->prev)指向哨兵位,再让哨兵位指向它。

//尾删
void LTPopBack(LTNode* phead)
{
//链表必须有效且不能为空assert(phead && phead->next != phead);LTNode* del = phead->prev;del->prev->next = phead;phead->prev = del->prev;free(del);del = NULL;
}

5.6双向链表的头删

头删链表必须有效而且不能为空,尾删的过程要让原链表的第一个有节点的后一个节点(del->next)指向哨兵位,再让哨兵位指向它。

//头删
void LTPopFront(LTNode* phead)
{assert(phead && phead->next != phead);LTNode* del = phead->next;phead->next = del->next;del->next->prev = phead;free(del);del = NULL;
}

5.7双向链表的查找

就是一个遍历的过程

LTNode* LTFind(LTNode* phead, LTDataType x)
{LTNode* pcur = phead->next;while (pcur != phead){if (pcur->data == x){return pcur;}}return NULL;
}

5.8在pos节点前插入数据

插入过程要先让新节点指向pos节点和pos前一个节点(pos->perv),再让它们指向新节点。

//在pos之后插入数据
void LTInsert(LTNode* pos, LTDataType x)
{assert(pos);LTNode* newnode = LTBuyNode(x);newnode->prev = pos;newnode->next = pos->next;pos->next->prev = newnode;pos->next = newnode;
}

5.9删除pos节点

删除过程就是让pos节点的前后两个节点相连。

//删除pos节点
void LTErase(LTNode* pos)
{assert(pos);pos->prev->next = pos->next;pos->next->prev = pos->prev;free(pos);pos = NULL;
}

5.10销毁双向链表

也是一个遍历的过程。

//销毁链表
void LTDesTroy(LTNode* phead)
{assert(phead);LTNode* pcur = phead->next;while (pcur != phead){LTNode* next = pcur->next;free(pcur);pcur = next;}free(phead);phead = NULL;
}

六、接口一致性

其实我们可以注意到在5.9删除pos节点中我们改变了pos的地址,把其置为了NULL,所以理论上应该用二级指针才对。这里还是用的一级指针,一方面是这里pos不影响后续代码,还有一方面是为了保持接口一致性。

保持接口一致性的核心目的是降低使用成本、减少出错概率、提升代码的可维护性,具体体现在这几点:

1. 降低使用者的记忆/学习成本

如果有的接口传一级指针、有的传二级指针,使用者需要额外区分“什么时候该传一级、什么时候传二级”,增加了理解和使用的复杂度。
比如代码里所有操作都传一级指针,使用者只需要记住“链表接口都传节点的一级指针”,不用额外判断,用起来更顺手。

2. 避免调用时的指针类型错误

如果接口参数类型不统一,调用时容易误传指针类型(比如该传二级指针却传了一级),导致编译错误或运行时崩溃。
统一用一级指针,能减少这类低级错误,让代码更健壮。

3. 提升代码的可维护性

当后续需要修改或扩展接口时,统一的参数类型(一级指针)能减少适配成本。比如要新增一个“在pos之前插入”的接口,直接复用“传一级指针”的模式即可,不用重新设计参数规则。

4. 匹配“有哨兵位链表”的特性

代码里的链表是**带哨兵位(固定头节点)**的结构,所有操作都不需要修改phead本身的指向(因为头节点地址固定),天然适合用一级指针。统一传一级指针,也能体现出“哨兵位链表”的设计特点,让接口和数据结构的特性保持一致。

简单说:统一用一级指针,是为了让接口更好用、更少错、更容易维护,同时也匹配了当前链表(带哨兵位)的结构特性。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/966473.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

20251115 - Hash 总结

你说得对,但我几乎从来不把哈希叫做哈希,我习惯了叫 Hash。 比赛链接:https://vjudge.net/contest/766880。 卡 Hash 的出题人都是毒瘤出题人喵!一点也不良心。 A - Barn Echoes G 由于这个长度只有 \(80\),因此随…

BZOJ2372 music

思路 两个字符串等价,相当于两个串中排名相同的字符,出现的位置相同。 于是我们哈希,分别维护每种字符的出现位置序列。 现在瓶颈在于得到每种字符的排名。 发现字符集只有 \(25\),可以直接枚举,桶排即可。然后再…

P11664 [JOI 2025 Final] 缆车 / Mi Telefrico

思路 注意到,DAG 符合条件当且仅当节点 \(2 \sim n\) 的入度都不为零。 对于一个左端点 \(l\),合法的 \(r\) 具有单调性。设最小的使 \(l\) 合法的 \(r\) 为 \(R_l\),则区间 \([q_l,q_r]\) 当 \(R_{q_l} \le q_r\) …

WPF中RelayCommand的完成与使用详解

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

C++篇(14)二叉树进阶算法题 - 详解

C++篇(14)二叉树进阶算法题 - 详解2025-11-15 19:34 tlnshuju 阅读(0) 评论(0) 收藏 举报pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !im…

Python 潮流周刊#127:Python 3.16 JIT 性能提升计划

本周刊由 Python猫 出品,精心筛选国内外的 400+ 信息源,为你挑选最值得分享的文章、教程、开源项目、软件工具、播客和视频、热门话题等内容。愿景:帮助所有读者精进 Python 技术,并增长职业和副业的收入。 温馨提…

非线性序列密码结构

NFG,NCG,NCGC,CCG关于LFSR线性反馈移位寄存器和攻击方法B-M算法参考:序列密码概述部分:https://www.cnblogs.com/luminescence/p/18938331 下面内容主要围绕对LFSR进行非线性改造的四种经典方法展开,以克服LFSR序列…

2025/11/15

2025/11/15学习算法

LoongOS 上传文件

LoongOS 上传文件一般开发板预装的系统都自带有一个ssh服务器 如果默认没打开ssh服务 通过ifconfig查看ip地址 打开ssh服务systemctl start sshd 然后用MobaXterm连接ssh并登录 先在控制台选好地方 比如 cd \test 直接…

2025年目前品质好的羊毛地毯厂家推荐

文章摘要 2025年羊毛地毯行业持续向环保化、个性化方向发展,手工编织与天然材质成为市场主流趋势。本文基于行业数据与用户口碑,深度评测当前市场上品质优异的羊毛地毯生产厂家,为采购商和消费者提供权威参考。文末…

2025年市场上品质好的羊毛地毯制造企业

摘要 2025年,羊毛地毯行业持续增长,消费者对环保、设计和品质的需求日益提升。本文基于行业数据和用户评价,为您呈现市场上品质优秀的羊毛地毯制造企业排名,并提供详细分析供参考。如果您有采购或合作意向,可通过…

基础设施即服务(IaaS)全面解析:云计算的基石

本文详细介绍了基础设施即服务(IaaS)的概念、架构、工作原理、实施要点、优缺点及主要供应商。IaaS作为云计算三大服务模式之一,通过虚拟化技术为企业提供灵活、可扩展的计算资源。什么是基础设施即服务(IaaS)? 基础…

CentOS 7 通过 Packstack 安装 OpenStack Train 完整步骤

CentOS 7 通过 Packstack 安装 OpenStack Train 完整步骤该文章详细指导如何在 CentOS 7 系统上通过 Packstack 工具安装 OpenStack Train。首先介绍 OpenStack 的定义、核心特性(开源免费、资源统一管理等)与应用场…

【STM32工程开源】基于STM32的人体健康监测环境

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

实用指南:【C# OOP 入门到精通】从基础概念到 MVC 实战(含 SOLID 原则与完整代码)

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

tailwind自定义class问题小记

非常好的问题!您提到了两个关键点: 1. @layer components 是否合适? 是的,非常合适! pure-ipt 应该定义在 @layer components 中,因为:@layer base - 用于基础样式(如 *, body, html) @layer components - 用…

2025年主流开源AI智能体框架平台概览 - 实践

2025年主流开源AI智能体框架平台概览 - 实践2025-11-15 19:06 tlnshuju 阅读(0) 评论(0) 收藏 举报pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: bl…

threading.local()的实例化机制

threading.local() 的实例化机制threading.local() 是全局实例化一次的:它在模块/类/函数级别(通常在 __init__ 或全局作用域)创建一个单一的 local 对象实例(类型为 _thread._local)。这个对象本身是共享的(所有…

Tarjan复建

塔尖踏践他荐太监肽键抬肩台站太真。写在前面: 我 \(C_aO\) 了全™的忘干净了于是步了鱼鱼的后尘开始切黄绿DP绿蓝 \(Tarjan\) 。 哎呀呀反正肝硬化不受人待见没人看就随便写写了。强连通分量: 这个没忘干净,当年为…

采用git进行项目管理

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …