1. 前言
💓博主CSDN:杭电码农-NEO💓🎉🎉🎉
⏩专栏分类:数据结构学习分享(持续更新中🫵)⏪🎉🎉🎉
让我们紧接上一章顺序表的概念,引出链表,我们说顺序表每次增容需要申请新的空间,会产生很多空间碎片,代价比较高,并且我们扩容一般是扩两倍,还是会有一些空间被我们浪费掉. 所以我们基于顺序表的缺点,引出了链表的概念
2. 链表的概念以及结构
概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。我们光听这个概念可能有一点抽象,所以我们以画图的形式给大家说明一下;
我们画的图是形象图,但是实际上我们链表没有箭头这一说法,箭头只是帮助大家理解它的结构,其实它的真实存储结构应该是这样的: 🔑🔑
注意:
在上图中我们可以发现链式结构在逻辑上是连续的(即一个节点链接着一个节点),但是在物理上不一定连续(即地址不连续,节点1为A0,节点2为B0) 🔥🔥
实际存储中的节点是从堆上申请出来的,在堆上申请的空间是按照一定的策略来分配的,两次手球的空间可能连续也可能不连续 🔥🔥
3. 链表的分类
⭕ 实际中链表的结构非常多样,以下情况组合起来就有8种链表结构 ⭕
单向或者双向:
带头或者不带头(也叫做哨兵位):
循环或非循环:
虽然有这么多的链表的结构,但是我们实际中最常用还是两种结构:
无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。另外这种结构在笔试面试中出现很多💬
带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单了,后面我们代码实现了就知道了💬。
我们本节要介绍的就是无头单向非循环链表 🎉🎉🎉
4.链表的实现
4.1 初始化结构
和顺序表一样,在我们实现增删查改等功能之前,我们要先在.h文件中包含常见的头文件并且定义一个结构体后将它重命名:
#pragma once #include<stdio.h> #include<stdlib.h> #include<assert.h> typedef int SLTDateType;//想存储字符型就把int改成char typedef struct SlistNode { SLTDateType data;//链表节点中存储的数据 struct SlistNode* next;//节点中存储的下一个节点的地址 }SLTNode;
然后在在我们的test.c文件和Slist.c文件中包含Slist.h.不同于顺序表的是我们这里在test.c文件中定义一个plist结构体指针就可以直接开始使用了.我们接下来先在.h文件中将我们所有的函数都声明一遍:
#pragma once #include<stdio.h> #include<stdlib.h> #include<assert.h> typedef int SLTDateType; typedef struct SlistNode { SLTDateType data; struct SlistNode* next; }SLTNode; void SListPrint(SLTNode** phead);//打印链表元素 SLTNode* BuyListNode(SLTDateType x);//开辟空间 void SListPushBack(SLTNode** phead, SLTDateType x);//尾插 void SListPopBack(SLTNode** phead);//尾删 void SListPushFront(SLTNode** phead, SLTDateType x);//头插 void SListPopFront(SLTNode** phead);//头删 SLTNode* SListFind(SLTNode* phead, SLTDateType x);//查找 void SListInsert(SLTNode** phead, SLTNode* pos, SLTDateType x);//在pos位置前插入数据
😄😄😄
我们发现这里的增删查改都是传的二级指针,这是因为我们在test.c中定义结构体变量时定义的是一个结构体指针指向我们链表的第一个节点,我们在进行增删查改的过程中可以会改变这个结构体指针的指向(比如头插后,我们的结构体指针指向新的链表的头),所以我们需要传二级指针过去才能改变它的值.如果听到这儿你还不明白,可以先去我的码云看看所有的代码再慢慢理解gitee-杭电码农
4.2 尾插函数
既然是要插入数据,那么与数组不同的是链表每插入一次数据就开辟一块与这个数据大小相同的空间,所以我们第一步要做的就是动态开辟空间:
void SListPushBack(SLTNode** phead, SLTDateType x) { SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));//定义一个新节点后动态开辟空间 newnode->data = x;//将要插入的数据x赋值到节点的data上 newnode->next = NULL;//将尾插后的节点的next置空. }
当我们开辟好空间之后,我们得先找到尾,才能尾插,所以我们这里定义一个变量取遍历链表来找到尾:
void SListPushBack(SLTNode** phead, SLTDateType x) { SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));//定义一个新节点后动态开辟空间 newnode->data = x;//将要插入的数据x赋值到节点的data上 newnode->next = NULL;//将尾插后的节点的next置空. SLTNode* cur = *phead; //定义一个变量指向链表得头节点 while (cur->next!=NULL)//当cur->next等于NULL时,此时cur就是最后一个节点 { cur = cur->next;//cur不断往后遍历 } cur->next = newnode;//这时cur已经等于最后一个节点后出了while循环,将最后一个节点与新节点链接起来 }
值得注意的是这里有一种特殊情况,就当整个链表没有存储数据时,我们得phead为空指针,cur也是空指针,然后我们使用了cur->next相当于对空指针解引用了,所以这个地方得特殊情况要特殊考虑,优化代码后为:
void SListPushBack(SLTNode** phead, SLTDateType x) { assert(*phead);//确保链表不为空 SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode)); newnode->data = x; newnode->next = NULL; if (*phead == NULL) { *phead = newnode;//当链表为空时直接将phead给给newnode } else { SLTNode* cur = *phead; while (cur->next) { cur = cur->next; } cur->next = newnode; } }
4.3 尾删函数
和尾插类型,先找到尾再把尾节点的空间给释放掉,然后将尾节点的前一个节点的next置空使它变成新的尾节点:
void SListPopBack(SLTNode** phead) { SLTNode* cur = *phead; SLTNode* prev = NULL;//定义一个prev指向cur的前一个结点 while (cur->next!=NULL)//当cur->next为空时就代表cur是尾节点了,此时prev是尾节点的前一个结点 { prev = cur;//prev是cur结点的前面一个结点 cur = cur->next; } prev->next = NULL;//将prev结点置空成为新的尾 free(cur);//将要尾删的空间给释放掉 cur = NULL; }
还是和之前同一个问题,当我们链表中只有一个节点时,我们的prev还是NULL,后面讲prev->=NULL,也是对空指针解引用,也会遇见对空指针解引用的错误操作,所以这个地方我们优化代码:
void SListPopBack(SLTNode** phead) { if ((*phead)->next == NULL) { free(*phead);//当链表中只有一个节点时,直接free掉就行 *phead = NULL; } else { SLTNode* cur = *phead; SLTNode* prev = NULL; while (cur->next) { prev = cur; cur = cur->next; } prev->next = NULL; free(cur); cur = NULL; } }
4.4 头插函数
有了前面尾插,尾删的基础,这里我们直接上代码:
void SListPushFront(SLTNode** phead, SLTDateType x) { SLTNode* newhead = (SLTNode*)malloc(sizeof(SLTNode));//只要是插入数据就要开辟新空间 newhead->next = *phead; newhead->data = x; *phead = newhead;//把新的头给phead }
4.5 头删函数
这里头部的删除要考虑链表中是否还存在节点,并且链表中只有一个节点的情况也要拿出来特殊考虑
void SListPopFront(SLTNode** phead) { assert(*phead);//确保链表不为空 if ((*phead)->next == NULL)//链表只有一个节点 { free(*phead);//直接释放掉空间后置空 *phead = NULL; } else { SLTNode* newhead = (*phead)->next;//定义头节点的下一个节点,不然把头节点的空间释放掉后会找不到下一个节点 free(*phead); *phead = newhead;//把phead给上新的头节点 } }
4.6 开辟新节点
我们发现,在操作链表时,只要涉及到插入数据就会用到开辟动态内存这一个步骤,所以我们可以把这个步骤单独拿出来,遇见头插尾插时可以在函数中调用这个开辟空间的函数:
SLTNode* BuyListNode(SLTDateType x)//返回一个节点的地址 { SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode)); newnode->data = x;//将插入的数据给上 newnode->next = NULL; return newnode;//将开辟好后的空间返回 }
当我们写了这个函数过后,我们的头插和尾插就可以简化一点了🥳🥳🥳
比如我们的尾插可以改为:
void SListPushBack(SLTNode** phead, SLTDateType x) { assert(*phead);//确保链表不为空 SLTNode* newnode = BuyListNode(SLTDateType x);//定义一个节点来接受开辟的空间 if (*phead == NULL) { *phead = newnode;//当链表为空时直接将phead给给newnode } else { SLTNode* cur = *phead; while (cur->next) { cur = cur->next; } cur->next = newnode; } }
4.7 销毁链表
当我们使用完链表后,需要销毁它里面的数据和空间:
void SListDestroy(SLTNode** phead) { assert(phead); SLTNode* cur = *phead; while(cur) { SLTNode* next = cur->next; free(cur); cur = next; } *phead = NULL; }
5. 单链表OJ题目
在我的专栏单链表面试题分享中其实就给大家分享了很多个单链表面试题的题解思路以及一些衍生问题的探讨,有兴趣的同学可以点击前面跳转.或者如果你想自己做一遍题不想先看答案的,我给大家把这些题的链接放出来 :
✍🏼✍🏼✍🏼
删除链表中等于给定值 val 的所有节点。OJ题
反转一个单链表.OJ题
给定一个带有头结点 head 的非空单链表,返回链表的中间结点。如果有两个中间结点,则返回第二个
中间结点。OJ题
输入一个链表,输出该链表中倒数第k个结点OJ题
将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成
的。OJ题
链表的回文结构。OJ题
输入两个链表,找出它们的第一个公共结点。OJ题
给定一个链表,判断链表中是否有环。OJ题
给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 NULLOJ题
给定一个链表,每个节点包含一个额外增加的随机指针,该指针可以指向链表中的任何节点或空节点。
要求返回这个链表的深度拷贝。OJ题
在我往期的博客当中有这些题目的画图详解,当你们做题时遇见问题可以跳转我的专栏单链表面试题分享
6. 顺序表和链表的区别
我们用一个表格来阐述:
不同点 | 顺序表 | 链表 |
存储空间上 | 物理上一定连续 | 逻辑上一定连续,物理上不一定 |
随机访问 | 支持O(1) | O(N) |
任一位置插入或删除 | 需要挪动元素,效率低 | 只需要修改指针指向 |
插入 | 动态顺序表,空间不够需扩容 | 没有容量的概念 |
应用场景 | 元素高效存储和快速访问 | 频繁的任一位置插入或删除 |
缓存利用率 | 高 | 低 |
后期遇见既可以用顺序表作为结构也可以用链表作为结构的数据时,再详细讨论到底用哪个好
7. 总结
链表是为了弥补顺序表的缺陷而出现的一种数据结构,当然链表本身也有缺陷,它不能解决所有问题,所以我们后期还会学一些数据结构来完善我们对于结构的理解 有关于链表中最简单的结构:单链表的实现我们就讲到这里,如果有帮到你请点点赞吧.📝📝📝