一,什么是链表
1、链表的概念
和顺序表一样,链表的逻辑结构也是线性的,不一样的是链表的物理结构是分散的,堆区中一个又一个分散的节点构成了单链表的结构,而每个节点在内存中是分散分布的,他们是怎么连接起来的呢? 

我们可以发现,每个节点中存放着两个数据类型------数据本身和下一个节点的指针,上一个节点记录下一个节点的地址,一个接一个,这样链表就连接起来了,那如何找到链表中的第一个元素呢?答案是通过一个指针变量(phead)记录下第一个节点的地址。f
2、链表的分类
链表的类型有多种,他们取决于三个维度 :单向和双向,不闭环和闭环,带哨兵位和不带哨兵位
我们一个一个来看:
- 单向和双向:在单向链表中,只用记录下一个节点地址,而在双向链表中。需要同时记录下上一个节点和下一个节点的地址,这样做虽然有点麻烦,但好处也是很明显的,双向链表不仅可以往后遍历,还可以往前遍历,可以很轻易的找到上一个节点 ,无需像单链表重新遍历。

- 不闭环和闭环:闭环不一定是头和尾的闭环,也可以是在中间的某个节点闭环,那么这个时候就会有个问题,闭环的链表没有停止的信号,普通列表遍历到空指针就停下了,闭环链表没有停止的信号 ,我们怎么知道链表什么时候遍历完,什么时候停下呢?这个时候就要引入哨兵位了

- 带哨兵位和不带哨兵位:有哨兵位的情况下,头指针指向哨兵位,哨兵位本身也是一个节点,但是其中不存放有效数据,哨兵位的指针指向第一个有效节点,前面说了,哨兵位可以在环形链表中作为停止的信号 ;而哨兵位的好处不止如此,如果没有哨兵位,头插和头删的时候就需要分类讨论 。一般情况下插入和删除需要改变前一个节点和后一个节点的指向,在没有哨兵位的情况下,头插和头删就需改变头指针的指向了。

综上:链表类型本质是3个维度的排列组合,如下:

其中,用实线连起来的是常用的,虚线连起来的用的比较少
二,单链表的创建与维护
前面提到带哨兵位的链表无需分类讨论,实现起来更方便,这里讲的是单向不闭环带哨兵位的链表。维护工作主要通过下标实现。
0,参数名词介绍
SList:Single List(单链表)
SListDataType:Single List Data Type(单链表数据类型)
phead:Position Head(头指针,即链表中第一个数据的指针)
pnew:Position New(新开辟的空间或重新开辟空间)
pcur:Position Current(当前位置)
prev:Previous(要插入或删除的前一个节点指针,可以称为前驱指针)
next:(下一个节点的指针)
i:(下标序号)
x:(操作的数据)
1,准备工作
这里创建三个文件
头文件:
SList.h:定义链表的结构体,维护链表的函数声明
源文件:
SList.c:维护列表函数的实现
text.c:链表结构维护测试
下边是头文件(SList.h)的内容:
cs
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
//定义节点的结构体
typedef int SListDataType;//数据类型
typedef struct SingleNode
{
SListDataType data; //当前节点存放的数据
struct SingleNode* next; //指向下一个节点的指针
}Node;
////////////////////////////////////////////////////////////////////
下面是函数申明
//初始化链表
Node* SListInit();
//销毁链表
void SListDistory(Node* phead);
//打印链表
void SListPrint(Node* phead);
//按数据查找(返回下标)
int SListIndex(Node* phead, SListDataType x);
//数据个数
int SListSize(Node* phead);
//指定i位置插入
void SListInsert(Node* phead, int i, SListDataType x);
//指定位置删除
void SListDel(Node* phead, int i);
//头插
void SListInsertHead(Node* phead, SListDataType x);
//尾插
void SListInsertBack(Node* phead, SListDataType x);
//头删
void SListPopHead(Node* phead);
//尾删
void SListPopBack(Node* phead);
记得在源文件中包含SList.h。
2,链表初始化
cs
//初始化链表
Node* SListInit();
//返回值:指向哨兵位的头指针
向内存申请一个节点的空间作哨兵位,注意把节点的指向置为空,然后在里面随便放一个无效数据就行了,然后返回这个节点(哨兵位)的地址。
cs
//申请存放节点的空间
Node* BuyNode(SListDataType x)
{
//开辟空间和检查是否开辟成功
Node* pnew = (Node*)malloc(sizeof(Node));
if (!pnew)
{
perror("BuyNode::malloc");
exit(1);
}
//初始化新生成节点的数据
pnew->data = x;
pnew->next = NULL;
//返回新生成节点的地址
return pnew;
}
//初始化链表
Node* SListInit()
{
//生成哨兵位,并将数据置为-1
Node* pnew = BuyNode(-1);
//由于申请空间经常用到,这里专门定义了一个函数开辟空间
//返回指向哨兵位的头指针
return pnew;
}
3,链表打印
cs
//打印链表
void SListPrint(Node* phead);
//参数:
//phead:指向哨兵位的头指针
遍历链表,并打印,说起来很简单。让我们看看其中的细节:
- 假如说我们遍历链表的指针是pcur,开始时我们要让pcur第一个有效数据(即哨兵位的下一个数据);
- 如何得到下一个节点的地址呢?只需pcur->next即可,我们可以这样操作:pcur=pcur->next,这样执行一次pcur就是向下一个节点了;
- 我们想想应该什么时候停止呢?如果遍历停止的条件是pcur->next!=NULL(此时pcur指向最后一个节点) ,但是进入遍历循环(while)了吗,没有!那就漏了最后一个节点 。假如说pcur!=NULL呢 。我们发现在这种情况下,pcur==NULL时不会进入循环(不会对空指针解引用) 。所以循环条件应该是:pcur!=NULL。
cs
//打印链表
void SListPrint(Node* phead)
{
assert(phead);//检查空指针
//遍历链表,打印数值
Node* pcur = phead->next;
while (pcur)
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");//打印链表末尾的空指针
}
打印效果:2->3->1->NULL
4,按下标查找
cs
//按数据查找(返回下标)
int SListIndex(Node* phead, SListDataType x);
//参数:
//phead:指向哨兵位的头指针
//x:需要查找的数据
//返回值:该数据的下标
// 如果没找到则返回无效数据-1
这里和数组的遍历是类似的,循环条件我就不再过多赘述了。
cs
//(返回下标)
int SListIndex(Node* phead, SListDataType x)
{
assert(phead);//检查空指针
//遍历链表,寻找数据
Node* pcur = phead->next;
int i = 0;
while (pcur)
{
if (pcur->data == x)//如果相等
return i; //返回当前下标
pcur = pcur->next;
i++;
}
//跳出循环说明没找到
return -1;//返回无效数据-1
}
6,计算数据个数
cs
//统计数据个数
int SListSize(Node* phead);
//phead:指向哨兵位的头指针
//返回值:数据个数
定义一个计数器(count),初始值为0,每向前走一个节点就count++,从第一个有效数据(phead->next)开始。
cs
//数据个数
int SListSize(Node* phead)
{
assert(phead);//检查空指针
//遍历链表,计算个数
int count = 0;
Node* pcur = phead->next;
while (pcur)
{
pcur = pcur->next;
count++;
}
return count;
}
7,指定位置插入
cs
//指定i位置插入
void SListInsert(Node* phead, int i, SListDataType x);
//参数:
//phead:指向哨兵位的头指针
//i:要插入的位置对应的下标
//x:插入的数据
插入不仅要开辟空间,还要注意遍历寻找前驱节点的方法,注意修改指针的顺序,这里我们来详细讲一下:
1,找到i-1节点(前驱节点):有人可能会说,so easy,遍历链表,将循环条件设为i-1不就行啦。但这其中有个大雷 🥵。如果i为0(头插),要找到下标-1(哨兵位) ,阁下又该如何应对?所以我们索性从-1(哨兵位)开始遍历。这样即使prev为哨兵位也是可行的。
2,修改节点指向的顺序:这里是最容易犯迷糊的,但是我有一个秘法(后面再讲)。我们先来看看,可以先改变prev节点的指向,再改变pnew节点的指向吗?

很明显不行,因为。如果先改变了prev节点的指向,让其指向pnew,后面节点数据就丢失了,pnew无法连接上后面的节点 。所以说正确的方法应该是,先改变pnew节点的指向,再改变prev。

悄悄告诉你我的秘法:无论如何,先改变新生成节点的指向,再动原链表。因为新生成的节点无论你怎么改,都不会影响原链表。
动画演示:在下标为3的位置插入28

遍历链表,找到下标为3-1=2的节点,开辟空间并完成赋值,将新开辟的节点指向原链表中的3节点,再将2节点的下一个节点置为新开辟的节点。
cs
//指定i位置插入
void SListInsert(Node* phead, int n, SListDataType x)
{
assert(phead);//检查空指针
assert(n >= 0);//检查i是否合法
//遍历链表,找到第i-1个节点(prev)
Node* prev = phead;
for (int j = -1; j < n - 1 && prev; j++)//i=n-1时跳出循环
{
prev = prev->next;
}
assert(prev);//检查prev是否走到NULL
//如果走到NULL说明i超出了最大节点下标
//申请节点
Node* pnew = BuyNode(x);
//先修改新节点的下一个指针(next)
pnew->next = prev->next;
//在修改前驱节点(prev)的下一个指针(next)
prev->next = pnew;
}
8,指定位置删除
cs
//指定位置删除
void SListDel(Node* phead, int i);
//参数:
//phead:指向哨兵位的头指针
//i:要删除的数据对应的下标
找到前驱指针,修改指针指向,和前面一样。需要注意的是释放节点,要先保存要删除的节点,修改指向,再释放。否则找不到后面的节点了 ;同时还要防止下标过大越界访问(注释写的很详细)。

动画演示:删除下标为3的元素

遍历链表,找到下标为3-1=2的节点(用prev保存地址),用del保存要删除的3节点的地址,将prev节点的指针改为del的next,然后释放del节点。
cs
//指定位置删除
void SListDel(Node* phead, int n)
{
assert(phead);//检查空指针
assert(phead->next);//检查链表是否为空
assert(n >= 0);//检查下标n是否合法
//遍历链表找到第n-1个节点(prev)
Node* prev = phead;
for (int i = -1; i < n - 1 && prev->next; i++)//i=n-1时跳出循环
{ //走到最后一个节点也跳出循环
prev = prev->next; //如果走到走到最后一个节点说明i超出了最大节点下标
}
assert(prev->next);//前一个节点(prev)的下一个指针(next)是否为NULL
//如果是,则说明该节点(prev)是最后一个节点,
//则要删除的下一个节点不存在,报错
//保存要删去的节点,防止地址丢失
Node* del = prev->next;
//连接del前后的两个节点
prev->next = del->next;
//释放del节点
free(del);
del = NULL;
}
9,头尾插入删除
cs
//头插
void SListInsertHead(Node* phead, SListDataType x);
//尾插
void SListInsertBack(Node* phead, SListDataType x);
//头删
void SListPopHead(Node* phead);
//尾删
void SListPopBack(Node* phead);
直接使用之前写好的接口吐舌头
cs
//头插
void SListInsertHead(Node* phead, SListDataType x)
{
assert(phead);//检查空指针
SListInsert(phead, 0, x);
}
//尾插
void SListInsertBack(Node* phead, SListDataType x)
{
assert(phead);//检查空指针
int i = SListSize(phead);
SListInsert(phead, i, x);
}
//头删
void SListPopHead(Node* phead)
{
assert(phead);//检查空指针
assert(phead->next);//检查链表中是否存在有效数据
SListDel(phead, 0);
}
//尾删
void SListPopBack(Node* phead)
{
assert(phead);//检查空指针
assert(phead->next);//检查链表中是否存在有效数据
int i = SListSize(phead);//获取数据个数
SListDel(phead, i - 1); //删除最后一个数据
}
10,链表销毁
cs
//销毁链表
void SListDistory(Node* phead);
//要销毁的链表的头指针
释放一个节点后,下一个节点就找不到了 。所以这里定义两个指针,del用来释放节点,next用来记录下一个节点的地址。

动画演示:
cs
//销毁链表
void SListDistory(Node* phead)
{
assert(phead);//检查空指针
//遍历链表,释放节点
Node* del = phead;//del用于释放节点
Node* next = phead;//用于记录del节点的下一个位置,防止下一个节点地址丢失
while (del)
{
next = next->next;//先让next往后,记录del下一个节点的位置
free(del); //释放del节点,此时该接点的next已经丢失
del = next; //将下一个节点的地址(next)赋给del
}
}
三,链表的特点和局限性
主要优点:
动态大小,按需分配:可以根据需要动态地创建和释放节点。
插入和删除效率高:在已知前驱节点的情况下,插入或删除一个节点的时间复杂度是 O(1)。只需要修改相邻节点的指针指向即可,不会像顺序表那样移动大量元素
内存利用率高:链表是按需分配内存 ,不会出现顺序表那种"分配了10000个空间但只用了5001个"的浪费情况。同时,它能充分利用内存中的碎片空间。但会产生大量的空间碎片。
主要局限性:
随机访问性能差:要访问第 i 个元素,必须从头到尾逐个遍历,时间复杂度为 O(n)。而顺序表通过下标访问是 O(1)
额外的内存开销:每个节点除了存储数据本身,还需要额外存储指向下一个节点的指针。而且会产生大量的内存碎片
CPU缓存命中率低:由于节点在内存中是分散存储的,CPU缓存很难连续命中链表的数据,CPU每次读取都要从新扫描一次内存,导致访问速度比连续存储的顺序表要慢得多。
反向遍历困难:单链表只能从头到尾单向遍历。可以使用双向链表改善,而这又会进一步增加每个节点的指针内存开销(需要 prev 和 next 两个指针)。
不支持部分算法:有些依赖随机访问的高效算法(如二分查找、快速排序之类的)在链表上不好实现,但也有办法,先转换为数组,再插入链表,比较麻烦。
终于写完啦,爽!
下一节写双向链表以及链表与顺序表的对比,下面是博客封面:侵删

------青空------
小红车:@白川shiro