与其临渊羡鱼,不如退而结网。💓💓💓
目录
•✨说在前面
亲爱的读者们大家好!💖💖💖,我们又见面了,在之前的学习中我们了解了什么是数据结构以及顺序表的基本操作,而且还用顺序表做了通讯录的小项目。然而,顺序表并不是完美的,它的操作容易浪费空间,甚至降低程序的运行效率。我们今天要学习的链表,在这些地方就优于顺序表。
如果你没有准备好的话,或者说你还没有办法独立完成顺序表功能的代码,希望你先回去看看顺序表部分的内容,确认自己没有问题之后再来看这篇文章。
👇👇👇
💘💘💘知识连线时刻(直接点击即可)🎉🎉🎉复习回顾🎉🎉🎉
博主主页传送门:愿天垂怜的博客
🍋知识点一:什么是链表?
• 🌰1.链表的概念
定义通讯录顺序表链表是一种线性数据结构,由一系列的节点组成,每个节点都包含数据和指向下一个节点的指针。
链表的元素在内存中不必连续排列,而是通过指针相互连接。
链表的结构和火车箱相似,淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节,只需要将火车的某节车厢去掉/加上,不会影响其他车厢,每节车厢都是独立存在的。
设每节车厢的车门都是锁上的状态,需要不同的钥匙才能解锁,每次只能携带一把钥匙的情况下如何从车头走到车尾?
最简单的做法:每节车厢里都放一把下一节车厢的钥匙。
在链表里,每节"车厢"是什么样的呢?
与顺序表不同的是,链表里的每节"车厢"都是独立申请下来的空间,我们称之为"结点/节点"。节点的组成主要有两个部分:当前节点要保存的数据和保存下一个节点的地址(指针变量)。
图中指针变量 plist 保存的是第一个节点的地址,我们称 plist 此时"指向"第一个节点,如果希望 plist "指向"第二个节点时,只需要修改 plist 保存的内容为 0x0012FFA0。
📌为什么还需要指针变量来保存下一个节点的位置?
链表中每个节点都是独立申请的(即需要插入数据时才申请一块节点的空间),我们需要通过指针变量来保存下一个节点位置才能从当前节点找到下一个节点。
• 🌰2.链表的结构
链表的基本结构由节点组成,每个节点包含数据和执行下一个节点的指针。
结合之前结构体的知识,我们可以写出节点数据为整型的链表:
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;//数据域
struct SListNode* next;//指针域
}SLTNode;
• 🌰3.链表的分类
链表可以分为单向链表、双向链表和循环链表。
🎉单向链表:每个节点只有一个指针指向下一个节点。
🎉双向链表:每个节点有两个指针,分别指向前一个节点和后一个节点。
🎉循环链表:尾节点指向头结点。
链表的结构非常多样,以下情况组合起来就有8种(2 x 2 x 2) 链表结构:
链表说明:
🍋知识点二:单链表
• 🌰1.顺序表的劣势
🎉中间/头部的插入删除,时间复杂度为O(N)
🎉增容需要申请新空间,拷贝数据,释放旧空间,会有不小的消耗。
🎉增容一般是2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,我们再进行插入了5个数据,后面就没有数据插入了,那么就浪费了95个数据空间。
总结:
中间/头部插入删除效率低下、增容降低运行效率、增容造成空间浪费。
然而我们接下来要学习的链表就完美地解决了顺序表的问题。
• 🌰2.单链表动态申请节点
单链表申请节点的过程其实就是初始化的过程,也就是说单链表不需要单独初始化,需要用单链表的时候我们直接申请节点就可以了,当然,不论是头插、尾插还是指定位置插入都离不来申请节点的操作,所以我们将它写成一个函数:
SLTNode* SLTBuyNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
申请一个结点newnode,首先if判空,不为空就将x存入数据域data,再让newnode的next指针置为NULL,因为没有下一个节点,所以必须置空,否则为野指针。
• 🌰3.链表元素的打印
注意:链表和顺序表是有所不同的,顺序表传过来的指针是肯定不会为空的,而链表传过来的指针是可能为空的,比如说当链表中没有元素时,头指针所指向的就是NULL,如果在第一行写上断言就会有问题,所以不需要assert断言。
void SLTPrint(SLTNode* phead)
{
SLTNode* pcur = phead;
while (pcur)
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
例如下列链表:
int main()
{
//创建链表节点
SLTNode* node1 = SLTBuyNode(1);
SLTNode* node2 = SLTBuyNode(2);
SLTNode* node3 = SLTBuyNode(3);
SLTNode* node4 = SLTBuyNode(4);
//让next指针指向下一个节点,最后一个为NULL
node1->next = node2;
node2->next = node3;
node3->next = node4;
node4 = NULL;
SLTPrint(node1);
return 0;
}
利用SLTPrint函数得到了结果如下:
1->2->3->4->NULL
• 🌰4.单链表头部插入元素
向单链表的头部插入元素,我们只需要创建一个新节点,让这个新节点指向我们原来的第一个节点,再将第一个节点的指针*pphead指向newnode即可。
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
注意,对于单链表来说,单链表的头部位置插入/删除、尾部位置插入/删除、指定位置插入/删除我们传参传的都是二级指针,因为我们都需要修改传过去的参数,也就是指向节点的指针。在之前的文章中,已经强调很多遍了,形参只是实参的一份临时拷贝,对形参的修改并不会影响实参。若传递结构体变量,是不会改变结构体变量本身的值的,如果我们需要在函数中改变一个变量的值,则需要传递这个值的地址,所以需要传址调用。
就比如头插SLTPushFront函数,我们需要将指向原先第一个节点的指针指向newnode,所以我们就要传第一个节点的地址的指针,也就是二级指针。
其次,我们断言二级指针pphead不为空,目的是为了保证二级指针能够解引用得到一级指针*pphead,那有必要断言一级指针*pphead不为空吗?其实是没有必要的。如果*pphead为空,也就是没有任何节点的情况,其实就相当于申请新节点newnode,然后newnode->data为x,newnode->next为NULL。
• 🌰5.单链表尾部插入元素
向单链表的尾部插入元素,我们只需要创建一个新节点,让原先的单链表的最后一个节点的next指针指向这个新节点即可。
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
assert(pphead);
SLTNode* newnode = SLTBuyNode(x);
if (*pphead == NULL)
{
newnode = *pphead;
}
else
{
SLTNode* ptail = *pphead;
while (ptail->next)
{
ptail = ptail->next;
}
ptail->next = newnode;
}
}
单链表尾部插入元素,需要考虑*pphead为空的情况。头插不需要单独处理是因为及时*pphead为空,根据函数依然能创建一个节点,使得数据为x,next指针为NULL。而对于尾插而言,逻辑是让原来的最后一个节点的next指针指向newnode,而*pphead如果为NULL,也就是没有节点,那也就没有next指针,就没有办法处理这一情况,所以我们要单独处理。
如果*pphead为空,直接用SLTBuyNode申请一个新节点就可以了。对于*pphead不为空的情况,我们需要找到原单链表的最后一个节点。定义ptail指向第一个节点,利用while循环使得ptail指向原链表的最后一个节点,最后让ptail->next指针指向newnode即可。
• 🌰6.指定位置之前插入数据
在pos节点的前面插入数据,需要先找到pos前一个的节点prev和创建的newnode节点,利用next指针将节点之间的关系设置好。
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead && *pphead && pos);
if (*pphead == pos)
{
SLTPushFront(pphead, x);
}
else
{
SLTNode* newnode = SLTBuyNode(x);;
SLTNode* prev = *pphead;
while (prev->next = pos)
{
prev = prev->next;
}
prev->next = newnode;
newnode->next = pos;
}
}
在pos之前插入数据,需要得到三个节点:pos节点、pos前一个节点prev、newnode节点。由于需要得到pos前一个节点,所以当链表中只有一个节点的时候,我们没有办法找到,所以需要单独讨论,对于这种情况,其实就相当于头插SLTPushFront。
当链表中的节点大于1时,我们先创建newnode,然后利用while循环得到pos之前的prev节点,此时三个节点都有了,我们就可以进行操作了
让prev的next指针指向newnode,再让newnode的next指针指向pos就可以了。
• 🌰7.指定位置之后插入数据
在pos节点之后插入数据,我们需要得到pos节点、newnode节点和pos->next节点。
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
assert(pos);
SLTNode* newnode = SLTBuyNode(x);;
newnode->next = pos->next;
pos->next = newnode;
}
我们观察下图,发现在pos后插入数据其实并不需要第一个节点,所以我们的节点参数传的只有pos。
分析上图,有一个地方需要大家注意,就是我们必须先让newnode->next指向pos->next节点,而不能先让pos->next=newnode,也就是代码的最后两行不可以颠倒!这时因为我们如果先修改了pos->next,实际上pos->next=newnode并不是让pos->next指向newnode,根据上图不难思考,这一步实际上直接转移了newnode,新创建的节点就没有用了。总之,这两行代码的顺序是不可以调换的。以后我们再处理复杂的节点关系,可以先从newnode入手,因为newnode是新的节点,和原链表没有直接的关系。
• 🌰8.单链表头部删除元素
单链表头部删除元素需要将第一个节点的next指针保存下来,这样才能保证free释放后的第一个节点next指针可以访问。
void SLTPopFront(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* next = (*pphead)->next;
free(*pphead);
*pphead = next;
}
删除元素,一定要保证pphead和*pphead都不为NULL,其次,我们一定要在free之前就将(*pphead)->next指针保存下来,并且由于 * 的优先级低于 -> ,需要再*pphead上加上括号。
注意,free只是放弃了对malloc开辟的空间的使用权限,*pphead还是可以继续使用的。
• 🌰9.单链表尾部删除元素
尾删需要我们得到尾节点ptail和尾节点的前一个节点prev
void SLTPopBack(SLTNode** pphead)
{
assert(pphead && *pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* ptail = *pphead;
SLTNode* prev = *pphead;
while (ptail->next)
{
prev = ptail;
ptail = ptail->next;
}
prev->next = NULL;
free(ptail);
ptail = NULL;
}
}
一定要注意单链表中特殊情况需不需要分类讨论,标准就是如果特殊情况用普遍情况的代码也可以走得通就不需要单独讨论,反之则需要。
由于需要得到两个节点,当原链表中只有一个节点时,我们需要单独讨论。若只有一个节点, 我们直接释放掉它,再将它置为NULL就可以了。
• 🌰10.删除指定位置的节点
删除指定的节点pos,需要得到pos节点、pos的前一个节点prev和pos->next节点。
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
assert(pphead && *pphead && 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;
}
}
因为我们要得到prev节点,所以要对只有一个节点的情况分类讨论,这种情况直接用头删/尾删都是可以的。当节点数量大于1时,我们需要用while循环得到prev,再让prev->next指向pos->next就可以了。
此外,free释放后应该及时将pos置为NULL,这时一个好的习惯。
• 🌰11.删除指定位置之后的节点
删除pos的后一个节点,需要得到pos节点、pos->next节点和pos->next->next节点。
void SLTEraseAfter(SLTNode* pos)
{
assert(pos && pos->next);
SLTNode* del = pos->next;
pos->next = del->next;
free(del);
del = NULL;
}
由题意不难发现,这样的链表必须至少有两个节点,所以assert需要保证pos节点和pos->next节点都不为空。
所以,我们不需要讨论特殊情况。由于pos->next指针如果free释放,就不能再通过它来访问pos->next->next,所以我们令del=pos->next,先del->next赋给pos->next,然后我们free释放del,这样就可以了。
• 🌰12.单链表的查找
查找单链表中数据为x的节点。
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
SLTNode* pcur = phead;
while (pcur->next)
{
if (pcur->data == x)
return pcur;
pcur = pcur->next;
}
return NULL;
}
我们用pcur遍历整个链表,整个过程需要保证pcur的next指针不为NULL,因为如果为NULL,它就已经是最后一个节点了。当我们查找完整个链表都没有存储数据为x的节点时,我们直接返回NULL就可以了。
• 🌰13.单链表的销毁
当我们操作完单链表完成我们需要做的事情后,需要对链表进行销毁。
void SListDesTroy(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* pcur = *pphead;
while (pcur)
{
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
*pphead = NULL;
}
用pcur遍历整个链表,先用next指针保存下一个节点,然后释放当前节点,重复这个过程,最后再将第一个接地那也置为NULL就可以了。由于需要保证pcur能够访问到pcur->next,所以assert需要断言pphead和*pphead。
🍋知识点三:单链表基本操作
单链表的基本操作就是实现对顺序表元素的增、删、查、改,关于如何实现已经在前面都进行了讲解,也给出了代码,现在希望大家掌握之后通过下面给出的SList.h头文件,在SList.c文件中分别实现这些功能,并在test.c的main函数中测试:
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data;
struct SListNode* next;
}SLTNode;
//链表的打印
void SLTPrint(SLTNode* phead);
//增加新节点
SLTNode* SLTBuyNode(SLTDataType x);
//尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x);
//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x);
//尾删
void SLTPopBack(SLTNode** pphead);
//头删
void SLTPopFront(SLTNode** pphead);
//查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);
//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos);
//删除pos之后的节点
void SLTEraseAfter(SLTNode* pos);
//销毁链表
void SListDesTroy(SLTNode** pphead);
• ✨SumUp结语
数据结构的学习一定要多画图,多理解,多思考,切忌直接抄写代码,就认为自己已经会了,一定到自己动手,才能明白自己哪个地方有问题。
如果大家觉得有帮助,麻烦大家点点赞,如果有错误的地方也欢迎大家指出~