前面我们掌握了顺序表的基本标准以及其接口函数的实现,对于顺序表而言,在扩充空间容量方面存在着开辟空间的消耗以及多余空间的浪费,那么这个问题如何解决呢?我们可以通过链表来解决。链表不同于顺序表数据存储在连续的物理地址上(即在一块连续的空间中连续存储数据)。链表的每一块空间都是不一定连续的,物理上不连续,每次想要存入数据就开辟一个空间,由于不是一次性开辟大型空间,而是一小块一小块地开辟,而每一次开辟的存储数据的空间称之为结点。因此物理上不连续,我们无法通过数组下标的形式或者指针平移的形式找到下一个或者下下个数据所在位置,而是在结点中存储着下一个数据的地址,通过上一个结点中的地址来找到下一个结点中的数据。
链表---LinkedList
一、链表的分类
链表从哪些方面分类呢?一般来说从单向还是双向、有头或是无头(哨兵位/首元结点有无)、循环或者非循环三个方面进行分类,那么进行组合就会有8种链表。
如上图,我们可以根据不同结构上的分类情况对于链表排列组合,最多能够得到8种链表形式,但是实际上最常用到的链表是以下两种:
①无头单向不循环链表
无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结
构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多。
②带头双向循环链表
带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都
是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了。
二、单链表---SingleLinkedList
下面演示无头单向非循环链表
1.单链表的结构
//单链表SingleLinkedList
//结构
#define SLLDataType int
typedef struct SLListNode
{
SLLDataType data;//结点中的数据
SLLNode* next;//结点中的指针---存储着下一个结点的地址
}SLLNode;
每一个结点中包含两部分,一部分是数据data,另一部分是指向下一个结点空间的指针next。
2.单链表的接口函数
①打印SLLPrint
//打印
void SLLPrint(SLLNode* phead)
{
SLLNode* cur = phead;
while (cur != NULL)
{
printf("%d->", cur->data);
cur = cur->next;
}
printf("NULL");
printf("\n");
}
打印操作,我们的数据是结构体成员data,由于链表在物理上不是连续的,我们不能使用顺序表那样数组下标的形式进行遍历打印,链表前一个结点存储的指针next中存储后一个结点的地址,而前一个结点还会存储该结点的数据data,那么我们可以创建一个结构体指针cur,用cur对链表进行遍历打印:由于最后一个结点存储的指针指向NULL,那么当cur为空,说明遍历到了末尾跳出循环。
②开辟空间为data和next赋值SLLCreateMemory
//开辟结点、导入数据
SLLNode* SLLCreateMemory(SLLDataType x)
{
SLLNode* newnode = (SLLNode*)malloc(sizeof(SLLNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);//直接结束程序
}
newnode->data = x;//新的结点空间的数据赋值x
newnode->next = NULL;//新的结点空间的指针指向NULL
return newnode;
}
链表每插入一个数据就开辟一块空间存储该数据,这个空间称为结点,结点中存储着数据和指向下一个结点的指针。使用一个新结点newnode结构体指针变量指向malloc开辟的空间,开辟成功则在该结点空间中存放入数据x,由于新开辟,我们将结点存储的指针置空NULL。最后返回newnode指针,使用一个newnode指针变量去接收这个地址,就可以访问新开辟的结点了。
③查找数据SLLFind
查找数据x,如有则返回对应的结点地址,如没有返回NULL。
//查找x数据的结点
SLLNode* SLLFind(SLLNode* phead, SLLDataType x)
{
SLLNode* cur = phead;
while (cur != NULL)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
查找操作一般与后面的任意结点位置的删除与插入联用,因为查找函数能够返回结点位置。
没必要对*phead进行断言限制,因为不进入循环最后也会返回NULL,当然断言也是没有问题的。
④销毁链表SLLDestroy
由于链表的每一个结点空间都是单独开辟的,因此我们对于链表的销毁需要遍历整个链表,依次从头销毁。
//销毁
void SLLDestroy(SLLNode** pphead)
{
assert(pphead);
SLLNode* prev = *pphead;
SLLNode* cur = *pphead;
while (cur != NULL)
{
prev = cur;
cur = cur->next;
free(prev);
}
*pphead = NULL;
}
同样我们创建两个结构体指针,cur用于遍历,prev用于销毁每个结点。
头尾的插入删除如下
⑤头插SLLPushFront
//头插
void SLLPushFront(SLLNode** pphead, SLLDataType x)
{
assert(pphead);
SLLNode* newnode = SLLCreateMemory(x);
newnode->next = *pphead;
*pphead = newnode;
}
对于这些头尾插删的函数,我们先写出一般情况的代码,然后去判断能否满足特殊情况如无结点或者只有1个结点,对于头插而言,特殊情况只有无结点需要探讨。
头插--->起始位置插入一个结点,那么会改变phead指向(phead存储的值),而phead是一个结构指针,使用函数改变结构指针需要传入结构指针的地址--->因为形参只是实参的一份临时拷贝。
所以我们需要传入结构体二级指针pphead,还有需要插入的x数据,怎么插入呢?我们用newnode接收开辟的空间,然后将newnode存储的next指向原来的起始结点,再将phead指向newnode即可
单链表的头插时间复杂度为O(1), N个数据头插时间复杂度O(N)。
⑥尾插SLLPushBack
//尾插
void SLLPushBack(SLLNode** pphead, SLLDataType x)
{
assert(pphead);
//phead指针为空的情况
if (*pphead == NULL)
{
SLLNode* newnode = SLLCreateMemory(x);
*pphead = newnode;
}
else
{
//phead指针不为空的情况
SLLNode* tail = *pphead;
//找尾部结点,让tail指向这个结点
while (tail->next != NULL)//不能tail!=NULL,因为tail要找到尾结点的指针,而不是到它的下一位NULL
{
tail = tail->next;
}
SLLNode* newnode = SLLCreateMemory(x);
tail->next = newnode;//让tail中的next指针指向新开辟的结点空间
}
}
由于链表的物理不连续性,我们想在末尾插入结点,必须依次遍历找到末尾位置的结点存储的指针, 我们使用tail结构体指针用于找到该指针,那循环的条件是什么呢?tail != NULL吗?不是,而是tail->next != NULL。
当我们使用tail指向了尾结点,那么就应该开辟空间准备进行尾插,将tail作为一个中间跳板,连接起两边的结点:
SLLNode* newnode = SLLCreateMemory(x);
tail->next = newnode;//让tail中的next指针指向新开辟的结点空间
那么以上就是一般情况下的尾插操作了,那能不能满足特定情况呢?比如phead指向空?(即没有初始结点)
当没有初始结点之时,phead指向NULL,我们在操作中让tail指向了phead指向的空间,那么tail也指向了NULL,在循环条件判断时,tail->next就对空指针进行了解引用操作!就产生了非法访问!
因此对于phead指向NULL的情况得分开考虑,这个时候直接添加就行了:
//phead指针为空的情况
if (*pphead == NULL)
{
SLLNode* newnode = SLLCreateMemory(x);
*pphead = newnode;
}
单链表的尾插时间复杂度为O(N), N个数据尾插时间复杂度O(N^2)。
⑦头删SLLPopFront
//头删
void SLLPopFront(SLLNode** pphead)
{
assert(pphead);
assert(*pphead);//删除得有结点吧
//1个结点或者多个结点均可以处理
SLLNode* head = *pphead;
*pphead = head->next;
free(head);
}
对于删除操作,首先需要判断有没有结点,没有就无法删除!使用assert暴力断言或者if温柔判断均可。 由于需要对phead结构指针进行修改,需要传入结构指针的地址!由于在头部,不需要对结点进行遍历,phead指向的就是第一个结点。首先判断一般情况,创建一个结构体指针类型的中间变量head,指向第一个结点,我们将phead指向第二个结点后,使用free释放第一个结点空间,因为head指向了第一个结点,所以能够释放。
当只有一个结点时,将head->next赋给phead就是将NULL赋给phead,没有什么区别,也释放了空间,也没有野指针,所以这个代码可以对特殊情况进行操作。
单链表的头删时间复杂度为O(1), N个数据头删时间复杂度O(N)。
⑧尾删SLLPopBack
//尾删
void SLLPopBack(SLLNode** pphead)
{
assert(pphead);
//1.没有结点
assert(*pphead);//不能没有结点,温柔用if;暴力用assert
SLLNode* tail = *pphead;
//2.结点数>1
if(tail->next!=NULL)
{
SLLNode* copy = *pphead;//copy用来找尾的前一个
//找尾
while (tail->next != NULL)
{
copy = tail;//copy最后就能操纵删完的最后一个的next置NULL
tail = tail->next;
}
copy->next = NULL;
free(tail);
}
//3.只有一个结点
else
{
free(tail);
*pphead = NULL;
}
}
尾删,由于在尾部,需要遍历让tail指向尾结点用于释放尾结点空间,同时,尾结点的删除会影响到倒数第二个结点存储的指针next,如果不对其指向更改为NULL,就没有成功完成尾删,同时next就变成野指针了。那么我们就需要创建另一个结构体指针copy,让copy跟着tail走,直到该指针指向了倒数第二个结点,这样它就可以修改next的指向:
SLLNode* tail = *pphead;
SLLNode* copy = *pphead;//copy用来找尾的前一个
//找尾
while (tail->next != NULL)
{
copy = tail;//copy最后就能操纵删完的最后一个的next置NULL
tail = tail->next;
}
那么结点数为0或者为1时?
结点数为0时,phead指向NULL,那么copy与tail均指向NULL,我们却对copy进行解引用操作访问next,这是一种非法访问,因此结点数为0时需要单独讨论;
结点数为0时直接暴力断言。
结点数为1时,phead->next指向NULL,此时copy与tail均指向结点1,并没有分开,我们释放空间free(tail),此时由于copy与phead均指向这一块空间,他俩均为野指针,因此也需要单独讨论。
结点数为1时,直接释放结点1然后phead置空即可。
单链表的尾删时间复杂度为O(N), N个数据尾删时间复杂度O(N^2)。
⑨在结点pos前插入结点SLLInsert
在pos前插入结点,那么我们需要知道pos前一个结点,才能将其与新插入结点的地址链接,因此需要创建prev指针遍历找到pos前一个结点。同时,如果pos在首结点时,不能遍历---因为遍历会直到prev为NULL然后非法访问,pos在首结点直接就变成了一个头插操作,调用头插接口函数或者再写一次头插的代码即可。
//pos结点之前插入一个结点
void SLLInsert(SLLNode** pphead, SLLNode* pos, SLLDataType x)
{
assert(pos);
assert(*pphead);
assert(pphead);//“防止酒驾”
SLLNode* prev = *pphead;
if (prev == pos)//1.pos指向首结点
{
//头插
SLLPushFront(pphead, x);
}
else//2.pos指向首结点之后的任意结点
{
while (prev->next != pos)
{
prev = prev->next;
}
SLLNode* newnode = SLLCreateMemory(x);
newnode->next = pos;
prev->next = newnode;
}
}
如果我们认为空链表在NULL处插入是头插,那么我们可以不使用assert直接断言pos和*pphead,此时我们的pos和*pphead是有关系的,即要么都为NULL,要么都不为NULL,那么可以使用以下的断言方式解决:
assert((!pos && !(*pphead)) || (pos && *pphead));
|| 操作符前面为真表示两者均为NULL,那么我们可以if判断一下,如果均为NULL,我们进行一个头插,|| 操作符前面为假那么需要判断后面,后面为真则断言通过,且pos与*pphead均不为空,那么就进入到上面的插入结点的思路。
当然,简单一点直接如上将双方都直接断言即可,默认不能为NULL。
⑩删除pos结点SLLErase
对于链表而言,在结点前的插删操作,都需要找到pos前一个结点,然后对该结点的next指针进行修改。那么既然需要创建prev找,那么就存在两种情况,那就是指向头结点和不指向头结点。
//删除pos结点
void SLLErase(SLLNode** pphead, SLLNode* pos)
{
assert(pphead);
assert(*pphead);
//1.不能无结点,使用if温柔判断也行
assert(pos);
if (*pphead == pos)//2.pos指向首结点---就是头删
{
*pphead = pos->next;
free(pos);
pos = NULL;
}
else//多个结点
{
SLLNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
指向头结点时,遍历是失效的,会使prev最后成为NULL然后非法访问,因此拿出来单独讨论,讨论之前依旧断言,pos指向头结点,其实就是头删,调用头删的函数即可,或者再写一遍,先将*pphead指向头结点后一个结点,然后释放pos、pos置空即可。
⑪在结点pos后插入结点SLLInsertAfter
//在pos结点后插入结点
void SLLInsertAfter(SLLNode* pos, SLLDataType x)
{
assert(pos);
SLLNode* newnode = SLLCreateMemory(x);
SLLNode* cur = pos->next;
pos->next = newnode;
newnode->next = cur;
}
pos结点后插入结点就相对轻松不少了,因为可以直接通过pos就能找到结点,不用遍历找pos前的结点,因此我们不用传入结构首地址phead或者是结构指针的地址pphead。pos指向首结点或者其他结点时,都是一样的也不用分情况讨论。
⑫在结点pos后删除结点SLLEraseAfter
//在pos结点后删除结点
void SLLEraseAfter(SLLNode* pos)
{
assert(pos);
SLLNode* cur = pos->next;
if (cur == NULL)
return;
pos->next = cur->next;
free(cur);
cur = NULL;
}
同样,在pos后删除也不需要传入结构首地址或者指针地址,直接使用pos就够了。不能删除空指针先断言一下pos,然后进行删除操作。如果pos为尾结点,那么cur->next就是空指针的解引用,造成了非法访问,因此我们需要使用if进行温柔判断或者使用assert进行暴力禁止。
单链表的头删与头插的效率不错,其他的插删接口函数的效率低。
3.成环链表
链表的尾结点存储的next指针不指向NULL,而是指向该链表中任意一个结点,这样的链表我们称之为成环链表。
成环链表有一些经典题目:如判断链表是否成环,再进阶一点,如果成环请返回入环点。
关于这些内容打算后面单独开一篇博客进行说明。