目录
一、单链表的概念
单链表是属于线性结构中的一员,其表现在逻辑结构上相邻,物理结构上不一定相邻(通常不相邻)。其底层是由指针来维护线性关系。
形如:
上图就是单链表的结构,理解起来和顺序表相比,较为抽象,为了更好的理解此结构,下面我举一个生动形象的例子用来理解此结构:
火车主要由两部分组成,一个是车头,一个是一节一节的车厢,车厢之间又由一个一个车钩进行连接。
通常我们见到的火车车厢与车厢之间并不是固定死的,而是一个个独立的空间,每当客流量小的时候,就会卸下几节车厢,以免造成空间浪费,客流量大的时候,又会多增加几节车厢,来满足客运需要。
其实上述描述的场景就和单链表的定义,一些功能的实现就极为相似了。
(1)“车头”— 在单链表里表示头指针(这个可要可不要)
(2)“车厢”—在单链表里表示一个一个独立的节点
(3)“车钩”— 在单链表里表示指针,这个指针的含义下文再解释
类比过来,单链表就是这样一个结构:
类比上面的加粗斜体字:
这里plist就是头指针— 既然是指针变量,那他就存储的是地址,那又是谁的地址呢?
经观察,它的地址和下一个结点的地址相同。(这里可不敢肯定这里一定存放的是下一个结点它的地址哦,万一是巧合呢)
再观察,这里的“车厢“也就是结点分为两个部分,一个是数据,一个是指针,而这个指针,存放的也是它的下一个节点的地址。(还是不敢肯定存放的是下一个节点的地址)
再往后观察,发现每一个结点的指针都存放的是其下一个结点它的地址,这时就敢肯定这个地址它存储的就是下一个节点的地址。
观察完毕,这时就可以给这三个结构(头指针,节点,地址)确定详细的定义了:
1)头指针就是指向第一个结点地址的一个指针变量
2)节点分为两个部分,一个用来存储数据的数据域,一个用来存储地址的指针域。
3)点2中的地址存放的是下一个结点它的地址。
通过上面3点定义,再加上单链表在物理结构上它是不相邻的,所以就可以推断出单链表在内存中的存储形式通常是什么样的:
为什么是通常为上图这种形式呢?
因为如果链表申请的空间是一块连续的空间,那每个节点指向的下一个节点地址就是连续的,所以这里为了严谨,描述成通常为上图这种形式。
二、单链表的操作
1.创建单链表结构
单链表是由一个一个节点所构成,所以定义单链表这种结构就是定义节点的结构。
//定义单链表结构 --- 也就是定义节点结构
typedef int SLNDataType; //数据域存储的数据类型重命名
typedef struct SingleListNode
{
SLNDataType data; //数据域 -- 用于存储数据
struct SingleListNode* node; //指针域 -- 用于存储下一个节点的地址
}SLN;
注意这里的指针域的数据类型可不能使用重命名之后的SLN,因为当程序执行到指针域这里的代码时,typedef重命名代码还没执行,所以不可以使用SLN来定义。
2.创建节点
单链表这种数据结构可以不进行初始化,由于每个节点都是单独开辟空间,直接在创建时进行“初始化”即可。
//创建节点
SLN* SLNBuyNode(SLNDataType x)
{
//每创建一个节点就开辟一个节点大小的空间
SLN* newnode = (SLN*)malloc(sizeof(SLN));
if (newnode == NULL)
{
perror("malloc");
exit(1);
}
//初始赋值
newnode->data = x; //数据域赋值为x
newnode->node = NULL; //指针域赋值为NULL
return newnode;
}
3.打印链表
//打印链表
void SLNPrint(SLN** pphead)
{
SLN* pcur = *pphead;
while (pcur) //pcur!=NULL
{
printf("%d-", pcur->data);
pcur = pcur->next; //找到下一节点
}
printf("NULL\n");
}
4.查找节点
//查找节点
SLN* SLNFInd(SLN** pphead, SLNDataType x)
{
assert(pphead); //pphead不能传空
SLN* pcur = *pphead;
while (pcur)
{
if (pcur->data == x)
{
return pcur;
}
pcur = pcur->next; //找向下一个节点
}
return NULL;
}
查找首节点,测试运行:
没有查找到节点,测试运行:
5.尾插
尾插,也就是尾部插入。直接将节点指向NULL的指针改为指向插入的节点地址即可。
当链表为空时,插入的节点即为首节点;
当链表不为空时,要先找到链尾,再将最后一个节点的指针由NULL,指向插入节点的地址。
//尾插
void SLNPush_Back(SLN** pphead, SLNDataType x)
{
//这里不用assert断言,因为空链表也是可以去插入的
//先创建一个节点
SLN* newnode = SLNBuyNode(x);
if (*pphead == NULL)
{
//链表为空
*pphead=newnode;
}
else
{
//链表不为空
//找尾
SLN* ptail = *pphead;
while (ptail->next) //ptail->next!=NULL
{
ptail = ptail->next;
}
//找到了
ptail->next = newnode;
}
时间复杂度:O(N),空间复杂度:O(1)
这里尾插了4个节点:
调试观察:
测试运行:
6.头插
头插,即头部插入。
不管链表是否为空,都将该插入的节点视为首节点。
//头插
void SLNPush_Front(SLN** pphead, SLNDataType x)
{
//这里同样不用assert断言,因为空链表也是可以去插入的
//创建新节点
SLN* newnode = SLNBuyNode(x);
//将插入的节点作为首节点
newnode->next = *pphead;
*pphead = newnode;
}
时间复杂度:O(1),空间复杂度:O(1)
这里头插了4个节点:
调试观察:
测试运行:
7.尾删
尾删,即删除尾部的节点。
当链表只有一个节点时,尾删操作就是直接把此节点销毁掉;
当链表有多个节点时,要先将最后一个节点的前一个节点的指针置为空,再销毁尾节点,如果反过来操作,会导致尾节点的前一个节点指向一个被释放掉的空间,变成野指针。
//尾删
void SLNPop_Back(SLN** pphead)
{
//防止*pphead开始传为空
//链表为空的时候不能再进行删除操作
assert(pphead && (*pphead)!=NULL);
SLN* ptail = *pphead; //找尾节点的指针
SLN* prev = NULL; //保存尾节点的前一个节点
if ((*pphead)->next == NULL)
{
//链表就只有首节点
free(*pphead);
(*pphead) = NULL;
}
else
{
//链表有多个节点
//找尾
while (ptail->next)
{
prev = ptail; //保存上一个节点的地址
ptail = ptail->next; //找下一个节点
}
//找到了
prev->next = NULL; //先将尾节点的前一个节点置为空
//防止先销毁后,指针指向一个被销毁的空间,变成野指针
free(ptail); //再销毁
ptail = NULL;
}
}
时间复杂度:O(N),空间复杂度:O(1)
这里删除之前尾插的4个节点:
测试运行:
再尾删一次,测试运行:
8.头删
头删,即头部删除,这里不分链表中有几个节点,当然不能为空,直接将首节点销毁掉,之后再将首节点的下一个节点作为首节点即可。
//头删
void SLNPop_Front(SLN** pphead)
{
//防止*pphead开始传为空
//链表为空的时候不能再进行删除操作
assert(pphead && (*pphead) != NULL);
SLN* next = (*pphead)->next; //将首节点的下一个节点保存起来
//防止在释放掉首节点的空间后,找不到该节点
free(*pphead);
(*pphead) = NULL;
*pphead = next;
}
时间复杂度:O(1),空间复杂度:O(1)
这里删除之前尾插的4个节点:
测试运行:
再尾删一次,测试运行:
9.任意pos位置插入节点
1)pos位置之前插入数据
当pos在首节点位置时,那么在pos位置之前插入数据就是头插操作;
当pos在非首节点位置时,即下面这种情况:
和尾删一样,不仅要找到尾节点,而且也要找到尾节点的前一个节点;在改变指针指向的时候要严格按照图中顺序进行,若顺序颠倒,则会找不到pos位置的节点。
//在任意pos位置之前插入节点
void SLNInsert(SLN** pphead, SLN* pos, SLNDataType x)
{
//防止*pphead开始传为空
//不能在空位置之前插入数据
assert(pphead && pos);
if (*pphead == pos)
{
//pos位置在首节点 --- 进行头插操作
SLNPush_Front(pphead, x);
}
else
{
//pos位置为非首节点位置
//创建节点
SLN* newnode = SLNBuyNode(x);
SLN* prev = *pphead; //定义pos位置节点的前一个节点
//找到pos位置
while (prev->next != pos)
{
prev = prev->next; //找向下一个节点
}
//找到了
newnode->next = pos;
prev->next = newnode;
}
}
测试运行:
(1)pos在首节点位置
(2)pos在非首节点位置
2)pos位置之后插入数据
pos位置之后插入节点不用管pos在哪个位置(当然pos不能为空),都是如下图的插入方式:
//在任意pos位置之后插入节点
void SLNInsertAfter(SLN** pphead, SLN* pos, SLNDataType x)
{
//防止*pphead开始传为空
//不能在空位置之后插入数据
assert(pphead && pos);
//创建节点
SLN* newnode = SLNBuyNode(x);
//不管pos在哪个位置都是这样改变指针指向
newnode->next = pos->next;
pos->next = newnode;
}
测试运行:
(1)pos在首节点位置插入节点:
(2)pos在尾节点之后插入节点:
10.任意pos位置删除节点
当pos在首节点位置时,删除操作相当于头删;
当pos为非首节点位置时,需要找到pos位置节点的前一个节点,将它的指针指向pos指向的节点地址,再将pos节点销毁。
//在任意pos位置删除节点
void SLNErase(SLN** pphead, SLN* pos)
{
//防止*pphead开始传为空
//不能在空位置删除数据
assert(pphead && pos);
if (pos == *pphead)
{
//pos在首节点位置 --- 头删
SLNPop_Front(pphead);
}
else
{
//pos在非首节点位置
//定义pos位置的前一个节点指针
SLN* prev = *pphead;
//找pos的前一个节点
while (prev->next != pos)
{
prev = prev->next; //找向下一个节点
}
//找到了
prev->next = pos->next;
free(pos);
pos = NULL;
}
}
测试运行:
(1)当pos在首节点位置时:
(2)当pos为非首节点位置时:
11.任意pos位置之后删除节点
这种情况下,函数参数可以不用传首节点的地址,pos能直接锁定其位置,直接销毁掉pos位置之后的节点即可。
//在任意pos位置之后删除节点
void SLNEraseAfter(SLN* pos)
{
//不能在空位置删除节点
//链表为空时,不能再删除
assert(pos && pos->next);
//定义pos的下一个节点
SLN* del = pos->next;
pos->next = del->next;
free(del);
del = NULL;
}
测试运行:
删除首节点位置之后的节点:
12.销毁链表
由于对链表进行操作是一个一个申请节点进行操作的,所以要将每个节点的空间都释放掉。
遍历链表直接释放节点会导致找不到被释放节点的下一个节点地址,所以在释放空间前要先保存下一个空间的节点,再去释放节点空间。
//销毁链表
void SLNDestroy(SLN** pphead)
{
//链表不能传空
assert(pphead);
//定义pcur指针代替*pphead
SLN* pcur = *pphead;
while (pcur->next)
{
//先保存要释放的节点的下一个节点
SLN* next = pcur->next;
free(pcur);
pcur = NULL;
pcur = next;
}
*pphead = NULL; //最后再将链表手动置空。
}
三、单链表与顺序表的区别
顺序表底层是数组,它的逻辑结构是相邻的,物理结构也是相邻的。在进行插入删除操作时,尾插,尾删操作比头删,头插操作时间复杂度更低,效率更高。
链表底层是指针,它的逻辑结构是相邻的,物理结构通常不相邻。在进行插入删除操作时,头插,头删操作比尾删,尾插操作时间复杂度更低,效率更高。
所以以上两种数据结构没有优劣之分,在适合的操作下选择适合的数据结构即可。